diff --git a/Author/alice-enwunder.json b/Author/alice-enwunder.json new file mode 100644 index 0000000..0768378 --- /dev/null +++ b/Author/alice-enwunder.json @@ -0,0 +1,53 @@ +{ + "data": { + "type": "card", + "attributes": { + "firstName": "Alice", + "lastName": "Enwunder", + "bio": null, + "fullBio": null, + "quote": "Curiouser and curiouser", + "contactLinks": [ + { + "label": "X", + "value": "https://x.com/alice-boxel" + }, + { + "label": "LinkedIn", + "value": "https://linkedin.com/alice-boxel" + }, + { + "label": "Email", + "value": "alice@email.com" + } + ], + "email": null, + "featuredImage": { + "imageUrl": "https://boxel-images.boxel.ai/app-assets/portraits/photo-1509868918748-a554ad25f858.jpeg", + "credit": null, + "caption": null, + "altText": "Alice Enwunder", + "size": "actual", + "height": null, + "width": null + }, + "cardInfo": { + "summary": null, + "cardThumbnailURL": "https://boxel-images.boxel.ai/app-assets/portraits/photo-1509868918748-a554ad25f858.jpeg" + } + }, + "relationships": { + "blog": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../author", + "name": "Author" + } + } + } +} diff --git a/SkillListing/06761eda-87a2-4961-b710-084dc00fea5d.json b/SkillListing/06761eda-87a2-4961-b710-084dc00fea5d.json new file mode 100644 index 0000000..c2c1eff --- /dev/null +++ b/SkillListing/06761eda-87a2-4961-b710-084dc00fea5d.json @@ -0,0 +1,114 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "SkillListing", + "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 schema for representing an individual author within a content system. Its primary purpose is to encapsulate key author information such as name, biography, contact links, email, and featured image, providing versatile presentation formats including embedded, atom, fitted, and full bio variants. The component supports detailed author profiles for use in embedded views or isolated displays, enabling consistent and adaptable author representations across interfaces.", + "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/software-development" + } + }, + "categories.1": { + "links": { + "self": "http://localhost:4201/catalog/Category/web-development" + } + }, + "examples.0": { + "links": { + "self": "../Author/alice-enwunder" + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file 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/author.gts b/author.gts new file mode 100644 index 0000000..20bda25 --- /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/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; + } + + }; +}