From c486fc1e6cac047d90b75f68a6f575d14571bdc6 Mon Sep 17 00:00:00 2001 From: Richard Tan <30404522+richardhjtan@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:11:44 +0800 Subject: [PATCH] add Author Profile Card Definition changes [boxel-content-hash:d2e3050fff60] --- .../0b9c06fd-3833-4947-a0b8-ac24b8e71ee7.json | 53 ++ .../583df6bb-5739-418a-9186-978bd72816c1.json | 19 + .../21ff2b33-4432-40e4-abda-f9b3d58a006d.json | 40 + .../2bdaf9b3-d58a-406d-8345-ee6ea57f5a4a.json | 40 + Spec/author.json | 30 + Spec/blog.json | 20 + .../ce16ca21-ff2b-4344-b280-e42bdaf9b3d5.json | 40 + .../daf9b3d5-8a00-4d43-85ee-6ea57f5a4af4.json | 40 + .../ff2b3344-3280-442b-9af9-b3d58a006d43.json | 40 + Spec/fields/contact-link-field.json | 19 + Spec/fields/featured-image-field.json | 19 + .../76694d17-ec6a-4d01-866c-31a378c878fe.json | 109 +++ author.gts | 785 ++++++++++++++++++ blog-app.gts | 554 ++++++++++++ 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 +++++++ review-blog.gts | 46 + 21 files changed, 2826 insertions(+) create mode 100644 Author/0b9c06fd-3833-4947-a0b8-ac24b8e71ee7.json create mode 100644 ReviewBlog/583df6bb-5739-418a-9186-978bd72816c1.json create mode 100644 Spec/21ff2b33-4432-40e4-abda-f9b3d58a006d.json create mode 100644 Spec/2bdaf9b3-d58a-406d-8345-ee6ea57f5a4a.json create mode 100644 Spec/author.json create mode 100644 Spec/blog.json create mode 100644 Spec/ce16ca21-ff2b-4344-b280-e42bdaf9b3d5.json create mode 100644 Spec/daf9b3d5-8a00-4d43-85ee-6ea57f5a4af4.json create mode 100644 Spec/ff2b3344-3280-442b-9af9-b3d58a006d43.json create mode 100644 Spec/fields/contact-link-field.json create mode 100644 Spec/fields/featured-image-field.json create mode 100644 ThemeListing/76694d17-ec6a-4d01-866c-31a378c878fe.json create mode 100644 author.gts create mode 100644 blog-app.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 review-blog.gts diff --git a/Author/0b9c06fd-3833-4947-a0b8-ac24b8e71ee7.json b/Author/0b9c06fd-3833-4947-a0b8-ac24b8e71ee7.json new file mode 100644 index 0000000..6d351cd --- /dev/null +++ b/Author/0b9c06fd-3833-4947-a0b8-ac24b8e71ee7.json @@ -0,0 +1,53 @@ +{ + "data": { + "type": "card", + "attributes": { + "firstName": "Michael", + "lastName": "Anderson", + "bio": "Michael Anderson is an award-winning film critic and cultural commentator with over a decade of experience. He specializes in superhero and sci-fi genres, and is known for his insightful analysis of the Marvel Cinematic Universe. Michael hosts the popular podcast \u201cReel Talk\u201d and contributes regularly to major publications. He holds a degree in Film Studies from USC and is passionate about fostering critical thinking in media consumption.", + "fullBio": "Michael Anderson isn\u2019t just a film critic; he\u2019s a cinematic explorer, navigating the vast universe of film with an insatiable curiosity and a keen eye for the extraordinary. With over a decade of experience, Michael has become a trusted voice in the world of cinema, particularly in the realms of superhero sagas and science fiction spectacles.\n\nBorn in the neon-lit streets of Los Angeles, Michael\u2019s love affair with movies began in the flickering darkness of a small, family-owned theater. It was there, amidst the aroma of buttered popcorn and the whir of film reels, that he first glimpsed the power of storytelling through motion pictures. This childhood fascination evolved into a lifelong passion, eventually leading him to the hallowed halls of USC\u2019s School of Cinematic Arts.\n\nMichael\u2019s writing style is as dynamic as the films he critiques. He possesses a unique ability to dissect complex narratives and visual techniques, presenting them in a way that\u2019s both intellectually stimulating and accessible to the average moviegoer. His reviews are not mere summaries, but thoughtful explorations of a film\u2019s place in the broader cultural context.\n\nWhile Michael\u2019s expertise spans all genres, he\u2019s particularly renowned for his insightful analysis of the Marvel Cinematic Universe. His annual \u201cState of the MCU\u201d articles have become required reading for fans and industry insiders alike. Michael approaches each superhero film with the same reverence he would a Kurosawa classic, finding depth and nuance where others see mere popcorn entertainment.\n\nBeyond the written word, Michael has embraced the digital age of film criticism. His weekly podcast, \u201cReel Talk with Michael Anderson,\u201d features in-depth discussions with filmmakers, actors, and fellow critics. He\u2019s also not afraid to engage in spirited debates on social media, where his witty retorts and thoughtful arguments have earned him a devoted following.\n\nWhen he\u2019s not in a dark theater or hunched over his laptop crafting his latest review, Michael can be found lecturing on film studies at his alma mater or mentoring the next generation of critics through his online workshop series. He believes passionately in the importance of critical thinking in media consumption and strives to foster this skill in others.\n\nAs the landscape of cinema continues to evolve, so too does Michael\u2019s approach to criticism. He remains ever-vigilant, always ready to champion bold new voices in filmmaking or to challenge the industry when it falls short of its potential. For Michael Anderson, every frame is a world waiting to be explored, every film a journey worth taking.", + "quote": "\u201cCinema is not just entertainment; it\u2019s a mirror reflecting the complexities of our world.\u201d", + "contactLinks": [ + { + "label": "Email", + "value": "michael.anderson@companyname.com" + }, + { + "label": "LinkedIn", + "value": "https://linkedin.com/michael-anderson-boxel" + }, + { + "label": "X", + "value": "https://x.com/michael-anderson-boxel" + } + ], + "email": "michael.anderson@companyname.com", + "featuredImage": { + "imageUrl": "https://boxel-images.boxel.ai/app-assets/portraits/photo-1556474835-b0f3ac40d4d1.jpeg", + "credit": null, + "caption": null, + "altText": "Michael Anderson", + "size": "actual", + "height": null, + "width": null + }, + "cardInfo": { + "summary": "Senior Film Critic & Cultural Commentator", + "cardThumbnailURL": "https://boxel-images.boxel.ai/app-assets/portraits/photo-1556474835-b0f3ac40d4d1.jpeg" + } + }, + "relationships": { + "blog": { + "links": { + "self": "../ReviewBlog/583df6bb-5739-418a-9186-978bd72816c1" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../author", + "name": "Author" + } + } + } +} diff --git a/ReviewBlog/583df6bb-5739-418a-9186-978bd72816c1.json b/ReviewBlog/583df6bb-5739-418a-9186-978bd72816c1.json new file mode 100644 index 0000000..4de9614 --- /dev/null +++ b/ReviewBlog/583df6bb-5739-418a-9186-978bd72816c1.json @@ -0,0 +1,19 @@ +{ + "data": { + "type": "card", + "attributes": { + "website": "www.cinereview.com", + "cardInfo": { + "name": "CineReview", + "summary": null, + "cardThumbnailURL": null + } + }, + "meta": { + "adoptsFrom": { + "module": "../review-blog", + "name": "ReviewBlog" + } + } + } +} diff --git a/Spec/21ff2b33-4432-40e4-abda-f9b3d58a006d.json b/Spec/21ff2b33-4432-40e4-abda-f9b3d58a006d.json new file mode 100644 index 0000000..7d926ae --- /dev/null +++ b/Spec/21ff2b33-4432-40e4-abda-f9b3d58a006d.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/2bdaf9b3-d58a-406d-8345-ee6ea57f5a4a.json b/Spec/2bdaf9b3-d58a-406d-8345-ee6ea57f5a4a.json new file mode 100644 index 0000000..1e49eed --- /dev/null +++ b/Spec/2bdaf9b3-d58a-406d-8345-ee6ea57f5a4a.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/author.json b/Spec/author.json new file mode 100644 index 0000000..9681292 --- /dev/null +++ b/Spec/author.json @@ -0,0 +1,30 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "name": "Author", + "module": "../author" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "Author", + "cardDescription": "Spec for Author", + "cardThumbnailURL": null + }, + "relationships": { + "linkedExamples.0": { + "links": { + "self": "../Author/ad28d989-68a8-4bad-a8dc-05f9f724489c" + } + } + }, + "meta": { + "adoptsFrom": { + "module": "https://cardstack.com/base/spec", + "name": "Spec" + } + } + } +} \ No newline at end of file diff --git a/Spec/blog.json b/Spec/blog.json new file mode 100644 index 0000000..010e8c4 --- /dev/null +++ b/Spec/blog.json @@ -0,0 +1,20 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "Blog", + "cardDescription": "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/Spec/ce16ca21-ff2b-4344-b280-e42bdaf9b3d5.json b/Spec/ce16ca21-ff2b-4344-b280-e42bdaf9b3d5.json new file mode 100644 index 0000000..5148ce6 --- /dev/null +++ b/Spec/ce16ca21-ff2b-4344-b280-e42bdaf9b3d5.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/Spec/daf9b3d5-8a00-4d43-85ee-6ea57f5a4af4.json b/Spec/daf9b3d5-8a00-4d43-85ee-6ea57f5a4af4.json new file mode 100644 index 0000000..5ec4c5d --- /dev/null +++ b/Spec/daf9b3d5-8a00-4d43-85ee-6ea57f5a4af4.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/ff2b3344-3280-442b-9af9-b3d58a006d43.json b/Spec/ff2b3344-3280-442b-9af9-b3d58a006d43.json new file mode 100644 index 0000000..7a6f485 --- /dev/null +++ b/Spec/ff2b3344-3280-442b-9af9-b3d58a006d43.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/fields/contact-link-field.json b/Spec/fields/contact-link-field.json new file mode 100644 index 0000000..21f9820 --- /dev/null +++ b/Spec/fields/contact-link-field.json @@ -0,0 +1,19 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "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/Spec/fields/featured-image-field.json b/Spec/fields/featured-image-field.json new file mode 100644 index 0000000..27a39f0 --- /dev/null +++ b/Spec/fields/featured-image-field.json @@ -0,0 +1,19 @@ +{ + "data": { + "type": "card", + "attributes": { + "cardTitle": "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/ThemeListing/76694d17-ec6a-4d01-866c-31a378c878fe.json b/ThemeListing/76694d17-ec6a-4d01-866c-31a378c878fe.json new file mode 100644 index 0000000..6c2e83d --- /dev/null +++ b/ThemeListing/76694d17-ec6a-4d01-866c-31a378c878fe.json @@ -0,0 +1,109 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "ThemeListing", + "module": "http://localhost:4201/catalog/catalog-app/listing/listing" + } + }, + "type": "card", + "attributes": { + "name": "Author Profile Card Definition", + "images": [], + "summary": "The Author component defines a structured representation of an individual author, including personal details such as first name, last name, bio, full bio in markdown, quote, contact links, email, featured image, and related blog. It provides multiple display styles—embedded, atom, and fitted—to accommodate various presentation contexts, from embedded snippets to compact profile summaries and responsive card layouts. The component facilitates structured display of author information with customizable layouts and styles, supporting rich media content and social/contact links.", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "skills": { + "links": { + "self": null + } + }, + "tags.0": { + "links": { + "self": "http://localhost:4201/catalog/Tag/140feda8-625b-4a24-9ddb-6f4da891aef2" + } + }, + "tags.1": { + "links": { + "self": "http://localhost:4201/catalog/Tag/4d0f9ae2-048e-4ce0-b263-7006602ce6a4" + } + }, + "license": { + "links": { + "self": "http://localhost:4201/catalog/License/4c5a023b-a72c-4f90-930b-da60a1de5b2d" + } + }, + "specs.0": { + "links": { + "self": "../Spec/daf9b3d5-8a00-4d43-85ee-6ea57f5a4af4" + } + }, + "specs.1": { + "links": { + "self": "../Spec/author" + } + }, + "specs.2": { + "links": { + "self": "../Spec/blog" + } + }, + "specs.3": { + "links": { + "self": "../Spec/21ff2b33-4432-40e4-abda-f9b3d58a006d" + } + }, + "specs.4": { + "links": { + "self": "../Spec/ff2b3344-3280-442b-9af9-b3d58a006d43" + } + }, + "specs.5": { + "links": { + "self": "../Spec/ce16ca21-ff2b-4344-b280-e42bdaf9b3d5" + } + }, + "specs.6": { + "links": { + "self": "../Spec/2bdaf9b3-d58a-406d-8345-ee6ea57f5a4a" + } + }, + "specs.7": { + "links": { + "self": "../Spec/fields/contact-link-field" + } + }, + "specs.8": { + "links": { + "self": "../Spec/fields/featured-image-field" + } + }, + "publisher": { + "links": { + "self": null + } + }, + "categories.0": { + "links": { + "self": "http://localhost:4201/catalog/Category/38b5d1dc-00d3-4a19-8998-29f0c19081de" + } + }, + "examples.0": { + "links": { + "self": "../Author/0b9c06fd-3833-4947-a0b8-ac24b8e71ee7" + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/author.gts b/author.gts new file mode 100644 index 0000000..fce9491 --- /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); + + 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/blog-app.gts b/blog-app.gts new file mode 100644 index 0000000..cfa1c36 --- /dev/null +++ b/blog-app.gts @@ -0,0 +1,554 @@ +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: { + 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', + module: new URL('./blog-post', import.meta.url).href, + }, + }, + { + displayName: 'Author Bios', + icon: AuthorIcon, + cardTypeName: 'Author', + createNewButtonText: 'Author', + cardRef: { + name: 'Author', + module: new URL('./author', import.meta.url).href, + }, + }, + { + displayName: 'Categories', + icon: CategoriesIcon, + cardTypeName: 'Category', + createNewButtonText: 'Category', + cardRef: { + name: 'BlogCategory', + 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/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..5a46f2d --- /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..4c170ae --- /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/review-blog.gts b/review-blog.gts new file mode 100644 index 0000000..ffa841d --- /dev/null +++ b/review-blog.gts @@ -0,0 +1,46 @@ +import MovieIcon from '@cardstack/boxel-icons/movie'; +import BlogPostIcon from '@cardstack/boxel-icons/newspaper'; +import AuthorIcon from '@cardstack/boxel-icons/square-user'; +import CategoriesIcon from '@cardstack/boxel-icons/hierarchy-3'; +import { type LayoutFilter } from './components/layout'; +import { BlogApp } from './blog-app'; + +export class ReviewBlog extends BlogApp { + static displayName = 'Review Blog'; + static icon = MovieIcon; + + static filterList: LayoutFilter[] = [ + { + displayName: 'Posts', + icon: BlogPostIcon, + cardTypeName: 'Review', + createNewButtonText: 'Post', + showAdminData: true, + sortOptions: BlogApp.sortOptionList, + cardRef: { + name: 'Review', + module: new URL('./review', import.meta.url).href, + }, + }, + { + displayName: 'Authors', + icon: AuthorIcon, + cardTypeName: 'Author', + createNewButtonText: 'Author', + cardRef: { + name: 'Author', + module: new URL('./author', import.meta.url).href, + }, + }, + { + displayName: 'Categories', + icon: CategoriesIcon, + cardTypeName: 'Category', + createNewButtonText: 'Category', + cardRef: { + name: 'BlogCategory', + module: new URL('./blog-category', import.meta.url).href, + }, + }, + ]; +}