From 0a97e311a554caf772e869f46e0c2c52a42e934c Mon Sep 17 00:00:00 2001 From: Boxel Submission Bot Date: Tue, 14 Apr 2026 16:35:12 +0800 Subject: [PATCH] add BilingualBlog changes [boxel-content-hash:b4bc7a36b035] --- .../85de4d7a-e9d8-459d-80a7-d9f0508c9ea1.json | 41 + .../b2b43084-8be5-49c1-894f-7f8ceb6e70e4.json | 134 +++ CatalogEntry/author.json | 30 + CatalogEntry/blog-post.json | 20 + CatalogEntry/blog.json | 20 + CatalogEntry/fields/contact-link-field.json | 19 + CatalogEntry/fields/featured-image-field.json | 19 + .../050d0757-f0bf-4401-bd5b-74cffa5d0c38.json | 40 + .../16b03120-8ae6-4b78-9a10-0314e0050563.json | 40 + .../86d5b48c-277c-49c7-9fc6-cd88dfec16b0.json | 40 + .../98a7b9f4-86d5-448c-a77c-99c7dfc6cd88.json | 40 + .../99c7dfc6-cd88-4fec-96b0-31208ae6eb78.json | 40 + .../b48c277c-99c7-4fc6-8d88-dfec16b03120.json | 40 + .../d5b48c27-7c99-47df-86cd-88dfec16b031.json | 40 + .../dfec16b0-3120-4ae6-ab78-1a100314e005.json | 40 + .../ec16b031-208a-46eb-b81a-100314e00505.json | 40 + author.gts | 785 ++++++++++++++++ bilingual-blog.gts | 16 + blog-app.gts | 558 +++++++++++ blog-category.gts | 221 +++++ blog-post.gts | 868 ++++++++++++++++++ components/card-list.gts | 74 ++ components/grid.gts | 96 ++ components/layout.gts | 217 +++++ components/sort.gts | 123 +++ fields/contact-link.gts | 174 ++++ fields/featured-image.gts | 288 ++++++ user.gts | 18 + 28 files changed, 4081 insertions(+) create mode 100644 BilingualBlog/85de4d7a-e9d8-459d-80a7-d9f0508c9ea1.json create mode 100644 CardListing/b2b43084-8be5-49c1-894f-7f8ceb6e70e4.json create mode 100644 CatalogEntry/author.json create mode 100644 CatalogEntry/blog-post.json create mode 100644 CatalogEntry/blog.json create mode 100644 CatalogEntry/fields/contact-link-field.json create mode 100644 CatalogEntry/fields/featured-image-field.json create mode 100644 Spec/050d0757-f0bf-4401-bd5b-74cffa5d0c38.json create mode 100644 Spec/16b03120-8ae6-4b78-9a10-0314e0050563.json create mode 100644 Spec/86d5b48c-277c-49c7-9fc6-cd88dfec16b0.json create mode 100644 Spec/98a7b9f4-86d5-448c-a77c-99c7dfc6cd88.json create mode 100644 Spec/99c7dfc6-cd88-4fec-96b0-31208ae6eb78.json create mode 100644 Spec/b48c277c-99c7-4fc6-8d88-dfec16b03120.json create mode 100644 Spec/d5b48c27-7c99-47df-86cd-88dfec16b031.json create mode 100644 Spec/dfec16b0-3120-4ae6-ab78-1a100314e005.json create mode 100644 Spec/ec16b031-208a-46eb-b81a-100314e00505.json create mode 100644 author.gts create mode 100644 bilingual-blog.gts create mode 100644 blog-app.gts create mode 100644 blog-category.gts create mode 100644 blog-post.gts create mode 100644 components/card-list.gts create mode 100644 components/grid.gts create mode 100644 components/layout.gts create mode 100644 components/sort.gts create mode 100644 fields/contact-link.gts create mode 100644 fields/featured-image.gts create mode 100644 user.gts diff --git a/BilingualBlog/85de4d7a-e9d8-459d-80a7-d9f0508c9ea1.json b/BilingualBlog/85de4d7a-e9d8-459d-80a7-d9f0508c9ea1.json new file mode 100644 index 0000000..db3a702 --- /dev/null +++ b/BilingualBlog/85de4d7a-e9d8-459d-80a7-d9f0508c9ea1.json @@ -0,0 +1,41 @@ +{ + "data": { + "type": "card", + "attributes": { + "translation": "", + "headline": "", + "slug": null, + "body": "To quote wikipedia:\n\nMicroblogging is a form of blogging using short posts without titles known as microposts[1][2][3] (or status updates on a minority of websites like Meta Platforms'). Microblogs \"allow users to exchange small elements of content such as short sentences, individual images, or video links\",[1] which may be the major reason for their popularity.[4] Some popular social networks such as Twitter, Threads, Mastodon, Tumblr, Koo, and Instagram can be viewed as collections of microblogs.", + "publishDate": null, + "featuredImage": { + "imageUrl": null, + "credit": null, + "caption": null, + "altText": null, + "size": "actual", + "height": null, + "width": null + }, + "cardDescription": null, + "cardThumbnailURL": null + }, + "relationships": { + "authorBio": { + "links": { + "self": null + } + }, + "blog": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../bilingual-blog", + "name": "BilingualBlog" + } + } + } +} \ No newline at end of file diff --git a/CardListing/b2b43084-8be5-49c1-894f-7f8ceb6e70e4.json b/CardListing/b2b43084-8be5-49c1-894f-7f8ceb6e70e4.json new file mode 100644 index 0000000..07cb048 --- /dev/null +++ b/CardListing/b2b43084-8be5-49c1-894f-7f8ceb6e70e4.json @@ -0,0 +1,134 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "CardListing", + "module": "https://realms-staging.stack.cards/catalog/catalog-app/listing/listing" + } + }, + "type": "card", + "attributes": { + "name": "BilingualBlog", + "images": [], + "summary": "The BilingualBlog is a specialized blog post card that extends the standard BlogPost to support content in two languages. It adds a translation field designed to hold a complete French translation of the blog post body. Its primary purpose is to facilitate bilingual content presentation within a card-driven content system.", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "tags.0": { + "links": { + "self": "https://realms-staging.stack.cards/catalog/Tag/4d0f9ae2-048e-4ce0-b263-7006602ce6a4" + } + }, + "specs.0": { + "links": { + "self": "../Spec/050d0757-f0bf-4401-bd5b-74cffa5d0c38" + } + }, + "specs.1": { + "links": { + "self": "../Spec/98a7b9f4-86d5-448c-a77c-99c7dfc6cd88" + } + }, + "specs.2": { + "links": { + "self": "../CatalogEntry/author" + } + }, + "specs.3": { + "links": { + "self": "../CatalogEntry/blog" + } + }, + "specs.4": { + "links": { + "self": "../Spec/d5b48c27-7c99-47df-86cd-88dfec16b031" + } + }, + "specs.5": { + "links": { + "self": "../Spec/86d5b48c-277c-49c7-9fc6-cd88dfec16b0" + } + }, + "specs.6": { + "links": { + "self": "../CatalogEntry/blog-post" + } + }, + "specs.7": { + "links": { + "self": "../Spec/b48c277c-99c7-4fc6-8d88-dfec16b03120" + } + }, + "specs.8": { + "links": { + "self": "../Spec/16b03120-8ae6-4b78-9a10-0314e0050563" + } + }, + "specs.9": { + "links": { + "self": "../Spec/ec16b031-208a-46eb-b81a-100314e00505" + } + }, + "specs.10": { + "links": { + "self": "../Spec/dfec16b0-3120-4ae6-ab78-1a100314e005" + } + }, + "specs.11": { + "links": { + "self": "../CatalogEntry/fields/contact-link-field" + } + }, + "specs.12": { + "links": { + "self": "../CatalogEntry/fields/featured-image-field" + } + }, + "specs.13": { + "links": { + "self": "../Spec/99c7dfc6-cd88-4fec-96b0-31208ae6eb78" + } + }, + "skills": { + "links": { + "self": null + } + }, + "license": { + "links": { + "self": "https://realms-staging.stack.cards/catalog/License/4c5a023b-a72c-4f90-930b-da60a1de5b2d" + } + }, + "publisher": { + "links": { + "self": null + } + }, + "categories.0": { + "links": { + "self": "https://realms-staging.stack.cards/catalog/Category/content-creation" + } + }, + "examples.0": { + "links": { + "self": "../BilingualBlog/85de4d7a-e9d8-459d-80a7-d9f0508c9ea1" + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + }, + "cardInfo.cardThumbnail": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/CatalogEntry/author.json b/CatalogEntry/author.json new file mode 100644 index 0000000..0a7d6a7 --- /dev/null +++ b/CatalogEntry/author.json @@ -0,0 +1,30 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": "what is this going on", + "ref": { + "name": "Author", + "module": "../author" + }, + "specType": "card", + "containedExamples": [], + "title": "Author", + "description": "Spec for Author", + "thumbnailURL": null + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/CatalogEntry/blog-post.json b/CatalogEntry/blog-post.json new file mode 100644 index 0000000..33a8b67 --- /dev/null +++ b/CatalogEntry/blog-post.json @@ -0,0 +1,20 @@ +{ + "data": { + "type": "card", + "attributes": { + "title": "BlogPost", + "description": "Spec for BlogPost", + "specType": "card", + "ref": { + "module": "../blog-post", + "name": "BlogPost" + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/CatalogEntry/blog.json b/CatalogEntry/blog.json new file mode 100644 index 0000000..98b6ecc --- /dev/null +++ b/CatalogEntry/blog.json @@ -0,0 +1,20 @@ +{ + "data": { + "type": "card", + "attributes": { + "title": "Blog", + "description": "Spec for Blog App card", + "ref": { + "module": "../blog-app", + "name": "BlogApp" + }, + "specType": "card" + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/CatalogEntry/fields/contact-link-field.json b/CatalogEntry/fields/contact-link-field.json new file mode 100644 index 0000000..99b9174 --- /dev/null +++ b/CatalogEntry/fields/contact-link-field.json @@ -0,0 +1,19 @@ +{ + "data": { + "type": "card", + "attributes": { + "title": "Contact Link Field", + "specType": "field", + "ref": { + "module": "../../fields/contact-link", + "name": "ContactLinkField" + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/CatalogEntry/fields/featured-image-field.json b/CatalogEntry/fields/featured-image-field.json new file mode 100644 index 0000000..e71fbcd --- /dev/null +++ b/CatalogEntry/fields/featured-image-field.json @@ -0,0 +1,19 @@ +{ + "data": { + "type": "card", + "attributes": { + "title": "Featured Image Field", + "specType": "field", + "ref": { + "module": "../../fields/featured-image", + "name": "FeaturedImageField" + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} diff --git a/Spec/050d0757-f0bf-4401-bd5b-74cffa5d0c38.json b/Spec/050d0757-f0bf-4401-bd5b-74cffa5d0c38.json new file mode 100644 index 0000000..bb81a46 --- /dev/null +++ b/Spec/050d0757-f0bf-4401-bd5b-74cffa5d0c38.json @@ -0,0 +1,40 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "Spec", + "module": "https://cardstack.com/base/spec" + } + }, + "type": "card", + "attributes": { + "ref": { + "name": "BilingualBlog", + "module": "../bilingual-blog" + }, + "title": "BilingualBlog", + "readMe": "# BilingualBlog\n\nThe BilingualBlog card definition extends the base BlogPost card, adding a `translation` field that contains the blog post content translated to French.\n\n## Import\n\nTo import the BilingualBlog code, use the following:\n\n```js\nimport { BilingualBlog } from 'https://realms-staging.stack.cards/experiments/bilingual-blog/bilingual-blog';\n```\n\n## Define Field\n\nThe `translation` field is defined as a `contains(StringField)` field, which means it is an embedded field within the BilingualBlog card.\n\n```js\n@field translation = contains(StringField, {\n description: 'A full translation of the blog post body in French'\n});\n```\n\n## Invoke Template\n\nIn templates, you can access the `translation` field using the fields API:\n\n```hbs\n
\n <@fields.translation />\n
\n```\n\n## Dependencies\n\nThe BilingualBlog card definition depends on the following:\n\n- `BlogPost` card definition\n- `StringField` from the Boxel base API\n- `Langugages` icon from the Boxel icon set\n\n## Usage and Examples\n\nThe BilingualBlog card can be used anywhere a BlogPost card would be used, but with the added benefit of having a French translation of the post body available.\n\nHere's an example of how you might use the BilingualBlog card:\n\n```json\n{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"title\": \"Bienvenue sur notre blog!\",\n \"body\": \"Ceci est le contenu de notre premier article de blog bilingue.\",\n \"translation\": \"Welcome to our blog!\\nThis is the content of our first bilingual blog post.\"\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"./bilingual-blog\",\n \"name\": \"BilingualBlog\"\n }\n }\n }\n}\n```\n\nIn this example, the `BilingualBlog` card is used to display a blog post with both the original French content and the English translation.", + "cardInfo": { + "notes": null, + "title": null, + "description": null, + "thumbnailURL": null + }, + "specType": "card", + "description": null, + "containedExamples": [] + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + }, + "linkedExamples": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/Spec/16b03120-8ae6-4b78-9a10-0314e0050563.json b/Spec/16b03120-8ae6-4b78-9a10-0314e0050563.json new file mode 100644 index 0000000..7a6f485 --- /dev/null +++ b/Spec/16b03120-8ae6-4b78-9a10-0314e0050563.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../components/grid", + "name": "CardsGrid" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "CardsGrid", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/86d5b48c-277c-49c7-9fc6-cd88dfec16b0.json b/Spec/86d5b48c-277c-49c7-9fc6-cd88dfec16b0.json new file mode 100644 index 0000000..d43a72d --- /dev/null +++ b/Spec/86d5b48c-277c-49c7-9fc6-cd88dfec16b0.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../blog-post", + "name": "Status" + }, + "specType": "field", + "containedExamples": [], + "cardTitle": "Status", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/98a7b9f4-86d5-448c-a77c-99c7dfc6cd88.json b/Spec/98a7b9f4-86d5-448c-a77c-99c7dfc6cd88.json new file mode 100644 index 0000000..5ec4c5d --- /dev/null +++ b/Spec/98a7b9f4-86d5-448c-a77c-99c7dfc6cd88.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../author", + "name": "AuthorContactLink" + }, + "specType": "field", + "containedExamples": [], + "cardTitle": "Contact Link", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/99c7dfc6-cd88-4fec-96b0-31208ae6eb78.json b/Spec/99c7dfc6-cd88-4fec-96b0-31208ae6eb78.json new file mode 100644 index 0000000..8a65ef2 --- /dev/null +++ b/Spec/99c7dfc6-cd88-4fec-96b0-31208ae6eb78.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../user", + "name": "User" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "User", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/b48c277c-99c7-4fc6-8d88-dfec16b03120.json b/Spec/b48c277c-99c7-4fc6-8d88-dfec16b03120.json new file mode 100644 index 0000000..7d926ae --- /dev/null +++ b/Spec/b48c277c-99c7-4fc6-8d88-dfec16b03120.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../components/card-list", + "name": "CardList" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "CardList", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/d5b48c27-7c99-47df-86cd-88dfec16b031.json b/Spec/d5b48c27-7c99-47df-86cd-88dfec16b031.json new file mode 100644 index 0000000..15ff05a --- /dev/null +++ b/Spec/d5b48c27-7c99-47df-86cd-88dfec16b031.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../blog-category", + "name": "BlogCategory" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "Blog Category", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/dfec16b0-3120-4ae6-ab78-1a100314e005.json b/Spec/dfec16b0-3120-4ae6-ab78-1a100314e005.json new file mode 100644 index 0000000..1e49eed --- /dev/null +++ b/Spec/dfec16b0-3120-4ae6-ab78-1a100314e005.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../components/sort", + "name": "SortMenu" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "SortMenu", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/ec16b031-208a-46eb-b81a-100314e00505.json b/Spec/ec16b031-208a-46eb-b81a-100314e00505.json new file mode 100644 index 0000000..5148ce6 --- /dev/null +++ b/Spec/ec16b031-208a-46eb-b81a-100314e00505.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../components/layout", + "name": "Layout" + }, + "specType": "component", + "containedExamples": [], + "cardTitle": "Layout", + "cardDescription": null, + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "linkedExamples": { + "links": { + "self": null + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/author.gts b/author.gts new file mode 100644 index 0000000..c9de5d9 --- /dev/null +++ b/author.gts @@ -0,0 +1,785 @@ +import { FeaturedImageField } from './fields/featured-image'; +import MarkdownField from 'https://cardstack.com/base/markdown'; +import TextAreaField from 'https://cardstack.com/base/text-area'; +import { + Component, + CardDef, + field, + contains, + containsMany, + linksTo, + StringField, +} from 'https://cardstack.com/base/card-api'; +import EmailField from 'https://cardstack.com/base/email'; + +import Email from '@cardstack/boxel-icons/mail'; +import Linkedin from '@cardstack/boxel-icons/linkedin'; +import XIcon from '@cardstack/boxel-icons/brand-x'; +import UserIcon from '@cardstack/boxel-icons/user'; +import UserRoundPen from '@cardstack/boxel-icons/user-round-pen'; + +import { cn, not } from '@cardstack/boxel-ui/helpers'; + +import { setBackgroundImage } from './components/layout'; +import { ContactLinkField } from './fields/contact-link'; +import { BlogApp } from './blog-app'; + +class AuthorContactLink extends ContactLinkField { + static values = [ + { + type: 'social', + label: 'X', + icon: XIcon, + cta: 'Follow', + }, + { + type: 'social', + label: 'LinkedIn', + icon: Linkedin, + cta: 'Connect', + }, + { + type: 'email', + label: 'Email', + icon: Email, + cta: 'Contact', + }, + ]; +} + +export class Author extends CardDef { + static displayName = 'Author'; + static icon = UserRoundPen; + @field firstName = contains(StringField); + @field lastName = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: Author) { + let fullName = [this.firstName, this.lastName].filter(Boolean).join(' '); + return fullName.length ? fullName : 'Untitled Author'; + }, + description: 'Full name of author', + }); + @field bio = contains(TextAreaField, { + description: 'Default author bio for embedded and isolated views.', + }); + @field fullBio = contains(MarkdownField, { + description: 'Full bio for isolated view', + }); + @field quote = contains(TextAreaField); + @field contactLinks = containsMany(AuthorContactLink); + @field email = contains(EmailField); + @field featuredImage = contains(FeaturedImageField); + @field blog = linksTo(BlogApp, { isUsed: true }); + + static isolated = class Isolated extends Component { + + }; + + static embedded = class Embedded extends Component { + + }; + + static atom = class Atom extends Component { + + }; + + static fitted = class FittedTemplate extends Component { + + }; +} diff --git a/bilingual-blog.gts b/bilingual-blog.gts new file mode 100644 index 0000000..aceeacc --- /dev/null +++ b/bilingual-blog.gts @@ -0,0 +1,16 @@ +import { + field, + contains, + StringField, +} from 'https://cardstack.com/base/card-api'; +import { BlogPost } from './blog-post'; +import Langugages from '@cardstack/boxel-icons/languages'; + +export class BilingualBlog extends BlogPost { + static displayName = 'BilingualBlog'; + static icon = Langugages; + + @field translation = contains(StringField, { + description: 'A full translation of the blog post body in French', + }); +} diff --git a/blog-app.gts b/blog-app.gts new file mode 100644 index 0000000..45c7b22 --- /dev/null +++ b/blog-app.gts @@ -0,0 +1,558 @@ +import { on } from '@ember/modifier'; +import { action } from '@ember/object'; +import type Owner from '@ember/owner'; +import GlimmerComponent from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; +import { restartableTask } from 'ember-concurrency'; + +import { + CardDef, + Component, + realmURL, + field, + contains, + StringField, + type CardContext, +} from 'https://cardstack.com/base/card-api'; + +import { + type LooseSingleCardDocument, + ResolvedCodeRef, + TypedFilter, +} from '@cardstack/runtime-common'; +import { + type SortOption, + sortByCardTitleAsc, + SortMenu, +} from './components/sort'; +import { CardList } from './components/card-list'; +import { CardsGrid } from './components/grid'; +import { TitleGroup, Layout, type LayoutFilter } from './components/layout'; + +import { + BasicFitted, + BoxelButton, + FieldContainer, + Pill, + ViewSelector, +} from '@cardstack/boxel-ui/components'; +import { eq } from '@cardstack/boxel-ui/helpers'; +import { IconPlus } from '@cardstack/boxel-ui/icons'; + +import CategoriesIcon from '@cardstack/boxel-icons/hierarchy-3'; +import BlogPostIcon from '@cardstack/boxel-icons/newspaper'; +import BlogAppIcon from '@cardstack/boxel-icons/notebook'; +import AuthorIcon from '@cardstack/boxel-icons/square-user'; + +import type { BlogPost } from './blog-post'; + +type ViewOption = 'card' | 'strip' | 'grid'; + +export const toISOString = (datetime: Date) => datetime.toISOString(); + +export const formatDatetime = ( + datetime: Date, + opts: Intl.DateTimeFormatOptions, +) => { + const Format = new Intl.DateTimeFormat('en-US', opts); + return Format.format(datetime); +}; + +const or = function (item1: any, item2: any) { + if (Boolean(item1)) { + return item1; + } else if (Boolean(item2)) { + return item2; + } + return; +}; + +interface CardAdminViewSignature { + Args: { + cardId: string; + context?: CardContext; + }; + Element: HTMLElement; +} +class BlogAdminData extends GlimmerComponent { + + + @tracked resource = this.args.context + ? this.args.context.getCard(this, () => this.args.cardId) + : undefined; + + formattedDate = (datetime: Date) => { + return formatDatetime(datetime, { + year: 'numeric', + month: 'numeric', + day: 'numeric', + hour12: true, + hour: 'numeric', + minute: '2-digit', + }); + }; + + get editors() { + return this.resource?.card && this.resource.card.editors.length > 0 + ? this.resource.card.editors + .map((editor) => + editor.email ? `${editor.name} (${editor.email})` : editor.name, + ) + .join(',') + : 'N/A'; + } +} + +class BlogAppTemplate extends Component { + + + @tracked private selectedView: ViewOption = 'card'; + @tracked private activeFilter: LayoutFilter; + @tracked private filters: LayoutFilter[] = []; + + constructor(owner: Owner, args: any) { + super(owner, args); + this.setFilters(); + this.activeFilter = this.filters[0]; + } + + private get context() { + return this.args.context as CardContext; + } + + private get gridClass() { + let displayName = this.activeFilter.displayName; + let gridName = + displayName === 'Blog Posts' + ? 'blog-posts-grid' + : displayName === 'Author Bios' + ? 'author-bios-grid' + : displayName === 'Categories' + ? 'categories-grid' + : ''; + return gridName ? `bordered-items ${gridName}` : ''; + } + + private setFilters() { + let blogId = this.args.model.id; + + let makeQuery = (codeRef: ResolvedCodeRef) => { + if (!blogId) { + throw new Error('Missing blog id'); + } + + return { + filter: { + on: codeRef, + eq: { 'blog.id': blogId }, + }, + }; + }; + + this.filters = + this.args.model.filters?.map((filter) => { + if (!filter.query && filter.cardRef) { + return { + ...filter, + query: makeQuery(filter.cardRef), + }; + } + return filter; + }) ?? []; + } + + private get selectedSort() { + if (!this.activeFilter.sortOptions?.length) { + return; + } + return this.activeFilter.selectedSort ?? this.activeFilter.sortOptions[0]; + } + + private get showAdminData() { + return this.activeFilter.showAdminData && this.selectedView === 'card'; + } + + private get realms() { + return [this.args.model[realmURL]!]; + } + + private get realmHrefs() { + return this.realms.map((url) => url.href); + } + + private get query() { + return { + ...this.activeFilter.query, + sort: this.selectedSort?.sort ?? sortByCardTitleAsc, + }; + } + + @action private onChangeView(id: ViewOption) { + this.selectedView = id; + } + + @action private onSort(option: SortOption) { + this.activeFilter.selectedSort = option; + this.activeFilter = this.activeFilter; + } + + @action private onFilterChange(filter: LayoutFilter) { + this.activeFilter = filter; + } + + @action private createNew() { + this.createCard.perform(); + } + + private createCard = restartableTask(async () => { + if (!this.activeFilter?.query?.filter) { + throw new Error('Missing active filter'); + } + let ref = (this.activeFilter.query.filter as TypedFilter).on; + + if (!ref) { + throw new Error('Missing card ref'); + } + let currentRealm = this.realms[0]; + let doc: LooseSingleCardDocument = { + data: { + type: 'card', + relationships: { + blog: { + links: { + self: this.args.model.id!, + }, + }, + }, + meta: { + adoptsFrom: ref, + }, + }, + }; + await this.args.createCard?.(ref, currentRealm, { + realmURL: currentRealm, + doc, + }); + }); +} + +// TODO: BlogApp should extend AppCard +// Using type CardDef instead of AppCard from catalog because of +// the many type issues resulting from the lack types from catalog realm +export class BlogApp extends CardDef { + @field website = contains(StringField); + static displayName = 'Blog App'; + static icon = BlogAppIcon; + static prefersWideFormat = true; + static headerColor = '#fff500'; + + static sortOptionList: SortOption[] = [ + { + id: 'datePubDesc', + displayName: 'Date Published', + sort: [ + { + on: { + // @ts-expect-error import.meta is valid ESM but TS detects .gts as CJS + module: new URL('./blog-post', import.meta.url).href, + name: 'BlogPost', + }, + by: 'publishDate', + direction: 'desc', + }, + ], + }, + { + id: 'lastUpdatedDesc', + displayName: 'Last Updated', + sort: [ + { + by: 'lastModified', + direction: 'desc', + }, + ], + }, + { + id: 'cardTitleAsc', + displayName: 'A-Z', + sort: sortByCardTitleAsc, + }, + ]; + + static filterList: LayoutFilter[] = [ + { + displayName: 'Blog Posts', + icon: BlogPostIcon, + cardTypeName: 'Blog Post', + createNewButtonText: 'Post', + showAdminData: true, + sortOptions: BlogApp.sortOptionList, + cardRef: { + name: 'BlogPost', + // @ts-expect-error import.meta is valid ESM but TS detects .gts as CJS + module: new URL('./blog-post', import.meta.url).href, + }, + }, + { + displayName: 'Author Bios', + icon: AuthorIcon, + cardTypeName: 'Author', + createNewButtonText: 'Author', + cardRef: { + name: 'Author', + // @ts-expect-error import.meta is valid ESM but TS detects .gts as CJS + module: new URL('./author', import.meta.url).href, + }, + }, + { + displayName: 'Categories', + icon: CategoriesIcon, + cardTypeName: 'Category', + createNewButtonText: 'Category', + cardRef: { + name: 'BlogCategory', + // @ts-expect-error import.meta is valid ESM but TS detects .gts as CJS + module: new URL('./blog-category', import.meta.url).href, + }, + }, + ]; + + get filters(): LayoutFilter[] { + if (this.constructor && 'filterList' in this.constructor) { + return this.constructor.filterList as LayoutFilter[]; + } + return BlogApp.filterList; + } + + static isolated = BlogAppTemplate; + static fitted = class Fitted extends Component { + + }; +} diff --git a/blog-category.gts b/blog-category.gts new file mode 100644 index 0000000..27908b0 --- /dev/null +++ b/blog-category.gts @@ -0,0 +1,221 @@ +import { + contains, + field, + Component, + CardDef, + linksTo, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import ColorField from 'https://cardstack.com/base/color'; +import { BlogApp as BlogAppCard } from './blog-app'; +import { htmlSafe } from '@ember/template'; +import { cssVar, getContrastColor } from '@cardstack/boxel-ui/helpers'; + +export const categoryStyle = (category: Partial) => { + if (!category) { + return; + } + const pillColor = category.pillColor ?? '#e8e8e8'; // var(--boxel-200) + const borderColor = category.pillColor ?? '#d3d3d3'; // var(--boxel-border-color) + return htmlSafe(` + background-color: ${pillColor}; + color: ${getContrastColor(pillColor, undefined, undefined, { + isSmallText: true, + })}; + border: 1px solid ${borderColor} + `); +}; + +let BlogCategoryTemplate = class Embedded extends Component< + typeof BlogCategory +> { + +}; + +export class BlogCategory extends CardDef { + static displayName = 'Blog Category'; + + @field longName = contains(StringField); + @field shortName = contains(StringField); + @field slug = contains(StringField); + @field pillColor = contains(ColorField); + @field cardDescription = contains(StringField); + @field blog = linksTo(BlogAppCard, { isUsed: true }); + + static embedded = BlogCategoryTemplate; + static isolated = BlogCategoryTemplate; + static atom = class Atom extends Component { + + }; + + static fitted = class FittedTemplate extends Component { + + }; +} diff --git a/blog-post.gts b/blog-post.gts new file mode 100644 index 0000000..5cc9a2f --- /dev/null +++ b/blog-post.gts @@ -0,0 +1,868 @@ +import { FeaturedImageField } from './fields/featured-image'; +import DateTimeField from 'https://cardstack.com/base/datetime'; +import StringField from 'https://cardstack.com/base/string'; +import MarkdownField from 'https://cardstack.com/base/markdown'; +import NumberField from 'https://cardstack.com/base/number'; +import { + CardDef, + field, + contains, + linksTo, + Component, + getCardMeta, + linksToMany, +} from 'https://cardstack.com/base/card-api'; + +import CalendarCog from '@cardstack/boxel-icons/calendar-cog'; +import BlogIcon from '@cardstack/boxel-icons/notebook'; + +import { setBackgroundImage } from './components/layout'; + +import { Author } from './author'; +import { formatDatetime, BlogApp as BlogAppCard } from './blog-app'; +import { BlogCategory, categoryStyle } from './blog-category'; +import { User } from './user'; +import { markdownToHtml } from '@cardstack/runtime-common/marked-sync'; + +class EmbeddedTemplate extends Component { + +} + +class FittedTemplate extends Component { + +} + +class Status extends StringField { + static displayName = 'Status'; + static icon = CalendarCog; +} + +export class BlogPost extends CardDef { + static displayName = 'Blog Post'; + static icon = BlogIcon; + @field headline = contains(StringField); + @field cardTitle = contains(StringField, { + computeVia: function (this: BlogPost) { + return this.headline?.length + ? this.headline + : `Untitled ${this.constructor.displayName}`; + }, + }); + @field slug = contains(StringField); + @field body = contains(MarkdownField); + @field authors = linksToMany(Author); + @field publishDate = contains(DateTimeField); + @field status = contains(Status, { + computeVia: function (this: BlogPost) { + if (!this.publishDate) { + return 'Draft'; + } + if (Date.now() >= Date.parse(String(this.publishDate))) { + return 'Published'; + } + return 'Scheduled'; + }, + }); + @field blog = linksTo(BlogAppCard, { isUsed: true }); + @field featuredImage = contains(FeaturedImageField); + @field categories = linksToMany(BlogCategory); + @field lastUpdated = contains(DateTimeField, { + computeVia: function (this: BlogPost) { + let lastModified = getCardMeta(this, 'lastModified'); + return lastModified ? new Date(lastModified * 1000) : undefined; + }, + }); + @field wordCount = contains(NumberField, { + computeVia: function (this: BlogPost) { + if (!this.body) { + return 0; + } + const plainText = markdownToHtml(this.body).replace( + /<\/?[^>]+(>|$)/g, + '', + ); + return plainText.trim().split(/\s+/).length; + }, + }); + @field editors = linksToMany(User); + + get formattedDatePublished() { + if (this.status === 'Published' && this.publishDate) { + return formatDatetime(this.publishDate, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } + return undefined; + } + + get datePublishedIsoTimestamp() { + if (this.status === 'Published' && this.publishDate) { + return this.publishDate.toISOString(); + } + return undefined; + } + + get formattedLastUpdated() { + return this.lastUpdated + ? formatDatetime(this.lastUpdated, { + year: 'numeric', + month: 'short', + day: 'numeric', + }) + : undefined; + } + + get lastUpdatedIsoTimestamp() { + return this.lastUpdated ? this.lastUpdated.toISOString() : undefined; + } + + get formattedAuthors() { + const authors = this.authors ?? []; + if (authors.length === 0) return undefined; + + const titles = authors.map((author) => author.cardTitle); + + if (titles.length === 2) { + return `${titles[0]} and ${titles[1]}`; + } + + return titles.length > 2 + ? `${titles.slice(0, -1).join(', ')}, and ${titles.at(-1)}` + : titles[0]; + } + + static embedded = EmbeddedTemplate; + static fitted = FittedTemplate; + static isolated = class Isolated extends Component { + + }; +} diff --git a/components/card-list.gts b/components/card-list.gts new file mode 100644 index 0000000..53d210d --- /dev/null +++ b/components/card-list.gts @@ -0,0 +1,74 @@ +import GlimmerComponent from '@glimmer/component'; + +import { type CardContext } from 'https://cardstack.com/base/card-api'; + +import { + type Query, + type PrerenderedCardLike, +} from '@cardstack/runtime-common'; + +interface CardListSignature { + Args: { + query: Query; + realms: string[]; + context?: CardContext; + }; + Blocks: { + meta: [card: PrerenderedCardLike]; + }; + Element: HTMLElement; +} +export class CardList extends GlimmerComponent { + +} diff --git a/components/grid.gts b/components/grid.gts new file mode 100644 index 0000000..d8b5605 --- /dev/null +++ b/components/grid.gts @@ -0,0 +1,96 @@ +import GlimmerComponent from '@glimmer/component'; + +import { type CardContext } from 'https://cardstack.com/base/card-api'; + +import { type Query } from '@cardstack/runtime-common'; + +interface CardsGridSignature { + Args: { + query: Query; + realms: string[]; + selectedView: string; + context?: CardContext; + }; + Element: HTMLElement; +} +export class CardsGrid extends GlimmerComponent { + +} diff --git a/components/layout.gts b/components/layout.gts new file mode 100644 index 0000000..9c4974d --- /dev/null +++ b/components/layout.gts @@ -0,0 +1,217 @@ +import GlimmerComponent from '@glimmer/component'; +import type { TemplateOnlyComponent } from '@ember/component/template-only'; +import { htmlSafe } from '@ember/template'; +import { type CardOrFieldTypeIcon } from 'https://cardstack.com/base/card-api'; +import ImageIcon from '@cardstack/boxel-icons/image'; +import { FilterList } from '@cardstack/boxel-ui/components'; +import { element } from '@cardstack/boxel-ui/helpers'; +import type { Query, ResolvedCodeRef } from '@cardstack/runtime-common'; +import type { SortOption } from './sort'; + +export interface LayoutFilter { + displayName: string; + icon: CardOrFieldTypeIcon; + cardTypeName?: string; + createNewButtonText?: string; + isCreateNewDisabled?: boolean; + cardRef?: ResolvedCodeRef; + query?: Query; + sortOptions?: SortOption[]; + selectedSort?: SortOption; + showAdminData?: boolean; +} + +interface LayoutSignature { + Args: { + filters: LayoutFilter[]; + activeFilter?: LayoutFilter | undefined; + onFilterChange: (filter: LayoutFilter) => void; + }; + Blocks: { + default: []; + sidebar: []; + contentHeader: []; + grid: []; + }; + Element: HTMLElement; +} + +export const setBackgroundImage = ( + backgroundURL: string | null | undefined, +) => { + if (!backgroundURL) { + return; + } + return htmlSafe(`background-image: url(${backgroundURL});`); +}; + +interface TitleGroupSignature { + Args: { + title?: string; + tagline?: string; + thumbnailURL?: string; + icon?: CardOrFieldTypeIcon; + element?: keyof HTMLElementTagNameMap; + }; + Element: HTMLElement; +} +export const TitleGroup: TemplateOnlyComponent = ; + +export class Layout extends GlimmerComponent { + +} diff --git a/components/sort.gts b/components/sort.gts new file mode 100644 index 0000000..415b7ba --- /dev/null +++ b/components/sort.gts @@ -0,0 +1,123 @@ +import { get } from '@ember/object'; +import GlimmerComponent from '@glimmer/component'; + +import { type Sort, baseRealm } from '@cardstack/runtime-common'; + +import { + BoxelButton, + BoxelDropdown, + Menu as BoxelMenu, +} from '@cardstack/boxel-ui/components'; +import { eq, MenuItem } from '@cardstack/boxel-ui/helpers'; +import { DropdownArrowFilled } from '@cardstack/boxel-ui/icons'; +import ArrowDown from '@cardstack/boxel-icons/arrow-down'; +import ArrowUp from '@cardstack/boxel-icons/arrow-up'; + +export const sortByCardTitleAsc: Sort = [ + { + on: { + module: `${baseRealm.url}card-api`, + name: 'CardDef', + }, + by: 'cardTitle', + direction: 'asc', + }, +]; + +export interface SortOption { + id: string; + displayName: string; + sort: Sort; +} + +interface SortMenuSignature { + Args: { + options: SortOption[]; + onSort: (option: SortOption) => void; + selected: SortOption; + }; + Element: HTMLElement; +} +export class SortMenu extends GlimmerComponent { + + + private get sortOptions() { + return this.args.options.map((option) => { + return new MenuItem({ + label: option.displayName, + action: () => this.args.onSort(option), + icon: option.sort?.[0].direction === 'desc' ? ArrowDown : ArrowUp, + checked: + option.displayName === this.args.selected.displayName && + option.sort?.[0].direction === this.args.selected.sort?.[0].direction, + }); + }); + } +} diff --git a/fields/contact-link.gts b/fields/contact-link.gts new file mode 100644 index 0000000..4a2c16b --- /dev/null +++ b/fields/contact-link.gts @@ -0,0 +1,174 @@ +import { + Component, + field, + contains, + StringField, + FieldDef, +} from 'https://cardstack.com/base/card-api'; +import UrlField from 'https://cardstack.com/base/url'; + +import { + BoxelSelect, + FieldContainer, + Pill, +} from '@cardstack/boxel-ui/components'; + +import type IconComponent from '@cardstack/boxel-icons/captions'; +import Email from '@cardstack/boxel-icons/mail'; +import Link from '@cardstack/boxel-icons/link'; +import Phone from '@cardstack/boxel-icons/phone'; + +export interface ContactLink { + type: 'email' | 'tel' | 'link' | string; + label: string; + icon: typeof IconComponent; + cta: string; +} + +const contactValues: ContactLink[] = [ + { + type: 'email', + label: 'Email', + icon: Email, + cta: 'Email', + }, + { + type: 'tel', + label: 'Phone', + icon: Phone, + cta: 'Contact', + }, + { + type: 'link', + label: 'Other', + icon: Link, + cta: 'Connect', + }, +]; + +export class ContactLinkField extends FieldDef { + static displayName = 'Contact Link'; + static values: ContactLink[] = contactValues; + @field label = contains(StringField); + @field value = contains(StringField); + @field url = contains(UrlField, { + computeVia: function (this: ContactLinkField) { + switch (this.item?.type) { + case 'email': + return `mailto:${this.value}`; + case 'tel': + return `tel:${this.value}`; + default: + return this.value; + } + }, + }); + get items() { + if (this.constructor && 'values' in this.constructor) { + return this.constructor.values as ContactLink[]; + } + return ContactLinkField.values; + } + get item() { + return this.items?.find((val) => val.label === this.label); + } + static edit = class Edit extends Component { + + + options = this.args.model.items; + + onSelect = (option: ContactLink) => (this.args.model.label = option.label); + + get selectedOption() { + return this.options?.find( + (option) => option.label === this.args.model.label, + ); + } + + get label() { + switch (this.selectedOption?.type) { + case 'email': + return 'Address'; + case 'tel': + return 'Number'; + default: + return 'Link'; + } + } + }; + static atom = class Atom extends Component { + + }; + static embedded = class Embedded extends Component { + + }; +} diff --git a/fields/featured-image.gts b/fields/featured-image.gts new file mode 100644 index 0000000..14f8eb5 --- /dev/null +++ b/fields/featured-image.gts @@ -0,0 +1,288 @@ +import { hash } from '@ember/helper'; +import { htmlSafe } from '@ember/template'; +import { + Component, + field, + contains, + StringField, + FieldDef, +} from 'https://cardstack.com/base/card-api'; +import NumberField from 'https://cardstack.com/base/number'; +import { ImageSizeField } from 'https://cardstack.com/base/base64-image'; +import UrlField from 'https://cardstack.com/base/url'; +import { FieldContainer } from '@cardstack/boxel-ui/components'; +import { FailureBordered } from '@cardstack/boxel-ui/icons'; +import PhotoIcon from '@cardstack/boxel-icons/photo'; +import { setBackgroundImage } from '../components/layout'; + +function cssForFeaturedImage({ + imageUrl, + size, + height, + width, +}: { + imageUrl: string | undefined; + size: 'actual' | 'contain' | 'cover' | undefined; + height?: number; + width?: number; +}) { + if (!imageUrl) { + return undefined; + } + + let css: string[] = []; + css.push(`background-image: url("${imageUrl}");`); + if (size && ['contain', 'cover'].includes(size)) { + css.push(`background-size: ${size};`); + } + if (height) { + css.push(`height: ${height}px;`); + } + if (width) { + css.push(`width: ${width}px`); + } else { + css.push(`width: 100%`); + } + return htmlSafe(css.join(' ')); +} + +export class FeaturedImageField extends FieldDef { + static displayName = 'Featured Image'; + static icon = PhotoIcon; + @field imageUrl = contains(UrlField); + @field credit = contains(StringField); + @field caption = contains(StringField); + @field altText = contains(StringField); + @field size = contains(ImageSizeField); + @field height = contains(NumberField); + @field width = contains(NumberField); + static edit = class Edit extends Component { + get usesActualSize() { + return this.args.model.size === 'actual' || this.args.model.size == null; + } + + get backgroundMaskStyle() { + let css: string[] = []; + if (this.args.model.height) { + css.push(`height: ${this.args.model.height}px;`); + } + if (this.args.model.width) { + css.push(`width: ${this.args.model.width}px`); + } + return htmlSafe(css.join(' ')); + } + + get needsHeight() { + return ( + (this.args.model.size === 'contain' || + this.args.model.size === 'cover') && + !this.args.model.height + ); + } + + + }; + + static atom = class Atom extends Component { + + }; + static embedded = class Embedded extends Component { + get usesActualSize() { + return this.args.model.size === 'actual' || this.args.model.size == null; + } + + }; +} diff --git a/user.gts b/user.gts new file mode 100644 index 0000000..cb407b4 --- /dev/null +++ b/user.gts @@ -0,0 +1,18 @@ +import { + CardDef, + StringField, + contains, + field, +} from 'https://cardstack.com/base/card-api'; +import EmailField from 'https://cardstack.com/base/email'; + +export class User extends CardDef { + static displayName = 'User'; + @field name = contains(StringField); + @field email = contains(EmailField); + @field cardTitle = contains(StringField, { + computeVia: function (this: User) { + return this.name; + }, + }); +}