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 {
+
+
+
+
+ <@fields.contactLinks @format='atom' />
+
+ {{#if @model.bio}}
+ <@fields.bio />
+ {{/if}}
+ {{#if @model.fullBio}}
+ <@fields.fullBio />
+ {{/if}}
+
+
+
+ };
+
+ static embedded = class Embedded extends Component {
+
+
+
+ {{#unless @model.cardThumbnailURL}}
+
+ {{/unless}}
+
+
+ <@fields.bio />
+
+ <@fields.contactLinks @format='embedded' />
+
+
+
+
+ };
+
+ static atom = class Atom extends Component {
+
+
+ {{#if @model.cardThumbnailURL}}
+
+ {{else}}
+
+ {{/if}}
+
+ <@fields.cardTitle />
+
+
+
+
+ };
+
+ static fitted = class FittedTemplate extends Component {
+
+
+
+ {{#unless @model.cardThumbnailURL}}
+
+ {{/unless}}
+
+
+ <@fields.bio />
+ <@fields.contactLinks @format='atom' />
+
+
+
+ };
+}
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 {
+
+ {{#if this.resource.cardError}}
+ Error: Could not load additional info
+ {{else if this.resource.card}}
+
+ {{#let this.resource.card as |card|}}
+
+ {{#if card.publishDate}}
+
+ {{else}}
+ N/A
+ {{/if}}
+
+
+ {{#if card.lastUpdated}}
+
+ {{else}}
+ N/A
+ {{/if}}
+
+
+ {{if card.wordCount card.wordCount 0}}
+
+
+ {{this.editors}}
+
+
+ {{card.status}}
+
+ {{/let}}
+
+ {{/if}}
+
+
+
+ @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 {
+
+
+ <:sidebar>
+
+ {{#if @createCard}}
+
+ {{/if}}
+
+ <:contentHeader>
+ {{this.activeFilter.displayName}}
+
+ {{#if this.activeFilter.sortOptions.length}}
+ {{#if this.selectedSort}}
+
+ {{/if}}
+ {{/if}}
+
+ <:grid>
+ {{#if this.query}}
+ {{#if (eq this.selectedView 'card')}}
+
+ <:meta as |card|>
+ {{#if this.showAdminData}}
+
+ {{/if}}
+
+
+ {{else}}
+
+ {{/if}}
+ {{/if}}
+
+
+
+
+
+ @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
+> {
+
+
+
+
+ <@fields.shortName />
+
+
+ Category
+
+
+ <@fields.longName />
+
+
<@fields.cardDescription />
+
+
+};
+
+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 {
+
+
+
+
+ <@fields.longName />
+
+
+ };
+
+ static fitted = class FittedTemplate extends Component {
+
+
+
+
+ <@fields.shortName />
+
+
+ Category
+
+
+ <@fields.longName />
+
+
<@fields.cardDescription />
+
+
+ };
+}
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 {
+
+
+
+ {{#if @model.categories.length}}
+
+ {{#each @model.categories as |category|}}
+
+ {{category.shortName}}
+
+ {{/each}}
+
+ {{/if}}
+ <@fields.cardTitle />
+ {{@model.cardDescription}}
+
+ {{@model.formattedAuthors}}
+
+ {{#if @model.datePublishedIsoTimestamp}}
+
+ {{/if}}
+
+
+
+}
+
+class FittedTemplate extends Component {
+
+
+
+
+ {{#each @model.categories as |category|}}
+
+ {{category.shortName}}
+
+ {{/each}}
+
+
+
<@fields.cardTitle />
+
{{@model.cardDescription}}
+ {{#if @model.formattedAuthors}}
+
{{@model.formattedAuthors}}
+ {{/if}}
+ {{#if @model.datePublishedIsoTimestamp}}
+
+ {{/if}}
+
+
+
+
+}
+
+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 {
+
+
+
+ {{#if @model.blog}}
+ <@fields.blog class='blog' @displayContainer={{false}} />
+ {{/if}}
+ {{#if @model.featuredImage.imageUrl}}
+ <@fields.featuredImage class='featured-image' />
+ {{/if}}
+ {{#if @model.categories.length}}
+
+ {{#each @model.categories as |category|}}
+
+ {{category.shortName}}
+
+ {{/each}}
+
+ {{/if}}
+ <@fields.cardTitle />
+ {{#if @model.cardDescription}}
+
+ <@fields.cardDescription />
+
+ {{/if}}
+
+ {{#if @model.authors.length}}
+ -
+ {{#each @fields.authors as |AuthorComponent|}}
+
+ {{/each}}
+
+ {{/if}}
+ {{#if @model.datePublishedIsoTimestamp}}
+ -
+ Published on
+
+
+ {{/if}}
+ {{#if @model.lastUpdatedIsoTimestamp}}
+ -
+ Last Updated on
+
+
+ {{/if}}
+
+
+ <@fields.body />
+ {{#if @model.authors.length}}
+ <@fields.authors class='author-embedded-bio' @format='embedded' />
+ {{/if}}
+
+
+
+ };
+}
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 {
+
+
+ {{#let
+ (component @context.prerenderedCardSearchComponent)
+ as |PrerenderedCardSearch|
+ }}
+
+ <:loading>
+ Loading...
+
+ <:response as |cards|>
+ {{#each cards key='url' as |card|}}
+ -
+
+ {{#if (has-block 'meta')}}
+ {{yield card to='meta'}}
+ {{/if}}
+
+ {{/each}}
+
+
+ {{/let}}
+
+
+
+}
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 {
+
+
+ {{#let
+ (component @context.prerenderedCardSearchComponent)
+ as |PrerenderedCardSearch|
+ }}
+
+ <:loading>
+ Loading...
+
+ <:response as |cards|>
+ {{#each cards key='url' as |card|}}
+ -
+
+
+ {{/each}}
+
+
+ {{/let}}
+
+
+
+}
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 =
+ {{#let (element @element) as |Tag|}}
+
+ {{#if @thumbnailURL}}
+
+ {{else if @icon}}
+
+ <@icon class='icon' width='24' height='24' />
+
+ {{else}}
+
+
+
+ {{/if}}
+ {{@title}}
+ {{@tagline}}
+
+ {{/let}}
+
+;
+
+export class Layout extends GlimmerComponent {
+
+
+
+
+
+ {{#if (has-block 'grid')}}
+
+ {{yield to='grid'}}
+
+ {{/if}}
+
+
+
+
+}
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 {
+
+
+ Sort by
+
+ <:trigger as |bindings|>
+
+
+ {{@selected.displayName}}
+ {{#if (eq (get @selected.sort '0.direction') 'desc')}}
+
+ {{else}}
+
+ {{/if}}
+
+
+
+
+ <:content as |dd|>
+
+
+
+
+
+
+
+ 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 {
+
+
+
+ {{item.label}}
+
+
+
+ <@fields.value />
+
+
+
+
+ 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 {
+
+ {{#if @model.url}}
+
+ <@fields.label />
+ <@model.item.icon height='20' width='20' />
+
+ {{/if}}
+
+
+ };
+ static embedded = class Embedded extends Component {
+
+ {{#if @model.url}}
+
+ <:iconLeft>
+ <@model.item.icon height='20' width='20' />
+
+ <:default>
+ {{@model.item.cta}}
+
+
+ {{/if}}
+
+
+ };
+}
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
+ );
+ }
+
+
+
+ {{#if @model.imageUrl}}
+
+ {{#if this.needsHeight}}
+
+
+ Can't render current image. Please provide a height when using
+ the "contain" or "cover" size.
+
+ {{else if this.usesActualSize}}
+
+ {{else}}
+
+ {{/if}}
+
+ {{/if}}
+
+ <@fields.imageUrl />
+
+
+ <@fields.altText />
+
+
+ <@fields.caption />
+
+
+ <@fields.credit />
+
+
+ <@fields.size />
+
+
+ <@fields.height />
+
+
+ <@fields.width />
+
+
+
+
+
+ };
+
+ static atom = class Atom extends Component {
+
+ {{#if @model.imageUrl}}
+
+ {{/if}}
+
+
+ };
+ static embedded = class Embedded extends Component {
+ get usesActualSize() {
+ return this.args.model.size === 'actual' || this.args.model.size == null;
+ }
+
+ {{#if @model.imageUrl}}
+
+ {{#if this.usesActualSize}}
+
+ {{else}}
+
+
+ {{/if}}
+
+ <@fields.credit />
+ <@fields.caption />
+
+
+ {{/if}}
+
+
+ };
+}
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;
+ },
+ });
+}