From 59ed395586247bcfdaea52a3d27c175ef8b03365 Mon Sep 17 00:00:00 2001 From: Boxel Submission Bot Date: Wed, 25 Mar 2026 16:46:06 +0800 Subject: [PATCH] add Recipe Card changes [boxel-content-hash:ba5ed7a5f5c8] --- .../a5e22517-118e-4b99-b69f-6046eeae4045.json | 69 + .../c71c62b2-501b-45e0-9a13-34c9729e2b14.json | 37 + .../df451c49-fa03-406e-a7f5-0d0bd1d1f9b8.json | 40 + .../f0df451c-49fa-4330-aee7-f50d0bd1d1f9.json | 40 + recipe-card.gts | 1167 +++++++++++++++++ 5 files changed, 1353 insertions(+) create mode 100644 AppListing/a5e22517-118e-4b99-b69f-6046eeae4045.json create mode 100644 RecipeCard/c71c62b2-501b-45e0-9a13-34c9729e2b14.json create mode 100644 Spec/df451c49-fa03-406e-a7f5-0d0bd1d1f9b8.json create mode 100644 Spec/f0df451c-49fa-4330-aee7-f50d0bd1d1f9.json create mode 100644 recipe-card.gts diff --git a/AppListing/a5e22517-118e-4b99-b69f-6046eeae4045.json b/AppListing/a5e22517-118e-4b99-b69f-6046eeae4045.json new file mode 100644 index 0000000..1e47420 --- /dev/null +++ b/AppListing/a5e22517-118e-4b99-b69f-6046eeae4045.json @@ -0,0 +1,69 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "AppListing", + "module": "https://realms-staging.stack.cards/catalog/catalog-app/listing/listing" + } + }, + "type": "card", + "attributes": { + "name": "Recipe Card", + "images": [], + "summary": "The RecipeCard defines a comprehensive representation of a cooking recipe, encapsulating details such as the recipe name, description, preparation and cooking times, servings, difficulty level, cuisine type, ingredients, cooking instructions, tips, and tags. It provides multiple formatting options—embedded, fitted, isolated, tile, and card—that adapt the presentation to various display contexts. This card also computes total time, displays key stats visually, and supports rich content with markdown instructions and nested ingredient components. Its primary purpose is to serve as a structured, flexible, and visually organized recipe profile within a catalog or culinary application.", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + } + }, + "relationships": { + "tags": { + "links": { + "self": null + } + }, + "specs.0": { + "links": { + "self": "../Spec/f0df451c-49fa-4330-aee7-f50d0bd1d1f9" + } + }, + "specs.1": { + "links": { + "self": "../Spec/df451c49-fa03-406e-a7f5-0d0bd1d1f9b8" + } + }, + "skills": { + "links": { + "self": null + } + }, + "license": { + "links": { + "self": null + } + }, + "publisher": { + "links": { + "self": null + } + }, + "categories": { + "links": { + "self": null + } + }, + "examples.0": { + "links": { + "self": "../RecipeCard/c71c62b2-501b-45e0-9a13-34c9729e2b14" + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/RecipeCard/c71c62b2-501b-45e0-9a13-34c9729e2b14.json b/RecipeCard/c71c62b2-501b-45e0-9a13-34c9729e2b14.json new file mode 100644 index 0000000..c27880a --- /dev/null +++ b/RecipeCard/c71c62b2-501b-45e0-9a13-34c9729e2b14.json @@ -0,0 +1,37 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "RecipeCard", + "module": "../recipe-card" + } + }, + "type": "card", + "attributes": { + "tags": [], + "tips": null, + "cuisine": null, + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, + "cookTime": null, + "prepTime": null, + "servings": null, + "difficulty": null, + "recipeName": "Aglio Olio Pasta", + "description": null, + "ingredients": [], + "instructions": null + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/Spec/df451c49-fa03-406e-a7f5-0d0bd1d1f9b8.json b/Spec/df451c49-fa03-406e-a7f5-0d0bd1d1f9b8.json new file mode 100644 index 0000000..1b3fbce --- /dev/null +++ b/Spec/df451c49-fa03-406e-a7f5-0d0bd1d1f9b8.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../recipe-card", + "name": "RecipeCard" + }, + "specType": "card", + "containedExamples": [], + "cardTitle": "Recipe", + "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/f0df451c-49fa-4330-aee7-f50d0bd1d1f9.json b/Spec/f0df451c-49fa-4330-aee7-f50d0bd1d1f9.json new file mode 100644 index 0000000..851c497 --- /dev/null +++ b/Spec/f0df451c-49fa-4330-aee7-f50d0bd1d1f9.json @@ -0,0 +1,40 @@ +{ + "data": { + "type": "card", + "attributes": { + "readMe": null, + "ref": { + "module": "../recipe-card", + "name": "IngredientField" + }, + "specType": "field", + "containedExamples": [], + "cardTitle": "Ingredient", + "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/recipe-card.gts b/recipe-card.gts new file mode 100644 index 0000000..4fb3aad --- /dev/null +++ b/recipe-card.gts @@ -0,0 +1,1167 @@ +import { and } from '@cardstack/boxel-ui/helpers'; +// ═══ [EDIT TRACKING: ON] Mark all changes with ⁿ ═══ +import { + CardDef, + FieldDef, + field, + contains, + containsMany, + Component, +} from 'https://cardstack.com/base/card-api'; // ¹ +import StringField from 'https://cardstack.com/base/string'; // ² +import NumberField from 'https://cardstack.com/base/number'; // ³ +import TextAreaField from 'https://cardstack.com/base/text-area'; // ⁴ +import MarkdownField from 'https://cardstack.com/base/markdown'; // ⁵ +import enumField from 'https://cardstack.com/base/enum'; // ⁶ +import UtensilsIcon from '@cardstack/boxel-icons/utensils'; // ⁷ +import ClockIcon from '@cardstack/boxel-icons/clock'; // ⁸ +import UsersIcon from '@cardstack/boxel-icons/users'; // ⁹ +import ChefHatIcon from '@cardstack/boxel-icons/chef-hat'; // ¹⁰ + +// ¹¹ Difficulty enum +const DifficultyField = enumField(StringField, { + options: ['Easy', 'Medium', 'Hard', 'Expert'], +}); + +// ¹² Ingredient field definition +export class IngredientField extends FieldDef { + // ¹³ + static displayName = 'Ingredient'; + + @field name = contains(StringField); // ¹⁴ + @field amount = contains(StringField); // ¹⁵ + @field unit = contains(StringField); // ¹⁶ + @field notes = contains(StringField); // ¹⁷ + + static embedded = class Embedded extends Component { + // ¹⁸ + + }; + + static atom = class Atom extends Component { + // ¹⁹ + + }; +} + +// ²⁰ Main recipe card +export class RecipeCard extends CardDef { + // ²¹ + static displayName = 'Recipe'; + static icon = UtensilsIcon; // ²² + static prefersWideFormat = true; // ²³ + + @field recipeName = contains(StringField); // ²⁴ + @field description = contains(TextAreaField); // ²⁵ + @field prepTime = contains(NumberField); // ²⁶ in minutes + @field cookTime = contains(NumberField); // ²⁷ in minutes + @field servings = contains(NumberField); // ²⁸ + @field difficulty = contains(DifficultyField); // ²⁹ + @field cuisine = contains(StringField); // ³⁰ + @field ingredients = containsMany(IngredientField); // ³¹ + @field instructions = contains(MarkdownField); // ³² + @field tips = contains(TextAreaField); // ³³ + @field tags = containsMany(StringField); // ³⁴ + + @field cardTitle = contains(StringField, { + // ³⁵ + computeVia: function (this: RecipeCard) { + return this.cardInfo?.name ?? this.recipeName ?? 'Untitled Recipe'; + }, + }); + + // ³⁶ Total time computed + get totalTimeDisplay() { + // ³⁷ + try { + const prep = this.prepTime ?? 0; + const cook = this.cookTime ?? 0; + const total = prep + cook; + if (total === 0) return null; + if (total >= 60) { + const hours = Math.floor(total / 60); + const mins = total % 60; + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; + } + return `${total}m`; + } catch (e) { + return null; + } + } + + // ³⁸ Isolated format + static isolated = class Isolated extends Component { + get prepDisplay() { + // ³⁹ + try { + const v = this.args.model?.prepTime; + if (!v) return null; + return v >= 60 + ? `${Math.floor(v / 60)}h ${v % 60 > 0 ? (v % 60) + 'm' : ''}`.trim() + : `${v}m`; + } catch (e) { + return null; + } + } + + get cookDisplay() { + // ⁴⁰ + try { + const v = this.args.model?.cookTime; + if (!v) return null; + return v >= 60 + ? `${Math.floor(v / 60)}h ${v % 60 > 0 ? (v % 60) + 'm' : ''}`.trim() + : `${v}m`; + } catch (e) { + return null; + } + } + + get totalDisplay() { + // ⁴¹ + try { + const prep = this.args.model?.prepTime ?? 0; + const cook = this.args.model?.cookTime ?? 0; + const total = prep + cook; + if (total === 0) return null; + return total >= 60 + ? `${Math.floor(total / 60)}h ${total % 60 > 0 ? (total % 60) + 'm' : ''}`.trim() + : `${total}m`; + } catch (e) { + return null; + } + } + + get difficultyColor() { + // ⁴² + const map: Record = { + Easy: 'var(--chart-2)', + Medium: 'var(--chart-3)', + Hard: 'var(--chart-4)', + Expert: 'var(--chart-1)', + }; + return ( + map[this.args.model?.difficulty ?? ''] ?? 'var(--muted-foreground)' + ); + } + + + }; + + // ⁴⁴ Embedded format + static embedded = class Embedded extends Component { + get totalDisplay() { + // ⁴⁵ + try { + const prep = this.args.model?.prepTime ?? 0; + const cook = this.args.model?.cookTime ?? 0; + const total = prep + cook; + if (total === 0) return null; + return total >= 60 + ? `${Math.floor(total / 60)}h ${total % 60 > 0 ? (total % 60) + 'm' : ''}`.trim() + : `${total}m`; + } catch (e) { + return null; + } + } + + + }; + + // ⁴⁶ Fitted format + static fitted = class Fitted extends Component { + get totalDisplay() { + // ⁴⁷ + try { + const prep = this.args.model?.prepTime ?? 0; + const cook = this.args.model?.cookTime ?? 0; + const total = prep + cook; + if (total === 0) return null; + return total >= 60 + ? `${Math.floor(total / 60)}h ${total % 60 > 0 ? (total % 60) + 'm' : ''}`.trim() + : `${total}m`; + } catch (e) { + return null; + } + } + + + }; +}