From bf8d10106f163960797990451e90e5132abde244 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 2 Mar 2026 13:47:56 +0800 Subject: [PATCH 01/19] add markdown planning for submission port --- .../catalog-realm/submission-card/README.md | 274 ++++++++++++++++++ 1 file changed, 274 insertions(+) create mode 100644 packages/catalog-realm/submission-card/README.md diff --git a/packages/catalog-realm/submission-card/README.md b/packages/catalog-realm/submission-card/README.md new file mode 100644 index 00000000000..6ff060edc1a --- /dev/null +++ b/packages/catalog-realm/submission-card/README.md @@ -0,0 +1,274 @@ +# Submission Card Portal + +## Overview + +The `SubmissionCardPortal` is an app-level card that displays a prerendered grid of `SubmissionCard` instances. It lives in the **User Realm** (`/submissions/`) and serves as the user's personal submission tracker — showing the status of all their submitted catalog listings. + +The portal hides all GitHub technical details. Users care about *"Did my submission pass?"*, not *"What is the PR number?"*. + +--- + +## System Architecture + +``` +[ GitHub Repo ] + ↓ (webhook) +[ PR Card ] ──────────── Data Source Realm (read-only, auto-generated, no user access) + ↓ (referenced by) +[ Submission Card ] ───── User Realm /submissions/ (user can create, delete, archive) + ↓ +[ Submission Portal ] ─── User Realm /submissions/ (lists all submission cards) +``` + +### Data Source Realm — PR Card + +| Property | Value | +|----------|-------| +| Location | Data Source Realm (backend) | +| Driven by | GitHub webhook events | +| User access | Read-only — cannot be edited, deleted, or mutated | +| Purpose | Reflects the raw GitHub PR state | + +### User Realm — Submission Card + Portal + +| Property | Value | +|----------|-------| +| Location | `/submissions/` realm | +| User access | Create, delete, archive | +| Purpose | User-facing status view of their submission | +| Note | Deleting a Submission Card does **not** delete the PR — it only removes the reference | + +--- + +## Realm Location + +Both files belong in the `/submissions/` realm, not the catalog realm: + +``` +submission-card/ +├── README.md ← this file +├── submission-card-portal.gts ← portal app card → /submissions/ +└── submission-card.gts ← submission card → /submissions/ +``` + +--- + +## Submission Card — Permissions + +| Action | Allowed | Notes | +|--------|---------|-------| +| Create | Yes | Auto-created when user triggers PR submission | +| Delete | Yes | Removes reference only — PR still exists on GitHub | +| Archive | Yes | | +| Edit user fields | Partial | Only user-facing fields; `branchName` and `githubURL` are computed | +| Edit PR Card | No | PR Card lives in read-only Data Source Realm | + +--- + +## Submission Card — What to Show + +Show only user-facing status. **Hide all GitHub technical details.** + +| Field | Show | Notes | +|-------|------|-------| +| Listing name | Yes | `listing.name` — primary title | +| Status pill | Yes | `pending` / `open` / `merged` / `closed` / `changes_requested` | +| Submitted date | Yes | `createdAt` timestamp | +| Pass / Reject indicator | Yes | Derived from `status` | +| Branch name | Optional | Secondary, small label | +| GitHub PR link | Optional | Icon link only | +| Reviewer comments | No | Too technical | +| Commit history | No | Too technical | +| File contents | No | Too technical | + +> Users say *"I submitted something"* — not *"I created a PR"*. The card reflects submission state, not GitHub internals. + +--- + +## SubmissionCard Schema + +### FileContentField + +| Field | Type | Description | +|-------|------|-------------| +| `filename` | `StringField` | Name of the submitted file | +| `contents` | `StringField` | Raw file contents | + +### SubmissionCard + +| Field | Type | Description | +|-------|------|-------------| +| `cardTitle` | `StringField` (computed) | Derived from `listing.name` or `listing.cardTitle`, falls back to `'Untitled Submission'` | +| `roomId` | `StringField` | Matrix room ID used for bot status updates | +| `branchName` | `StringField` | GitHub branch name for the submitted PR | +| `githubURL` | `StringField` (computed) | Built from `branchName` using `GITHUB_BRANCH_URL_PREFIX` + URL-encoded segments | +| `listing` | `linksTo(Listing)` | The catalog listing being submitted | +| `allFileContents` | `containsMany(FileContentField)` | All files included in the PR submission | +| `status` | `StringField` | `pending` \| `open` \| `merged` \| `closed` \| `changes_requested` — updated by webhook | +| `createdAt` | `DatetimeField` | When the submission was created | + +### Computed Field Notes + +- **`cardTitle`** — read-only, auto-derived from the linked listing. +- **`githubURL`** — read-only, auto-derived from `branchName`. Each `/`-separated segment is individually `encodeURIComponent`-encoded. +- **`status`** — written by the webhook/bot, not by the user directly. + +### Edge Cases + +| Scenario | Behaviour | +|----------|-----------| +| No submissions yet | Portal shows empty state UI | +| `listing` deleted | `cardTitle` falls back to `'Untitled Submission'` | +| `branchName` empty | `githubURL` returns `undefined` — hide link in fitted card | +| `allFileContents` empty | Hide file count badge | +| Submission deleted | PR still exists on GitHub — only the reference is removed | +| `status` is `merged` | Show green "Approved" pill | +| `status` is `closed` / `changes_requested` | Show red "Rejected" pill | + +--- + +## Portal — Card Structure + +### SubmissionCardPortal Fields + +| Field | Type | Description | +|-------|------|-------------| +| `title` | `StringField` | Portal display name | +| `description` | `StringField` | Short description shown in the header | + +> The portal queries all `SubmissionCard` instances dynamically — it does **not** use `linksTo` or `containsMany`. + +--- + +## Portal — Isolated Template Layout + +``` +┌──────────────────────────────────────────────────────────┐ +│ Header │ +│ Title: "Submissions" │ +│ │ +│ [ Search by listing name or title... ] [Grid][Strip] │ +├──────────────────────────────────────────────────────────┤ +│ Grid (default) │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │Submission│ │Submission│ │Submission│ │ +│ │ Card │ │ Card │ │ Card │ │ +│ │ (fitted) │ │ (fitted) │ │ (fitted) │ │ +│ └──────────┘ └──────────┘ └──────────┘ │ +├──────────────────────────────────────────────────────────┤ +│ Strip (alternate view) │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ Submission Card (fitted — wide strip layout) │ │ +│ └────────────────────────────────────────────────────┘ │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ Submission Card (fitted — wide strip layout) │ │ +│ └────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ +``` + +--- + +## Portal — Search Functionality + +Filters by `listing.name` or `listing.cardTitle` using a text input in the portal header. + +```ts +get query() { + const baseFilter = { + type: { module: `${this.realmHref}submission-card`, name: 'SubmissionCard' }, + }; + + if (!this.searchText) { + return { filter: baseFilter }; + } + + return { + filter: { + every: [ + baseFilter, + { + any: [ + { contains: { 'listing.name': this.searchText } }, + { contains: { 'listing.cardTitle': this.searchText } }, + ], + }, + ], + }, + }; +} +``` + +--- + +## Portal — View Switcher (Grid vs Strip) + +| View | Layout | Fitted card size | +|------|--------|-----------------| +| `grid` | `repeat(auto-fill, ~164px)` columns | Small square tile | +| `strip` | Full-width single column | Wide horizontal strip | + +--- + +## Portal — Grid Rendering Pattern + +```hbs +<@context.prerenderedCardSearchComponent + @query={{this.query}} + @format='fitted' + @realms={{this.realmHrefs}} + @isLive={{true}} +> + <:loading>Loading submissions... + <:response as |cards|> + {{#if (eq this.selectedView 'strip')}} + + {{else}} + + {{/if}} + + +``` + +--- + +## SubmissionCard — Fitted Template Sizes + +Fitted cards define multiple layouts using `@container fitted-card` queries. + +### Size 1 — Tiny badge (`height <= 58px`) +``` +┌──────────────────────┐ +│ [icon] Title │ +└──────────────────────┘ +``` +Show: icon + `cardTitle` only. + +### Size 2 — Square tile (default grid, ~164×224px) +``` +┌──────────────┐ +│ [icon] │ +│ Title │ +│ listing name │ +│ [status pill]│ +└──────────────┘ +``` +Show: icon, `cardTitle`, `listing.name`, status pill. + +### Size 3 — Wide strip (`aspect-ratio > 2.0`, `height < 115px`) +``` +┌────────────────────────────────────────────────────────┐ +│ [icon] Title listing name [status pill] [→] │ +└────────────────────────────────────────────────────────┘ +``` +Show: icon, `cardTitle`, `listing.name`, status pill, GitHub link icon. + +--- + +## Static Config + +```ts +static displayName = 'Submission Card Portal'; +static prefersWideFormat = true; +static headerColor = '#e5f0ff'; +static icon = SubmissionIcon; +``` From 8f87f1d2dacd8abfe53d8e2cb0da6b64e2e3f126 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 3 Mar 2026 14:05:07 +0800 Subject: [PATCH 02/19] CS-10291: Create submission card portal --- .../1cc25e60-2b27-4851-9751-d2c4eb34a088.json | 55 +++ .../98478514-df5d-4e6e-91ad-0764944e6ba1.json | 67 +++ .../bdd6e8ab-66ea-4cb0-a575-a0edb59bdf91.json | 51 ++ .../b535d5fb-8eef-44a6-8114-4bce6929b95a.json | 28 ++ .../catalog-realm/submission-card/README.md | 17 +- .../submission-card-portal.gts | 179 +++++++ .../submission-card/submission-card.gts | 443 +++++++++++++++++- 7 files changed, 828 insertions(+), 12 deletions(-) create mode 100644 packages/catalog-realm/SubmissionCard/1cc25e60-2b27-4851-9751-d2c4eb34a088.json create mode 100644 packages/catalog-realm/SubmissionCard/98478514-df5d-4e6e-91ad-0764944e6ba1.json create mode 100644 packages/catalog-realm/SubmissionCard/bdd6e8ab-66ea-4cb0-a575-a0edb59bdf91.json create mode 100644 packages/catalog-realm/SubmissionCardPortal/b535d5fb-8eef-44a6-8114-4bce6929b95a.json create mode 100644 packages/catalog-realm/submission-card/submission-card-portal.gts diff --git a/packages/catalog-realm/SubmissionCard/1cc25e60-2b27-4851-9751-d2c4eb34a088.json b/packages/catalog-realm/SubmissionCard/1cc25e60-2b27-4851-9751-d2c4eb34a088.json new file mode 100644 index 00000000000..8bd5971dfd3 --- /dev/null +++ b/packages/catalog-realm/SubmissionCard/1cc25e60-2b27-4851-9751-d2c4eb34a088.json @@ -0,0 +1,55 @@ +{ + "data": { + "type": "card", + "attributes": { + "roomId": "!nVBWtfyqdxXQknrIuR:localhost", + "branchName": null, + "allFileContents": [ + { + "filename": "CardListing/3b6b80ad-7df4-4e53-a360-9a6eb2c74565.json", + "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"CardListing\",\n \"module\": \"../catalog-app/listing/listing\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"name\": \"Music Coder\",\n \"images\": [\n \"https://boxel-images.boxel.ai/app-assets/catalog/music-coder-listing/screenshot_01.png\"\n ],\n \"summary\": \"An interactive music coding component that enables users to create, play, and visualize synthesizer and drum pattern sequences, with preset options, pattern editing, and real-time waveform visualization. Its primary purpose is to facilitate coding-based music composition and live performance within a customizable web interface.\",\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": \"https://boxel-images.boxel.ai/app-assets/catalog/music-coder-listing/thumbnail.png\"\n }\n },\n \"relationships\": {\n \"tags\": {\n \"links\": {\n \"self\": null\n }\n },\n \"skills\": {\n \"links\": {\n \"self\": null\n }\n },\n \"license\": {\n \"links\": {\n \"self\": \"../License/4c5a023b-a72c-4f90-930b-da60a1de5b2d\"\n }\n },\n \"specs.0\": {\n \"links\": {\n \"self\": \"../Spec/6e428463-fc70-432e-9c88-59f61d4a2e48\"\n }\n },\n \"publisher\": {\n \"links\": {\n \"self\": null\n }\n },\n \"categories.0\": {\n \"links\": {\n \"self\": \"../Category/entertainment-media\"\n }\n },\n \"examples.0\": {\n \"links\": {\n \"self\": \"../music-coder/MusicCoder/demo-beat\"\n }\n },\n \"examples.1\": {\n \"links\": {\n \"self\": \"../music-coder/MusicCoder/f21c5c75-a9be-4db9-8a76-7cec46498236\"\n }\n },\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" + }, + { + "filename": "music-coder/music-coder.gts", + "contents": "import { not, eq } from '@cardstack/boxel-ui/helpers';\nimport {\n CardDef,\n Component,\n field,\n contains,\n} from 'https://cardstack.com/base/card-api';\nimport StringField from 'https://cardstack.com/base/string';\nimport NumberField from 'https://cardstack.com/base/number';\nimport TextAreaField from 'https://cardstack.com/base/text-area';\nimport { tracked } from '@glimmer/tracking';\nimport { on } from '@ember/modifier';\nimport Modifier from 'ember-modifier';\nimport MusicIcon from '@cardstack/boxel-icons/music';\nimport PlayIcon from '@cardstack/boxel-icons/play';\nimport StopIcon from '@cardstack/boxel-icons/square';\nimport RefreshIcon from '@cardstack/boxel-icons/refresh-cw';\n\ninterface CanvasModifierSignature {\n Element: HTMLCanvasElement;\n Args: {\n Named: {\n component: MusicCoderIsolated;\n };\n };\n}\n\nclass CanvasModifier extends Modifier {\n modify(\n element: HTMLCanvasElement,\n _positional: [],\n named: { component: MusicCoderIsolated },\n ) {\n if (named.component) {\n named.component.canvasElement = element;\n }\n }\n}\n\nclass MusicCoderIsolated extends Component {\n @tracked isPlaying = false;\n @tracked errorMessage = '';\n @tracked currentPattern =\n this.args.model.pattern || 'note(\"60 64 67 72\").s(\"sawtooth\")';\n @tracked currentBpm = this.args.model.bpm || 120;\n @tracked isStrudelInitialized = false;\n\n private strudel!: any;\n private player: { stop: () => void } | null = null;\n canvasElement: HTMLCanvasElement | null = null;\n private animationFrameId: number | null = null;\n\n // Pattern presets organized by category\n presets = [\n // Synth Presets\n {\n name: '🎹 Saw Synth Melody',\n pattern: 'note(\"60 64 67 72\").s(\"sawtooth\")',\n category: 'synth',\n },\n {\n name: '🎹 Square Bass',\n pattern: 'note(\"36 43 48\").s(\"square\")',\n category: 'synth',\n },\n {\n name: '🎹 Sine Arpeggio',\n pattern: 'note(\"c4 e4 g4 c5\").s(\"sine\")',\n category: 'synth',\n },\n {\n name: '🎹 Triangle Lead',\n pattern: 'note(\"60 62 64 65 67\").s(\"triangle\")',\n category: 'synth',\n },\n {\n name: '🎹 Sawtooth Chord',\n pattern: 'note(\"48 52 55 59\").s(\"sawtooth\")',\n category: 'synth',\n },\n\n // Drum Presets\n {\n name: '🥁 Basic Drums',\n pattern: 's(\"bd sd, hh*4\")',\n category: 'drums',\n },\n {\n name: '🥁 Fast Drums',\n pattern: 's(\"bd sd*2, hh*8\")',\n category: 'drums',\n },\n {\n name: '🥁 Syncopated Beat',\n pattern: 's(\"bd ~ bd sd, hh*8\")',\n category: 'drums',\n },\n {\n name: '🥁 Euclidean Rhythm',\n pattern: 's(\"bd(3,8), sd(5,8,2)\")',\n category: 'drums',\n },\n\n // Full Compositions\n {\n name: '🎼 Simple Mix',\n pattern: 'stack(s(\"bd sd\"), note(\"c3 eb3\").s(\"sawtooth\"))',\n category: 'full',\n },\n ];\n\n get canPlay() {\n return this.currentPattern.trim().length > 0 && this.isStrudelInitialized;\n }\n\n private async ensureStrudel() {\n if (typeof window === 'undefined') return;\n if (this.strudel) return;\n\n const mod = await import(\n // @ts-ignore\n 'https://cdn.jsdelivr.net/npm/@strudel/web@1.2.5/+esm'\n );\n this.strudel = mod as any;\n await this.strudel.initStrudel();\n\n // Load sample libraries\n const libraries = ['github:tidalcycles/Dirt-Samples'];\n await Promise.all(\n libraries.map((lib: string) => this.strudel.samples(lib)),\n );\n\n this.isStrudelInitialized = true;\n }\n\n private startWaveformVisualization(retryCount = 0) {\n if (!this.canvasElement || !this.strudel?.analysers) {\n return;\n }\n\n const analyserId = 'music-studio-scope';\n const analyser = this.strudel.analysers[analyserId];\n\n if (!analyser) {\n if (retryCount < 10) {\n setTimeout(\n () => {\n this.startWaveformVisualization(retryCount + 1);\n },\n 100 * (retryCount + 1),\n );\n }\n return;\n }\n\n const canvas = this.canvasElement;\n const ctx = canvas.getContext('2d');\n if (!ctx) return;\n\n const dataArray = new Uint8Array(analyser.fftSize);\n\n const draw = () => {\n this.animationFrameId = requestAnimationFrame(draw);\n\n analyser.getByteTimeDomainData(dataArray);\n\n // Clear canvas with dark background\n ctx.fillStyle = '#18181b';\n ctx.fillRect(0, 0, canvas.width, canvas.height);\n\n // Draw waveform\n ctx.lineWidth = 2;\n ctx.strokeStyle = '#3b82f6';\n ctx.beginPath();\n\n const sliceWidth = canvas.width / dataArray.length;\n let x = 0;\n\n for (let i = 0; i < dataArray.length; i++) {\n const v = dataArray[i] / 255.0;\n const y = v * canvas.height;\n\n if (i === 0) {\n ctx.moveTo(x, y);\n } else {\n ctx.lineTo(x, y);\n }\n\n x += sliceWidth;\n }\n\n ctx.stroke();\n };\n\n draw();\n }\n\n private stopWaveformVisualization() {\n if (this.animationFrameId !== null) {\n cancelAnimationFrame(this.animationFrameId);\n this.animationFrameId = null;\n }\n\n // Clear canvas\n if (this.canvasElement) {\n const ctx = this.canvasElement.getContext('2d');\n if (ctx) {\n ctx.fillStyle = '#18181b';\n ctx.fillRect(0, 0, this.canvasElement.width, this.canvasElement.height);\n }\n }\n }\n\n play = async () => {\n try {\n await this.ensureStrudel();\n const { evaluate } = this.strudel;\n\n const pattern = await evaluate(this.currentPattern, false);\n // Stop old player and visualization\n this.player?.stop();\n this.stopWaveformVisualization();\n\n // Add analyze() to the pattern chain and start with tempo\n const analyserId = 'music-studio-scope';\n this.player = pattern\n .analyze(analyserId)\n .cpm(this.currentBpm / 2)\n .play();\n\n this.isPlaying = true;\n this.errorMessage = '';\n\n // Wait a bit for Strudel to actually create the analyser, then start visualization\n setTimeout(() => {\n this.startWaveformVisualization();\n }, 100);\n } catch (error: any) {\n let errorMsg = error.message || 'Unknown error';\n\n this.errorMessage = errorMsg;\n this.isPlaying = false;\n console.error('Strudel error:', error);\n }\n };\n\n stop = () => {\n if (this.strudel?.hush) {\n this.strudel.hush();\n }\n this.player = null;\n this.isPlaying = false;\n this.stopWaveformVisualization();\n };\n\n update = async () => {\n if (!this.isPlaying) {\n await this.play();\n return;\n }\n\n this.stop();\n await this.play();\n };\n\n loadPreset = async (event: Event) => {\n const target = event.target as HTMLSelectElement;\n const selectedPattern = target.value;\n if (selectedPattern) {\n this.currentPattern = selectedPattern;\n this.args.model.pattern = selectedPattern;\n await this.stop();\n await this.play();\n }\n };\n\n updatePattern = (event: Event) => {\n const target = event.target as HTMLTextAreaElement;\n this.currentPattern = target.value;\n this.args.model.pattern = target.value;\n };\n\n updateBpm = (event: Event) => {\n const target = event.target as HTMLInputElement;\n const newBpm = parseInt(target.value, 10);\n this.currentBpm = newBpm;\n this.args.model.bpm = newBpm;\n };\n\n willDestroy() {\n if (this.strudel?.hush) {\n this.strudel.hush();\n }\n this.stopWaveformVisualization();\n super.willDestroy?.();\n }\n\n \n}\n\nexport class MusicCoder extends CardDef {\n static displayName = 'Music Coder';\n static icon = MusicIcon;\n\n @field cardTitle = contains(StringField);\n @field cardDescription = contains(TextAreaField);\n @field pattern = contains(TextAreaField);\n @field bpm = contains(NumberField);\n\n static isolated = MusicCoderIsolated;\n\n static embedded = class Embedded extends Component {\n \n };\n}\n" + }, + { + "filename": "Spec/6e428463-fc70-432e-9c88-59f61d4a2e48.json", + "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../music-coder/music-coder\",\n \"name\": \"MusicCoder\"\n },\n \"specType\": \"card\",\n \"containedExamples\": [],\n \"cardTitle\": \"MusicCoder\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" + }, + { + "filename": "music-coder/MusicCoder/demo-beat.json", + "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"MusicCoder\",\n \"module\": \"../music-coder\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"bpm\": 128,\n \"cardTitle\": \"Classic Video Game Music\",\n \"pattern\": \"stack(\\n // Main melody - the complete theme!\\n note(\\\"<[e5 e5 ~ e5 ~ c5 e5 ~] [g5 ~ ~ ~ g4 ~ ~ ~] [c5 ~ ~ g4 ~ ~ e4 ~] [a4 ~ b4 ~ bb4 a4 ~ ~] [g4 e5 ~ g5 a5 ~ f5 g5] [~ e5 ~ c5 d5 b4 ~ ~] [c5 ~ ~ g4 ~ ~ e4 ~] [a4 ~ b4 ~ bb4 a4 ~ ~] [g4 e5 ~ g5 a5 ~ f5 g5] [~ e5 ~ c5 d5 b4 ~ ~]>\\\").sound(\\\"square\\\").gain(0.5),\\n \\n // Bass line\\n note(\\\"<[c3 ~ ~ ~] [g2 ~ ~ ~] [c3 ~ ~ ~] [f2 ~ ~ ~] [c3 ~ ~ ~] [g2 ~ ~ ~] [c3 ~ ~ ~] [f2 ~ ~ ~] [c3 ~ ~ ~] [g2 ~ ~ ~]>\\\").sound(\\\"sawtooth\\\").gain(0.4).lpf(600),\\n \\n // Kick and snare pattern\\n s(\\\"bd ~ ~ ~, ~ ~ sd ~\\\").gain(0.5),\\n \\n // Hi-hat for groove\\n s(\\\"~ hh ~ hh\\\").gain(0.3)\\n)\",\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null\n },\n \"cardDescription\": \"A fun 8-bit style melody inspired by classic video game music\"\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" + }, + { + "filename": "music-coder/MusicCoder/f21c5c75-a9be-4db9-8a76-7cec46498236.json", + "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"MusicCoder\",\n \"module\": \"../music-coder\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"bpm\": 124,\n \"cardTitle\": \"House Groove\",\n \"pattern\": \"stack(note('<[c3 ~ c3 ~] [c3 ~ eb3 ~] [f3 ~ f3 ~] [g3 ~ g3 ab3]>').sound('sawtooth').lpf(800).gain(0.6), note('<[c5 ~ eb5 g5] [~ g5 f5 ~] [c5 ~ eb5 g5] [~ bb5 ab5 ~]>').sound('square').gain(0.4), s('bd ~ ~ ~, ~ ~ sd ~, [~ hh]*4, ~ ~ ~ cp').gain(0.7))\",\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null\n },\n \"cardDescription\": \"Classic house music beat with funky bassline and driving rhythm\"\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" + } + ], + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "listing": { + "links": { + "self": "../CardListing/3b6b80ad-7df4-4e53-a360-9a6eb2c74565" + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../submission-card/submission-card", + "name": "SubmissionCard" + } + } + } +} \ No newline at end of file diff --git a/packages/catalog-realm/SubmissionCard/98478514-df5d-4e6e-91ad-0764944e6ba1.json b/packages/catalog-realm/SubmissionCard/98478514-df5d-4e6e-91ad-0764944e6ba1.json new file mode 100644 index 00000000000..b29db6e7b8e --- /dev/null +++ b/packages/catalog-realm/SubmissionCard/98478514-df5d-4e6e-91ad-0764944e6ba1.json @@ -0,0 +1,67 @@ +{ + "data": { + "type": "card", + "attributes": { + "roomId": "!KOzaXPpicPKuawRZQs:localhost", + "branchName": null, + "allFileContents": [ + { + "filename": "CardListing/139dcc13-6a07-40f4-b8aa-264f8eba8e59.json", + "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"CardListing\",\n \"module\": \"../catalog-app/listing/listing\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"name\": \"Online Customer Listing\",\n \"images\": [\n \"https://boxel-images.boxel.ai/app-assets/catalog/online-customer-listing/screenshot_01.png\"\n ],\n \"summary\": \"Displaying the updated Online Customer Management System listing\",\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": \"https://boxel-images.boxel.ai/app-assets/catalog/online-customer-listing/thumbnail.png\"\n }\n },\n \"relationships\": {\n \"tags\": {\n \"links\": {\n \"self\": null\n }\n },\n \"skills\": {\n \"links\": {\n \"self\": null\n }\n },\n \"license\": {\n \"links\": {\n \"self\": null\n }\n },\n \"specs.0\": {\n \"links\": {\n \"self\": \"../Spec/contact-link\"\n }\n },\n \"specs.1\": {\n \"links\": {\n \"self\": \"../Spec/3a7a139d-cc13-4a07-80f4-b8aa264f8eba\"\n }\n },\n \"publisher\": {\n \"links\": {\n \"self\": \"../Publisher/9d3ca05b-684b-408f-8d8c-dd353d9956e0\"\n }\n },\n \"examples.0\": {\n \"links\": {\n \"self\": \"../online-customer/OnlineCustomer/elena-vasquez\"\n }\n },\n \"examples.1\": {\n \"links\": {\n \"self\": \"../online-customer/OnlineCustomer/james-mitchell-brown\"\n }\n },\n \"examples.2\": {\n \"links\": {\n \"self\": \"../online-customer/OnlineCustomer/zara-ahmed\"\n }\n },\n \"categories.0\": {\n \"links\": {\n \"self\": \"../Category/e-commerce-online-sales\"\n }\n },\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" + }, + { + "filename": "fields/contact-link.gts", + "contents": "import {\n Component,\n field,\n contains,\n StringField,\n FieldDef,\n} from 'https://cardstack.com/base/card-api';\nimport UrlField from 'https://cardstack.com/base/url';\n\nimport {\n BoxelSelect,\n FieldContainer,\n Pill,\n} from '@cardstack/boxel-ui/components';\n\nimport type IconComponent from '@cardstack/boxel-icons/captions';\nimport Email from '@cardstack/boxel-icons/mail';\nimport Link from '@cardstack/boxel-icons/link';\nimport Phone from '@cardstack/boxel-icons/phone';\n\nexport interface ContactLink {\n type: 'email' | 'tel' | 'link' | string;\n label: string;\n icon: typeof IconComponent;\n cta: string;\n}\n\nconst contactValues: ContactLink[] = [\n {\n type: 'email',\n label: 'Email',\n icon: Email,\n cta: 'Email',\n },\n {\n type: 'tel',\n label: 'Phone',\n icon: Phone,\n cta: 'Contact',\n },\n {\n type: 'link',\n label: 'Other',\n icon: Link,\n cta: 'Connect',\n },\n];\n\nexport default class ContactLinkField extends FieldDef {\n static displayName = 'Contact Link';\n static values: ContactLink[] = contactValues;\n @field label = contains(StringField);\n @field value = contains(StringField);\n @field url = contains(UrlField, {\n computeVia: function (this: ContactLinkField) {\n switch (this.item?.type) {\n case 'email':\n return `mailto:${this.value}`;\n case 'tel':\n return `tel:${this.value}`;\n default:\n return this.value;\n }\n },\n });\n get items() {\n if (this.constructor && 'values' in this.constructor) {\n return this.constructor.values as ContactLink[];\n }\n return ContactLinkField.values;\n }\n get item() {\n return this.items?.find((val) => val.label === this.label);\n }\n static edit = class Edit extends Component {\n \n\n options = this.args.model.items;\n\n onSelect = (option: ContactLink) => (this.args.model.label = option.label);\n\n get selectedOption() {\n return this.options?.find(\n (option) => option.label === this.args.model.label,\n );\n }\n\n get label() {\n switch (this.selectedOption?.type) {\n case 'email':\n return 'Address';\n case 'tel':\n return 'Number';\n default:\n return 'Link';\n }\n }\n };\n static atom = class Atom extends Component {\n \n };\n static embedded = class Embedded extends Component {\n \n };\n}\n" + }, + { + "filename": "online-customer/online-customer.gts", + "contents": "import {\n CardDef,\n FieldDef,\n field,\n contains,\n Component,\n} from 'https://cardstack.com/base/card-api';\nimport StringField from 'https://cardstack.com/base/string';\nimport NumberField from 'https://cardstack.com/base/number';\nimport DatetimeField from 'https://cardstack.com/base/datetime';\nimport ContactLinkField from '../fields/contact-link';\n\nimport {\n formatCurrency,\n formatDateTime,\n formatNumber,\n} from '@cardstack/boxel-ui/helpers';\nimport { BoxelSelect } from '@cardstack/boxel-ui/components';\n\nimport CustomerIcon from '@cardstack/boxel-icons/user';\nimport { tracked } from '@glimmer/tracking';\nimport { action } from '@ember/object';\n\nclass LoyaltyTierFieldEdit extends Component {\n get initialTierName() {\n return this.args.model.name || 'Bronze';\n }\n\n @tracked selectedTier: { name: string } | null = {\n name: this.initialTierName,\n };\n @tracked tierOptions = [\n { name: 'Bronze' },\n { name: 'Silver' },\n { name: 'Gold' },\n { name: 'Platinum' },\n ];\n\n @action onSelectTier(tier: { name: string } | null) {\n this.selectedTier = tier;\n // Update the field's name property directly\n if (tier) {\n this.args.model.name = tier.name;\n }\n }\n\n \n}\n\nclass LoyaltyTierField extends FieldDef {\n static displayName = 'Loyalty Tier';\n @field name = contains(StringField);\n static edit = LoyaltyTierFieldEdit;\n}\n\nclass IsolatedTemplate extends Component {\n get initials() {\n try {\n const name = this.args.model?.customerName;\n if (!name) return '?';\n return name\n .split(' ')\n .map((n: string) => n[0])\n .join('')\n .toUpperCase()\n .slice(0, 2);\n } catch (e) {\n return '?';\n }\n }\n\n get customerTier() {\n try {\n return this.args.model?.loyaltyTier?.name ?? 'Bronze';\n } catch (e) {\n return 'Bronze';\n }\n }\n\n get isPremium() {\n return this.customerTier === 'Gold' || this.customerTier === 'Platinum';\n }\n\n \n}\n\nclass EmbeddedTemplate extends Component {\n get initials() {\n try {\n const name = this.args.model?.customerName;\n if (!name) return '?';\n return name\n .split(' ')\n .map((n: string) => n[0])\n .join('')\n .toUpperCase()\n .slice(0, 2);\n } catch (e) {\n return '?';\n }\n }\n\n get customerTier() {\n try {\n return this.args.model?.loyaltyTier?.name ?? 'Bronze';\n } catch (e) {\n return 'Bronze';\n }\n }\n\n get isPremium() {\n return this.customerTier === 'Gold' || this.customerTier === 'Platinum';\n }\n\n \n}\n\nclass FittedTemplate extends Component {\n get initials() {\n try {\n const name = this.args.model?.customerName;\n if (!name) return '?';\n return name\n .split(' ')\n .map((n) => n[0])\n .join('')\n .toUpperCase()\n .slice(0, 2);\n } catch (e) {\n return '?';\n }\n }\n\n get customerTier() {\n try {\n return this.args.model?.loyaltyTier?.name ?? 'Bronze';\n } catch (e) {\n return 'Bronze';\n }\n }\n\n get totalSpentFormatted() {\n try {\n return this.args.model?.totalSpent\n ? formatCurrency(this.args.model.totalSpent, {\n currency: 'USD',\n size: 'tiny',\n })\n : null;\n } catch (e) {\n return null;\n }\n }\n\n \n}\n\nexport class OnlineCustomer extends CardDef {\n static displayName = 'Customer';\n static icon = CustomerIcon;\n\n @field customerName = contains(StringField);\n @field email = contains(ContactLinkField);\n @field phone = contains(ContactLinkField);\n @field totalOrders = contains(NumberField);\n @field totalSpent = contains(NumberField);\n @field customerSince = contains(DatetimeField);\n @field loyaltyTier = contains(LoyaltyTierField);\n\n @field cardTitle = contains(StringField, {\n computeVia: function (this: OnlineCustomer) {\n try {\n const name = this.customerName ?? 'Customer';\n return name.length > 50 ? name.substring(0, 47) + '...' : name;\n } catch (e) {\n console.error('OnlineCustomer: Error computing title', e);\n return 'Customer';\n }\n },\n });\n\n static isolated = IsolatedTemplate;\n static embedded = EmbeddedTemplate;\n static fitted = FittedTemplate;\n}\n" + }, + { + "filename": "Spec/contact-link.json", + "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"Spec\",\n \"module\": \"https://cardstack.com/base/spec\"\n },\n \"fields\": {\n \"containedExamples\": [\n {\n \"adoptsFrom\": {\n \"module\": \"../fields/contact-link\",\n \"name\": \"default\"\n }\n }\n ]\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"ref\": {\n \"name\": \"default\",\n \"module\": \"../fields/contact-link\"\n },\n \"cardTitle\": \"ContactLinkField\",\n \"readMe\": null,\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null\n },\n \"specType\": \"field\",\n \"cardDescription\": null,\n \"containedExamples\": [\n {\n \"label\": null,\n \"value\": null\n }\n ]\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n },\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" + }, + { + "filename": "Spec/3a7a139d-cc13-4a07-80f4-b8aa264f8eba.json", + "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../online-customer/online-customer\",\n \"name\": \"OnlineCustomer\"\n },\n \"specType\": \"card\",\n \"containedExamples\": [],\n \"cardTitle\": \"OnlineCustomer\",\n \"cardDescription\": \"Spec of OnlineCustomer\",\n \"cardInfo\": {},\n \"cardThumbnailURL\": null\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}" + }, + { + "filename": "online-customer/OnlineCustomer/elena-vasquez.json", + "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"OnlineCustomer\",\n \"module\": \"../online-customer\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"email\": {\n \"label\": \"Email\",\n \"value\": \"elena.vasquez@artgallery.org\"\n },\n \"phone\": {\n \"label\": \"Phone\",\n \"value\": \"+1-212-555-3847\"\n },\n \"cardInfo\": {},\n \"totalSpent\": 12389.5,\n \"cardDescription\": null,\n \"loyaltyTier\": {\n \"name\": \"Silver\"\n },\n \"totalOrders\": 45,\n \"customerName\": \"Elena Vasquez\",\n \"cardThumbnailURL\": null,\n \"customerSince\": \"2023-09-12T16:20:00.000Z\"\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}" + }, + { + "filename": "online-customer/OnlineCustomer/james-mitchell-brown.json", + "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"OnlineCustomer\",\n \"module\": \"../online-customer\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"email\": {\n \"label\": \"Email\",\n \"value\": \"j.brown@quantumdev.io\"\n },\n \"phone\": {\n \"label\": \"Phone\",\n \"value\": \"+1-206-555-9204\"\n },\n \"cardInfo\": {},\n \"totalSpent\": 67823.94,\n \"cardDescription\": null,\n \"loyaltyTier\": {\n \"name\": \"Platinum\"\n },\n \"totalOrders\": 238,\n \"customerName\": \"James Mitchell-Brown\",\n \"cardThumbnailURL\": null,\n \"customerSince\": \"2021-11-03T08:45:00.000Z\"\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}" + }, + { + "filename": "online-customer/OnlineCustomer/zara-ahmed.json", + "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"OnlineCustomer\",\n \"module\": \"../online-customer\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"email\": {\n \"label\": \"Email\",\n \"value\": \"zara.ahmed@healthtechstartup.com\"\n },\n \"phone\": {\n \"label\": \"Phone\",\n \"value\": \"+1-647-555-1286\"\n },\n \"cardInfo\": {},\n \"totalSpent\": 2847.33,\n \"cardDescription\": null,\n \"loyaltyTier\": {\n \"name\": \"Bronze\"\n },\n \"totalOrders\": 12,\n \"customerName\": \"Zara Ahmed\",\n \"cardThumbnailURL\": null,\n \"customerSince\": \"2024-02-28T13:10:00.000Z\"\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}" + } + ], + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "listing": { + "links": { + "self": "../CardListing/139dcc13-6a07-40f4-b8aa-264f8eba8e59" + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../submission-card/submission-card", + "name": "SubmissionCard" + } + } + } +} \ No newline at end of file diff --git a/packages/catalog-realm/SubmissionCard/bdd6e8ab-66ea-4cb0-a575-a0edb59bdf91.json b/packages/catalog-realm/SubmissionCard/bdd6e8ab-66ea-4cb0-a575-a0edb59bdf91.json new file mode 100644 index 00000000000..29282a7e069 --- /dev/null +++ b/packages/catalog-realm/SubmissionCard/bdd6e8ab-66ea-4cb0-a575-a0edb59bdf91.json @@ -0,0 +1,51 @@ +{ + "data": { + "type": "card", + "attributes": { + "roomId": "!dkXRRDVMhspnemshqZ:localhost", + "branchName": null, + "allFileContents": [ + { + "filename": "CardListing/24d2eed4-9754-4636-9667-72f516ad6b00.json", + "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"CardListing\",\n \"module\": \"../catalog-app/listing/listing\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"name\": \"Photo Collage - Minimalist Photography Collection\",\n \"images\": [\n \"https://boxel-images.boxel.ai/app-assets/catalog/photo-collage---minimalist-photography-collection-listing/screenshot_01.png\",\n \"https://boxel-images.boxel.ai/app-assets/catalog/photo-collage---minimalist-photography-collection-listing/screenshot_02.png\"\n ],\n \"summary\": \"Dynamic photo collage with masonry grid layout - upload multiple images with captions while viewing an adaptive grid display. Features varied sizing patterns, hover-reveal captions, smooth scaling animations, and elegant dark mode aesthetics for professional photo presentations.\",\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": \"https://boxel-images.boxel.ai/app-assets/catalog/photo-collage---minimalist-photography-collection-listing/thumbnail.png\"\n }\n },\n \"relationships\": {\n \"tags\": {\n \"links\": {\n \"self\": null\n }\n },\n \"skills\": {\n \"links\": {\n \"self\": null\n }\n },\n \"license\": {\n \"links\": {\n \"self\": null\n }\n },\n \"specs.0\": {\n \"links\": {\n \"self\": \"../Spec/eb5d5424-d2ee-4497-94d6-36166772f516\"\n }\n },\n \"publisher\": {\n \"links\": {\n \"self\": \"../Publisher/9d3ca05b-684b-408f-8d8c-dd353d9956e0\"\n }\n },\n \"examples.0\": {\n \"links\": {\n \"self\": \"../photo-collage/PhotoCollage/a2c50a4a-67ee-4c84-9a53-fe17243bbc24\"\n }\n },\n \"categories.0\": {\n \"links\": {\n \"self\": \"../Category/design-creative\"\n }\n },\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" + }, + { + "filename": "photo-collage/photo-collage.gts", + "contents": "import BooleanField from 'https://cardstack.com/base/boolean';\nimport {\n CardDef,\n field,\n contains,\n containsMany,\n Component,\n FieldDef,\n} from 'https://cardstack.com/base/card-api';\nimport StringField from 'https://cardstack.com/base/string';\nimport UrlField from 'https://cardstack.com/base/url';\n\nexport class PhotoItem extends FieldDef {\n static displayName = 'Photo';\n\n @field url = contains(UrlField);\n @field caption = contains(StringField);\n @field alt = contains(StringField);\n}\n\nexport class PhotoCollage extends CardDef {\n static displayName = 'Photo Collage';\n static prefersWideFormat = true;\n\n @field cardTitle = contains(StringField);\n @field photos = containsMany(PhotoItem);\n @field darkMode = contains(BooleanField);\n\n static isolated = class Isolated extends Component {\n \n };\n}\n" + }, + { + "filename": "Spec/eb5d5424-d2ee-4497-94d6-36166772f516.json", + "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"name\": \"PhotoCollage\",\n \"module\": \"../photo-collage/photo-collage\"\n },\n \"specType\": \"card\",\n \"containedExamples\": [],\n \"cardTitle\": \"PhotoCollage\",\n \"cardDescription\": \"Spec of PhotoCollage\",\n \"cardThumbnailURL\": null\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}" + }, + { + "filename": "photo-collage/PhotoCollage/a2c50a4a-67ee-4c84-9a53-fe17243bbc24.json", + "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"cardTitle\": \"Minimalist Photography Collection\",\n \"photos\": [\n {\n \"url\": \"https://images.unsplash.com/photo-1507608616759-54f48f0af0ee\",\n \"caption\": \"Solitude\",\n \"alt\": \"Person standing alone in a foggy landscape\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1477414348463-c0eb7f1359b6\",\n \"caption\": \"Cityscape\",\n \"alt\": \"Urban skyline with modern architecture\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1682687982107-14492010e05e\",\n \"caption\": \"Abstract Light\",\n \"alt\": \"Abstract patterns of light and shadow\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1475924156734-496f6cac6ec1\",\n \"caption\": \"Mountain Lake\",\n \"alt\": \"Serene mountain lake with forest reflections\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1526779259212-939e64788e3c\",\n \"caption\": \"Minimalist Architecture\",\n \"alt\": \"Clean lines of modern building design\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1598128558393-70ff21433be0\",\n \"caption\": \"Shadows\",\n \"alt\": \"Dramatic shadows on a white wall\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1496181133206-80ce9b88a853\",\n \"caption\": \"Workspace\",\n \"alt\": \"Minimalist desktop with laptop\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1496449903678-68ddcb189a24\",\n \"caption\": \"Urban Exploration\",\n \"alt\": \"Person walking in modern architecture\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe\",\n \"caption\": \"Negative Space\",\n \"alt\": \"Simple white room with single chair emphasizing empty space\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1494438639946-1ebd1d20bf85\",\n \"caption\": \"Industrial\",\n \"alt\": \"Industrial architecture in black and white\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1511447333015-45b65e60f6d5\",\n \"caption\": \"Mountain Silhouette\",\n \"alt\": \"Person silhouette against mountain backdrop\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1478104718532-efe04cc3ff7f\",\n \"caption\": \"Geometric\",\n \"alt\": \"Abstract geometric patterns in architecture\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1507608869274-d3177c8bb4c7\",\n \"caption\": \"Texture\",\n \"alt\": \"Textured surfaces in monochrome\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1515549832467-8783363e19b6\",\n \"caption\": \"Aerial\",\n \"alt\": \"Aerial view of landscape with minimal elements\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1605460375648-278bcbd579a6\",\n \"caption\": \"Simplicity\",\n \"alt\": \"Simple lines creating an abstract composition\"\n }\n ],\n \"darkMode\": false,\n \"cardDescription\": null,\n \"cardThumbnailURL\": null\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"../photo-collage\",\n \"name\": \"PhotoCollage\"\n }\n }\n }\n}" + } + ], + "cardInfo": { + "name": null, + "summary": null, + "cardThumbnailURL": null, + "notes": null + } + }, + "relationships": { + "listing": { + "links": { + "self": "../CardListing/24d2eed4-9754-4636-9667-72f516ad6b00" + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + }, + "meta": { + "adoptsFrom": { + "module": "../submission-card/submission-card", + "name": "SubmissionCard" + } + } + } +} \ No newline at end of file diff --git a/packages/catalog-realm/SubmissionCardPortal/b535d5fb-8eef-44a6-8114-4bce6929b95a.json b/packages/catalog-realm/SubmissionCardPortal/b535d5fb-8eef-44a6-8114-4bce6929b95a.json new file mode 100644 index 00000000000..fba3757b124 --- /dev/null +++ b/packages/catalog-realm/SubmissionCardPortal/b535d5fb-8eef-44a6-8114-4bce6929b95a.json @@ -0,0 +1,28 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "SubmissionCardPortal", + "module": "../submission-card/submission-card-portal" + } + }, + "type": "card", + "attributes": { + "title": "Submission Card Portal", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, + "description": null + }, + "relationships": { + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/packages/catalog-realm/submission-card/README.md b/packages/catalog-realm/submission-card/README.md index 6ff060edc1a..738a8a86629 100644 --- a/packages/catalog-realm/submission-card/README.md +++ b/packages/catalog-realm/submission-card/README.md @@ -42,15 +42,17 @@ The portal hides all GitHub technical details. Users care about *"Did my submiss ## Realm Location -Both files belong in the `/submissions/` realm, not the catalog realm: +Both files live in the catalog realm (which is editable): ``` submission-card/ ├── README.md ← this file -├── submission-card-portal.gts ← portal app card → /submissions/ -└── submission-card.gts ← submission card → /submissions/ +├── submission-card-portal.gts ← portal app card → catalog realm +└── submission-card.gts ← submission card → catalog realm ``` +> **Future:** `SubmissionCard` instances will eventually be created in the **user's own realm** (the realm from which they submitted the listing PR), not the catalog realm. The catalog realm is used as a starting point while the per-user realm flow is not yet implemented. + --- ## Submission Card — Permissions @@ -72,9 +74,7 @@ Show only user-facing status. **Hide all GitHub technical details.** | Field | Show | Notes | |-------|------|-------| | Listing name | Yes | `listing.name` — primary title | -| Status pill | Yes | `pending` / `open` / `merged` / `closed` / `changes_requested` | -| Submitted date | Yes | `createdAt` timestamp | -| Pass / Reject indicator | Yes | Derived from `status` | +| Submitted date | Yes | Card generation date (`_createdAt` from card metadata) | | Branch name | Optional | Secondary, small label | | GitHub PR link | Optional | Icon link only | | Reviewer comments | No | Too technical | @@ -104,14 +104,11 @@ Show only user-facing status. **Hide all GitHub technical details.** | `githubURL` | `StringField` (computed) | Built from `branchName` using `GITHUB_BRANCH_URL_PREFIX` + URL-encoded segments | | `listing` | `linksTo(Listing)` | The catalog listing being submitted | | `allFileContents` | `containsMany(FileContentField)` | All files included in the PR submission | -| `status` | `StringField` | `pending` \| `open` \| `merged` \| `closed` \| `changes_requested` — updated by webhook | -| `createdAt` | `DatetimeField` | When the submission was created | ### Computed Field Notes - **`cardTitle`** — read-only, auto-derived from the linked listing. - **`githubURL`** — read-only, auto-derived from `branchName`. Each `/`-separated segment is individually `encodeURIComponent`-encoded. -- **`status`** — written by the webhook/bot, not by the user directly. ### Edge Cases @@ -122,8 +119,6 @@ Show only user-facing status. **Hide all GitHub technical details.** | `branchName` empty | `githubURL` returns `undefined` — hide link in fitted card | | `allFileContents` empty | Hide file count badge | | Submission deleted | PR still exists on GitHub — only the reference is removed | -| `status` is `merged` | Show green "Approved" pill | -| `status` is `closed` / `changes_requested` | Show red "Rejected" pill | --- diff --git a/packages/catalog-realm/submission-card/submission-card-portal.gts b/packages/catalog-realm/submission-card/submission-card-portal.gts new file mode 100644 index 00000000000..486f26551cc --- /dev/null +++ b/packages/catalog-realm/submission-card/submission-card-portal.gts @@ -0,0 +1,179 @@ +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { debounce } from 'lodash'; + +import { + CardDef, + Component, + contains, + field, + realmURL, +} from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import { type Query } from '@cardstack/runtime-common'; + +import { BoxelInput, ViewSelector } from '@cardstack/boxel-ui/components'; +import { type ViewItem } from '@cardstack/boxel-ui/components'; +import CardList from 'https://cardstack.com/base/components/card-list'; +import BotIcon from '@cardstack/boxel-icons/bot'; +import { + Grid3x3 as GridIcon, + Rows4 as StripIcon, +} from '@cardstack/boxel-ui/icons'; + +type ViewOption = 'strip' | 'grid'; + +const SUBMISSION_VIEW_OPTIONS: ViewItem[] = [ + { id: 'strip', icon: StripIcon }, + { id: 'grid', icon: GridIcon }, +]; + +class Isolated extends Component { + @tracked searchText: string = ''; + @tracked selectedView: string = 'grid'; + + private debouncedSetSearch = debounce((value: string) => { + this.searchText = value; + }, 300); + + @action + onSearchInput(value: string) { + this.debouncedSetSearch(value); + } + + @action + setView(id: ViewOption) { + this.selectedView = id; + } + + get realmHrefs(): string[] { + const url = this.args.model[realmURL]; + return url ? [url.href] : []; + } + + get query(): Query { + const baseFilter = { + type: { + module: new URL('./submission-card', import.meta.url).href, + name: 'SubmissionCard', + }, + }; + + if (!this.searchText) { + return { filter: baseFilter }; + } + + return { + filter: { + every: [ + baseFilter, + { + any: [{ contains: { cardTitle: this.searchText } }], + }, + ], + }, + }; + } + + +} + +export class SubmissionCardPortal extends CardDef { + static displayName = 'Submission Card Portal'; + static prefersWideFormat = true; + static headerColor = '#e5f0ff'; + static icon = BotIcon; + static isolated = Isolated; + + @field title = contains(StringField); + @field description = contains(StringField); +} diff --git a/packages/catalog-realm/submission-card/submission-card.gts b/packages/catalog-realm/submission-card/submission-card.gts index 8fe3c8c05af..cad91f12389 100644 --- a/packages/catalog-realm/submission-card/submission-card.gts +++ b/packages/catalog-realm/submission-card/submission-card.gts @@ -1,6 +1,7 @@ import { CardDef, FieldDef, + Component, contains, containsMany, field, @@ -8,6 +9,9 @@ import { } from 'https://cardstack.com/base/card-api'; import StringField from 'https://cardstack.com/base/string'; import BotIcon from '@cardstack/boxel-icons/bot'; +import BrandGithubIcon from '@cardstack/boxel-icons/brand-github'; +import GitBranchIcon from '@cardstack/boxel-icons/git-branch'; +import MessageIcon from '@cardstack/boxel-icons/message'; import { Listing } from '../catalog-app/listing/listing'; const GITHUB_BRANCH_URL_PREFIX = @@ -31,7 +35,9 @@ export class SubmissionCard extends CardDef { @field cardTitle = contains(StringField, { computeVia: function (this: SubmissionCard) { - return this.listing?.name ?? this.listing?.cardTitle ?? 'Untitled Submission'; + return ( + this.listing?.name ?? this.listing?.cardTitle ?? 'Untitled Submission' + ); }, }); @field roomId = contains(StringField); @@ -46,4 +52,439 @@ export class SubmissionCard extends CardDef { }); @field listing = linksTo(() => Listing); @field allFileContents = containsMany(FileContentField); + + static fitted = class Fitted extends Component { + get fileCount() { + return this.args.model.allFileContents?.length ?? 0; + } + + get listingName() { + return ( + this.args.model.listing?.name ?? this.args.model.listing?.cardTitle + ); + } + + get title() { + return this.args.model.cardTitle; + } + + get githubURL() { + return this.args.model.githubURL; + } + + get branchName() { + return this.args.model.branchName; + } + + get roomId() { + return this.args.model.roomId; + } + + get listingImage() { + return this.args.model.listing?.images?.[0]; + } + + + }; +} + +function isPlural(count: number): boolean { + return count !== 1; } From 157f1c98e00c21f71ca94339e8a7d0c03bb2c8ec Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 3 Mar 2026 15:38:57 +0800 Subject: [PATCH 03/19] add islive --- .../catalog-realm/submission-card/submission-card-portal.gts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/catalog-realm/submission-card/submission-card-portal.gts b/packages/catalog-realm/submission-card/submission-card-portal.gts index 486f26551cc..792dbed3957 100644 --- a/packages/catalog-realm/submission-card/submission-card-portal.gts +++ b/packages/catalog-realm/submission-card/submission-card-portal.gts @@ -103,6 +103,7 @@ class Isolated extends Component { @format='fitted' @viewOption={{this.selectedView}} @context={{@context}} + @isLive={{true}} /> From 5e9eb3da7c5f0f30b0a8847eaf3def82118c0f4c Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 4 Mar 2026 10:33:42 +0800 Subject: [PATCH 04/19] implement isolated/embedded template --- .../submission-card/submission-card.gts | 521 ++++++++++++++++++ 1 file changed, 521 insertions(+) diff --git a/packages/catalog-realm/submission-card/submission-card.gts b/packages/catalog-realm/submission-card/submission-card.gts index cad91f12389..dee626c745c 100644 --- a/packages/catalog-realm/submission-card/submission-card.gts +++ b/packages/catalog-realm/submission-card/submission-card.gts @@ -7,9 +7,13 @@ import { field, linksTo, } from 'https://cardstack.com/base/card-api'; +import { or } from '@cardstack/boxel-ui/helpers'; +import { on } from '@ember/modifier'; +import { BoxelButton } from '@cardstack/boxel-ui/components'; import StringField from 'https://cardstack.com/base/string'; import BotIcon from '@cardstack/boxel-icons/bot'; import BrandGithubIcon from '@cardstack/boxel-icons/brand-github'; +import FileCodeIcon from '@cardstack/boxel-icons/file-code'; import GitBranchIcon from '@cardstack/boxel-icons/git-branch'; import MessageIcon from '@cardstack/boxel-icons/message'; import { Listing } from '../catalog-app/listing/listing'; @@ -27,6 +31,144 @@ function encodeBranchName(branchName: string): string { export class FileContentField extends FieldDef { @field filename = contains(StringField); @field contents = contains(StringField); + + static atom = class Atom extends Component { + get filename() { + return this.args.model.filename ?? 'Untitled'; + } + + + }; + + static embedded = class Embedded extends Component { + get filename() { + return this.args.model.filename ?? 'Untitled'; + } + + get preview() { + const contents = this.args.model.contents ?? ''; + return contents.split('\n').slice(0, 6).join('\n'); + } + + get lineCount() { + const contents = this.args.model.contents ?? ''; + return contents ? contents.split('\n').length : 0; + } + + + }; } export class SubmissionCard extends CardDef { @@ -483,6 +625,385 @@ export class SubmissionCard extends CardDef { }; + + static isolated = class Isolated extends Component { + get fileCount() { + return this.args.model.allFileContents?.length ?? 0; + } + + get listingName() { + return ( + this.args.model.listing?.name ?? this.args.model.listing?.cardTitle + ); + } + + get title() { + return this.args.model.cardTitle; + } + + get githubURL() { + return this.args.model.githubURL; + } + + get branchName() { + return this.args.model.branchName; + } + + get roomId() { + return this.args.model.roomId; + } + + get listingImage() { + return this.args.model.listing?.images?.[0]; + } + + openListing = () => { + const listing = this.args.model.listing; + if (listing) { + this.args.viewCard?.(listing, 'isolated'); + } + }; + + + }; } function isPlural(count: number): boolean { From dbd5de5881158b4a4f77cfc9ab9205037b704a5b Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 4 Mar 2026 17:31:30 +0800 Subject: [PATCH 05/19] feat: add realm filter tabs to SubmissionCardPortal --- .../1cc25e60-2b27-4851-9751-d2c4eb34a088.json | 26 +-- .../98478514-df5d-4e6e-91ad-0764944e6ba1.json | 26 +-- .../bdd6e8ab-66ea-4cb0-a575-a0edb59bdf91.json | 26 +-- .../catalog-realm/submission-card/README.md | 105 +++++++----- .../submission-card-portal.gts | 154 +++++++++++++++++- 5 files changed, 253 insertions(+), 84 deletions(-) diff --git a/packages/catalog-realm/SubmissionCard/1cc25e60-2b27-4851-9751-d2c4eb34a088.json b/packages/catalog-realm/SubmissionCard/1cc25e60-2b27-4851-9751-d2c4eb34a088.json index 8bd5971dfd3..a989f92c1fa 100644 --- a/packages/catalog-realm/SubmissionCard/1cc25e60-2b27-4851-9751-d2c4eb34a088.json +++ b/packages/catalog-realm/SubmissionCard/1cc25e60-2b27-4851-9751-d2c4eb34a088.json @@ -1,8 +1,20 @@ { "data": { + "meta": { + "adoptsFrom": { + "name": "SubmissionCard", + "module": "../submission-card/submission-card" + } + }, "type": "card", "attributes": { "roomId": "!nVBWtfyqdxXQknrIuR:localhost", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, "branchName": null, "allFileContents": [ { @@ -25,13 +37,7 @@ "filename": "music-coder/MusicCoder/f21c5c75-a9be-4db9-8a76-7cec46498236.json", "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"MusicCoder\",\n \"module\": \"../music-coder\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"bpm\": 124,\n \"cardTitle\": \"House Groove\",\n \"pattern\": \"stack(note('<[c3 ~ c3 ~] [c3 ~ eb3 ~] [f3 ~ f3 ~] [g3 ~ g3 ab3]>').sound('sawtooth').lpf(800).gain(0.6), note('<[c5 ~ eb5 g5] [~ g5 f5 ~] [c5 ~ eb5 g5] [~ bb5 ab5 ~]>').sound('square').gain(0.4), s('bd ~ ~ ~, ~ ~ sd ~, [~ hh]*4, ~ ~ ~ cp').gain(0.7))\",\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null\n },\n \"cardDescription\": \"Classic house music beat with funky bassline and driving rhythm\"\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" } - ], - "cardInfo": { - "name": null, - "summary": null, - "cardThumbnailURL": null, - "notes": null - } + ] }, "relationships": { "listing": { @@ -44,12 +50,6 @@ "self": null } } - }, - "meta": { - "adoptsFrom": { - "module": "../submission-card/submission-card", - "name": "SubmissionCard" - } } } } \ No newline at end of file diff --git a/packages/catalog-realm/SubmissionCard/98478514-df5d-4e6e-91ad-0764944e6ba1.json b/packages/catalog-realm/SubmissionCard/98478514-df5d-4e6e-91ad-0764944e6ba1.json index b29db6e7b8e..6ba074bf5e3 100644 --- a/packages/catalog-realm/SubmissionCard/98478514-df5d-4e6e-91ad-0764944e6ba1.json +++ b/packages/catalog-realm/SubmissionCard/98478514-df5d-4e6e-91ad-0764944e6ba1.json @@ -1,8 +1,20 @@ { "data": { + "meta": { + "adoptsFrom": { + "name": "SubmissionCard", + "module": "../submission-card/submission-card" + } + }, "type": "card", "attributes": { "roomId": "!KOzaXPpicPKuawRZQs:localhost", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, "branchName": null, "allFileContents": [ { @@ -37,13 +49,7 @@ "filename": "online-customer/OnlineCustomer/zara-ahmed.json", "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"OnlineCustomer\",\n \"module\": \"../online-customer\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"email\": {\n \"label\": \"Email\",\n \"value\": \"zara.ahmed@healthtechstartup.com\"\n },\n \"phone\": {\n \"label\": \"Phone\",\n \"value\": \"+1-647-555-1286\"\n },\n \"cardInfo\": {},\n \"totalSpent\": 2847.33,\n \"cardDescription\": null,\n \"loyaltyTier\": {\n \"name\": \"Bronze\"\n },\n \"totalOrders\": 12,\n \"customerName\": \"Zara Ahmed\",\n \"cardThumbnailURL\": null,\n \"customerSince\": \"2024-02-28T13:10:00.000Z\"\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}" } - ], - "cardInfo": { - "name": null, - "summary": null, - "cardThumbnailURL": null, - "notes": null - } + ] }, "relationships": { "listing": { @@ -56,12 +62,6 @@ "self": null } } - }, - "meta": { - "adoptsFrom": { - "module": "../submission-card/submission-card", - "name": "SubmissionCard" - } } } } \ No newline at end of file diff --git a/packages/catalog-realm/SubmissionCard/bdd6e8ab-66ea-4cb0-a575-a0edb59bdf91.json b/packages/catalog-realm/SubmissionCard/bdd6e8ab-66ea-4cb0-a575-a0edb59bdf91.json index 29282a7e069..625641d68ed 100644 --- a/packages/catalog-realm/SubmissionCard/bdd6e8ab-66ea-4cb0-a575-a0edb59bdf91.json +++ b/packages/catalog-realm/SubmissionCard/bdd6e8ab-66ea-4cb0-a575-a0edb59bdf91.json @@ -1,8 +1,20 @@ { "data": { + "meta": { + "adoptsFrom": { + "name": "SubmissionCard", + "module": "../submission-card/submission-card" + } + }, "type": "card", "attributes": { "roomId": "!dkXRRDVMhspnemshqZ:localhost", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, "branchName": null, "allFileContents": [ { @@ -21,13 +33,7 @@ "filename": "photo-collage/PhotoCollage/a2c50a4a-67ee-4c84-9a53-fe17243bbc24.json", "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"cardTitle\": \"Minimalist Photography Collection\",\n \"photos\": [\n {\n \"url\": \"https://images.unsplash.com/photo-1507608616759-54f48f0af0ee\",\n \"caption\": \"Solitude\",\n \"alt\": \"Person standing alone in a foggy landscape\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1477414348463-c0eb7f1359b6\",\n \"caption\": \"Cityscape\",\n \"alt\": \"Urban skyline with modern architecture\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1682687982107-14492010e05e\",\n \"caption\": \"Abstract Light\",\n \"alt\": \"Abstract patterns of light and shadow\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1475924156734-496f6cac6ec1\",\n \"caption\": \"Mountain Lake\",\n \"alt\": \"Serene mountain lake with forest reflections\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1526779259212-939e64788e3c\",\n \"caption\": \"Minimalist Architecture\",\n \"alt\": \"Clean lines of modern building design\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1598128558393-70ff21433be0\",\n \"caption\": \"Shadows\",\n \"alt\": \"Dramatic shadows on a white wall\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1496181133206-80ce9b88a853\",\n \"caption\": \"Workspace\",\n \"alt\": \"Minimalist desktop with laptop\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1496449903678-68ddcb189a24\",\n \"caption\": \"Urban Exploration\",\n \"alt\": \"Person walking in modern architecture\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe\",\n \"caption\": \"Negative Space\",\n \"alt\": \"Simple white room with single chair emphasizing empty space\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1494438639946-1ebd1d20bf85\",\n \"caption\": \"Industrial\",\n \"alt\": \"Industrial architecture in black and white\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1511447333015-45b65e60f6d5\",\n \"caption\": \"Mountain Silhouette\",\n \"alt\": \"Person silhouette against mountain backdrop\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1478104718532-efe04cc3ff7f\",\n \"caption\": \"Geometric\",\n \"alt\": \"Abstract geometric patterns in architecture\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1507608869274-d3177c8bb4c7\",\n \"caption\": \"Texture\",\n \"alt\": \"Textured surfaces in monochrome\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1515549832467-8783363e19b6\",\n \"caption\": \"Aerial\",\n \"alt\": \"Aerial view of landscape with minimal elements\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1605460375648-278bcbd579a6\",\n \"caption\": \"Simplicity\",\n \"alt\": \"Simple lines creating an abstract composition\"\n }\n ],\n \"darkMode\": false,\n \"cardDescription\": null,\n \"cardThumbnailURL\": null\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"../photo-collage\",\n \"name\": \"PhotoCollage\"\n }\n }\n }\n}" } - ], - "cardInfo": { - "name": null, - "summary": null, - "cardThumbnailURL": null, - "notes": null - } + ] }, "relationships": { "listing": { @@ -40,12 +46,6 @@ "self": null } } - }, - "meta": { - "adoptsFrom": { - "module": "../submission-card/submission-card", - "name": "SubmissionCard" - } } } } \ No newline at end of file diff --git a/packages/catalog-realm/submission-card/README.md b/packages/catalog-realm/submission-card/README.md index 738a8a86629..b4214e94f86 100644 --- a/packages/catalog-realm/submission-card/README.md +++ b/packages/catalog-realm/submission-card/README.md @@ -2,7 +2,9 @@ ## Overview -The `SubmissionCardPortal` is an app-level card that displays a prerendered grid of `SubmissionCard` instances. It lives in the **User Realm** (`/submissions/`) and serves as the user's personal submission tracker — showing the status of all their submitted catalog listings. +A `SubmissionCard` is an index card that records a single listing submission. It lives in the **same workspace realm where the user was working on that listing** — wherever that listing PR originated from. + +The `SubmissionCardPortal` is an app-level card that aggregates all submission cards across the user's realms into a single view. It can live anywhere; it is currently placed in the catalog realm as a temporary home. The portal hides all GitHub technical details. Users care about *"Did my submission pass?"*, not *"What is the PR number?"*. @@ -15,9 +17,9 @@ The portal hides all GitHub technical details. Users care about *"Did my submiss ↓ (webhook) [ PR Card ] ──────────── Data Source Realm (read-only, auto-generated, no user access) ↓ (referenced by) -[ Submission Card ] ───── User Realm /submissions/ (user can create, delete, archive) - ↓ -[ Submission Portal ] ─── User Realm /submissions/ (lists all submission cards) +[ Submission Card ] ───── Same realm the user submitted the listing PR from + ↓ (aggregated by) +[ Submission Portal ] ─── Anywhere (currently: catalog realm, temporary) ``` ### Data Source Realm — PR Card @@ -29,30 +31,36 @@ The portal hides all GitHub technical details. Users care about *"Did my submiss | User access | Read-only — cannot be edited, deleted, or mutated | | Purpose | Reflects the raw GitHub PR state | -### User Realm — Submission Card + Portal +### User Realm — Submission Card | Property | Value | |----------|-------| -| Location | `/submissions/` realm | +| Location | The realm the user submitted the listing PR from | | User access | Create, delete, archive | -| Purpose | User-facing status view of their submission | +| Purpose | Index card recording the user's submission and its status | | Note | Deleting a Submission Card does **not** delete the PR — it only removes the reference | +### Submission Portal + +| Property | Value | +|----------|-------| +| Location | Anywhere — currently catalog realm (temporary) | +| Queries | Only the user's own submissions, across their realms | +| Realm scope | User-selectable — can toggle which of their realms to include | + --- -## Realm Location +## Code Location -Both files live in the catalog realm (which is editable): +Both card definitions live in the catalog realm: ``` submission-card/ ├── README.md ← this file -├── submission-card-portal.gts ← portal app card → catalog realm -└── submission-card.gts ← submission card → catalog realm +├── submission-card-portal.gts ← portal app card (currently: catalog realm) +└── submission-card.gts ← submission card (instances: user's own realm) ``` -> **Future:** `SubmissionCard` instances will eventually be created in the **user's own realm** (the realm from which they submitted the listing PR), not the catalog realm. The catalog realm is used as a starting point while the per-user realm flow is not yet implemented. - --- ## Submission Card — Permissions @@ -131,7 +139,18 @@ Show only user-facing status. **Hide all GitHub technical details.** | `title` | `StringField` | Portal display name | | `description` | `StringField` | Short description shown in the header | -> The portal queries all `SubmissionCard` instances dynamically — it does **not** use `linksTo` or `containsMany`. +> The portal queries `SubmissionCard` instances dynamically across the user's realms — it does **not** use `linksTo` or `containsMany`. + +--- + +## Portal — Realm Scope + +Because `SubmissionCard` instances can live in any of the user's workspaces, the portal must query across multiple realms. The intended design is: + +- Default: query **all of the user's realms** +- UI: a realm toggle/filter so the user can narrow to a specific workspace + +**Implementation:** The portal uses `commandData` + `GetAllRealmMetasCommand` to load all writable user realms. It queries all of them by default. When the user selects specific realms via the toggle pills, only those realms are queried. While realm data is loading, the portal falls back to its own realm URL (`this.args.model[realmURL]`). --- @@ -142,7 +161,8 @@ Show only user-facing status. **Hide all GitHub technical details.** │ Header │ │ Title: "Submissions" │ │ │ -│ [ Search by listing name or title... ] [Grid][Strip] │ +│ [ Search by card title... ] [Grid][Strip] │ +│ [■ Realm A] [□ Realm B] [□ Realm C] ← shown when 2+ │ ├──────────────────────────────────────────────────────────┤ │ Grid (default) │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ @@ -165,12 +185,15 @@ Show only user-facing status. **Hide all GitHub technical details.** ## Portal — Search Functionality -Filters by `listing.name` or `listing.cardTitle` using a text input in the portal header. +Filters by `cardTitle` (the computed title on `SubmissionCard`) using a text input in the portal header. Input is debounced 300ms. ```ts get query() { const baseFilter = { - type: { module: `${this.realmHref}submission-card`, name: 'SubmissionCard' }, + type: { + module: new URL('./submission-card', import.meta.url).href, + name: 'SubmissionCard', + }, }; if (!this.searchText) { @@ -182,10 +205,7 @@ get query() { every: [ baseFilter, { - any: [ - { contains: { 'listing.name': this.searchText } }, - { contains: { 'listing.cardTitle': this.searchText } }, - ], + any: [{ contains: { cardTitle: this.searchText } }], }, ], }, @@ -199,29 +219,40 @@ get query() { | View | Layout | Fitted card size | |------|--------|-----------------| -| `grid` | `repeat(auto-fill, ~164px)` columns | Small square tile | -| `strip` | Full-width single column | Wide horizontal strip | +| `grid` | `repeat(auto-fill, 300px)` columns | `300×380px` tile | +| `strip` | Full-width single column (`1fr`) | `120px` tall strip | --- ## Portal — Grid Rendering Pattern +Uses the `CardList` base component which handles live querying, loading state, and grid/strip layout via `@viewOption`. + ```hbs -<@context.prerenderedCardSearchComponent + - <:loading>Loading submissions... - <:response as |cards|> - {{#if (eq this.selectedView 'strip')}} - - {{else}} - - {{/if}} - - +/> +``` + +CSS overrides size the list items so fitted container queries resolve correctly: + +```css +/* grid view */ +.portal-content :deep(.grid-view) { + --item-width: 300px; + --item-height: 380px; +} + +/* strip view */ +.portal-content :deep(.strip-view) { + --item-height: 120px; + grid-template-columns: 1fr; +} ``` --- @@ -238,7 +269,7 @@ Fitted cards define multiple layouts using `@container fitted-card` queries. ``` Show: icon + `cardTitle` only. -### Size 2 — Square tile (default grid, ~164×224px) +### Size 2 — Square tile (default grid, `300×380px`) ``` ┌──────────────┐ │ [icon] │ @@ -265,5 +296,5 @@ Show: icon, `cardTitle`, `listing.name`, status pill, GitHub link icon. static displayName = 'Submission Card Portal'; static prefersWideFormat = true; static headerColor = '#e5f0ff'; -static icon = SubmissionIcon; +static icon = BotIcon; // from @cardstack/boxel-icons/bot ``` diff --git a/packages/catalog-realm/submission-card/submission-card-portal.gts b/packages/catalog-realm/submission-card/submission-card-portal.gts index 792dbed3957..7705948b479 100644 --- a/packages/catalog-realm/submission-card/submission-card-portal.gts +++ b/packages/catalog-realm/submission-card/submission-card-portal.gts @@ -1,5 +1,7 @@ import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; import { debounce } from 'lodash'; import { @@ -10,9 +12,18 @@ import { realmURL, } from 'https://cardstack.com/base/card-api'; import StringField from 'https://cardstack.com/base/string'; -import { type Query } from '@cardstack/runtime-common'; +import { type Query, type getCards } from '@cardstack/runtime-common'; +import { commandData } from 'https://cardstack.com/base/resources/command-data'; +import type { + GetAllRealmMetasResult, + RealmMetaField, +} from 'https://cardstack.com/base/command'; +import GetAllRealmMetasCommand from '@cardstack/boxel-host/commands/get-all-realm-metas'; -import { BoxelInput, ViewSelector } from '@cardstack/boxel-ui/components'; +import GlimmerComponent from '@glimmer/component'; + +import { eq, gt } from '@cardstack/boxel-ui/helpers'; +import { BoxelInput, ViewSelector, Pill } from '@cardstack/boxel-ui/components'; import { type ViewItem } from '@cardstack/boxel-ui/components'; import CardList from 'https://cardstack.com/base/components/card-list'; import BotIcon from '@cardstack/boxel-icons/bot'; @@ -28,9 +39,66 @@ const SUBMISSION_VIEW_OPTIONS: ViewItem[] = [ { id: 'grid', icon: GridIcon }, ]; +interface RealmTabsSignature { + Args: { + realms: RealmMetaField[]; + selectedRealm: string | null; + onChange: (realm: string | null) => void; + }; +} + +class RealmTabs extends GlimmerComponent { + +} + class Isolated extends Component { @tracked searchText: string = ''; @tracked selectedView: string = 'grid'; + @tracked selectedRealm: string | null = null; private debouncedSetSearch = debounce((value: string) => { this.searchText = value; @@ -46,18 +114,81 @@ class Isolated extends Component { this.selectedView = id; } + @action + selectRealm(realm: string | null) { + this.selectedRealm = realm; + } + + allRealmsInfoResource = commandData( + this, + GetAllRealmMetasCommand, + ); + + // All realm URLs known to the host — used as the search scope + get allRealmUrls(): string[] { + const resource = this.allRealmsInfoResource; + if (resource?.isSuccess && resource.cardResult) { + return ( + (resource.cardResult as GetAllRealmMetasResult).results?.map( + (r) => r.url, + ) ?? [] + ); + } + return []; + } + + // Query SubmissionCards across all known realms so we can see which ones + // actually have instances (via instancesByRealm) + submissionDiscovery: ReturnType | undefined = + this.args.context?.getCards( + this, + () => this.baseTypeFilter, + () => this.allRealmUrls, + { isLive: true }, + ); + + get baseTypeFilter(): Query { + return { + filter: { + type: { + module: new URL('./submission-card', import.meta.url).href, + name: 'SubmissionCard', + }, + }, + }; + } + + // Only realms that actually have SubmissionCard instances, with full meta + get availableRealms(): RealmMetaField[] { + const realmsWithCards = new Set( + (this.submissionDiscovery?.instancesByRealm ?? []).map((r) => r.realm), + ); + const allMetas = + (this.allRealmsInfoResource?.cardResult as GetAllRealmMetasResult) + ?.results ?? []; + return allMetas.filter((r) => realmsWithCards.has(r.url)); + } + get realmHrefs(): string[] { + // Fall back to own realm while realm data is loading + if (!this.allRealmsInfoResource?.isSuccess) { + const url = this.args.model[realmURL]; + return url ? [url.href] : []; + } + + if (this.selectedRealm) { + return [this.selectedRealm]; + } + + // All realms selected — query every realm that has submissions + const urls = this.availableRealms.map((r) => r.url); + if (urls.length > 0) return urls; const url = this.args.model[realmURL]; return url ? [url.href] : []; } get query(): Query { - const baseFilter = { - type: { - module: new URL('./submission-card', import.meta.url).href, - name: 'SubmissionCard', - }, - }; + const baseFilter = this.baseTypeFilter.filter!; if (!this.searchText) { return { filter: baseFilter }; @@ -94,6 +225,13 @@ class Isolated extends Component { @items={{SUBMISSION_VIEW_OPTIONS}} /> + {{#if (gt this.availableRealms.length 1)}} + + {{/if}}
From 5977b1ea1e0950d6b51ab28d5deeadd5045ee2b0 Mon Sep 17 00:00:00 2001 From: Lucas Date: Thu, 5 Mar 2026 21:39:20 +0800 Subject: [PATCH 06/19] set branchName value in create-submission command --- packages/catalog-realm/commands/create-submission.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/catalog-realm/commands/create-submission.ts b/packages/catalog-realm/commands/create-submission.ts index a2239432c37..a37f8cd07f8 100644 --- a/packages/catalog-realm/commands/create-submission.ts +++ b/packages/catalog-realm/commands/create-submission.ts @@ -7,6 +7,7 @@ import { logger, planInstanceInstall, planModuleInstall, + toBranchName, type ListingPathResolver, type LooseSingleCardDocument, type Relationship, @@ -111,10 +112,12 @@ export default class CreateSubmissionCommand extends Command< if (!listing.name) { throw new Error('Missing listing.name for CreateSubmission'); } + let branchName = toBranchName(roomId, listing.name); let submission = new SubmissionCard({ listing, roomId, + branchName, allFileContents: filesWithContent.map( (file) => new FileContentField({ From 364c919abff7dcff8135048aa80293ca7e671545 Mon Sep 17 00:00:00 2001 From: Lucas Date: Thu, 5 Mar 2026 21:52:53 +0800 Subject: [PATCH 07/19] clean the submission instances --- .../1cc25e60-2b27-4851-9751-d2c4eb34a088.json | 55 --------------- .../98478514-df5d-4e6e-91ad-0764944e6ba1.json | 67 ------------------- .../bdd6e8ab-66ea-4cb0-a575-a0edb59bdf91.json | 51 -------------- .../submission-card/submission-card.gts | 1 + 4 files changed, 1 insertion(+), 173 deletions(-) delete mode 100644 packages/catalog-realm/SubmissionCard/1cc25e60-2b27-4851-9751-d2c4eb34a088.json delete mode 100644 packages/catalog-realm/SubmissionCard/98478514-df5d-4e6e-91ad-0764944e6ba1.json delete mode 100644 packages/catalog-realm/SubmissionCard/bdd6e8ab-66ea-4cb0-a575-a0edb59bdf91.json diff --git a/packages/catalog-realm/SubmissionCard/1cc25e60-2b27-4851-9751-d2c4eb34a088.json b/packages/catalog-realm/SubmissionCard/1cc25e60-2b27-4851-9751-d2c4eb34a088.json deleted file mode 100644 index a989f92c1fa..00000000000 --- a/packages/catalog-realm/SubmissionCard/1cc25e60-2b27-4851-9751-d2c4eb34a088.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "data": { - "meta": { - "adoptsFrom": { - "name": "SubmissionCard", - "module": "../submission-card/submission-card" - } - }, - "type": "card", - "attributes": { - "roomId": "!nVBWtfyqdxXQknrIuR:localhost", - "cardInfo": { - "name": null, - "notes": null, - "summary": null, - "cardThumbnailURL": null - }, - "branchName": null, - "allFileContents": [ - { - "filename": "CardListing/3b6b80ad-7df4-4e53-a360-9a6eb2c74565.json", - "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"CardListing\",\n \"module\": \"../catalog-app/listing/listing\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"name\": \"Music Coder\",\n \"images\": [\n \"https://boxel-images.boxel.ai/app-assets/catalog/music-coder-listing/screenshot_01.png\"\n ],\n \"summary\": \"An interactive music coding component that enables users to create, play, and visualize synthesizer and drum pattern sequences, with preset options, pattern editing, and real-time waveform visualization. Its primary purpose is to facilitate coding-based music composition and live performance within a customizable web interface.\",\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": \"https://boxel-images.boxel.ai/app-assets/catalog/music-coder-listing/thumbnail.png\"\n }\n },\n \"relationships\": {\n \"tags\": {\n \"links\": {\n \"self\": null\n }\n },\n \"skills\": {\n \"links\": {\n \"self\": null\n }\n },\n \"license\": {\n \"links\": {\n \"self\": \"../License/4c5a023b-a72c-4f90-930b-da60a1de5b2d\"\n }\n },\n \"specs.0\": {\n \"links\": {\n \"self\": \"../Spec/6e428463-fc70-432e-9c88-59f61d4a2e48\"\n }\n },\n \"publisher\": {\n \"links\": {\n \"self\": null\n }\n },\n \"categories.0\": {\n \"links\": {\n \"self\": \"../Category/entertainment-media\"\n }\n },\n \"examples.0\": {\n \"links\": {\n \"self\": \"../music-coder/MusicCoder/demo-beat\"\n }\n },\n \"examples.1\": {\n \"links\": {\n \"self\": \"../music-coder/MusicCoder/f21c5c75-a9be-4db9-8a76-7cec46498236\"\n }\n },\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" - }, - { - "filename": "music-coder/music-coder.gts", - "contents": "import { not, eq } from '@cardstack/boxel-ui/helpers';\nimport {\n CardDef,\n Component,\n field,\n contains,\n} from 'https://cardstack.com/base/card-api';\nimport StringField from 'https://cardstack.com/base/string';\nimport NumberField from 'https://cardstack.com/base/number';\nimport TextAreaField from 'https://cardstack.com/base/text-area';\nimport { tracked } from '@glimmer/tracking';\nimport { on } from '@ember/modifier';\nimport Modifier from 'ember-modifier';\nimport MusicIcon from '@cardstack/boxel-icons/music';\nimport PlayIcon from '@cardstack/boxel-icons/play';\nimport StopIcon from '@cardstack/boxel-icons/square';\nimport RefreshIcon from '@cardstack/boxel-icons/refresh-cw';\n\ninterface CanvasModifierSignature {\n Element: HTMLCanvasElement;\n Args: {\n Named: {\n component: MusicCoderIsolated;\n };\n };\n}\n\nclass CanvasModifier extends Modifier {\n modify(\n element: HTMLCanvasElement,\n _positional: [],\n named: { component: MusicCoderIsolated },\n ) {\n if (named.component) {\n named.component.canvasElement = element;\n }\n }\n}\n\nclass MusicCoderIsolated extends Component {\n @tracked isPlaying = false;\n @tracked errorMessage = '';\n @tracked currentPattern =\n this.args.model.pattern || 'note(\"60 64 67 72\").s(\"sawtooth\")';\n @tracked currentBpm = this.args.model.bpm || 120;\n @tracked isStrudelInitialized = false;\n\n private strudel!: any;\n private player: { stop: () => void } | null = null;\n canvasElement: HTMLCanvasElement | null = null;\n private animationFrameId: number | null = null;\n\n // Pattern presets organized by category\n presets = [\n // Synth Presets\n {\n name: '🎹 Saw Synth Melody',\n pattern: 'note(\"60 64 67 72\").s(\"sawtooth\")',\n category: 'synth',\n },\n {\n name: '🎹 Square Bass',\n pattern: 'note(\"36 43 48\").s(\"square\")',\n category: 'synth',\n },\n {\n name: '🎹 Sine Arpeggio',\n pattern: 'note(\"c4 e4 g4 c5\").s(\"sine\")',\n category: 'synth',\n },\n {\n name: '🎹 Triangle Lead',\n pattern: 'note(\"60 62 64 65 67\").s(\"triangle\")',\n category: 'synth',\n },\n {\n name: '🎹 Sawtooth Chord',\n pattern: 'note(\"48 52 55 59\").s(\"sawtooth\")',\n category: 'synth',\n },\n\n // Drum Presets\n {\n name: '🥁 Basic Drums',\n pattern: 's(\"bd sd, hh*4\")',\n category: 'drums',\n },\n {\n name: '🥁 Fast Drums',\n pattern: 's(\"bd sd*2, hh*8\")',\n category: 'drums',\n },\n {\n name: '🥁 Syncopated Beat',\n pattern: 's(\"bd ~ bd sd, hh*8\")',\n category: 'drums',\n },\n {\n name: '🥁 Euclidean Rhythm',\n pattern: 's(\"bd(3,8), sd(5,8,2)\")',\n category: 'drums',\n },\n\n // Full Compositions\n {\n name: '🎼 Simple Mix',\n pattern: 'stack(s(\"bd sd\"), note(\"c3 eb3\").s(\"sawtooth\"))',\n category: 'full',\n },\n ];\n\n get canPlay() {\n return this.currentPattern.trim().length > 0 && this.isStrudelInitialized;\n }\n\n private async ensureStrudel() {\n if (typeof window === 'undefined') return;\n if (this.strudel) return;\n\n const mod = await import(\n // @ts-ignore\n 'https://cdn.jsdelivr.net/npm/@strudel/web@1.2.5/+esm'\n );\n this.strudel = mod as any;\n await this.strudel.initStrudel();\n\n // Load sample libraries\n const libraries = ['github:tidalcycles/Dirt-Samples'];\n await Promise.all(\n libraries.map((lib: string) => this.strudel.samples(lib)),\n );\n\n this.isStrudelInitialized = true;\n }\n\n private startWaveformVisualization(retryCount = 0) {\n if (!this.canvasElement || !this.strudel?.analysers) {\n return;\n }\n\n const analyserId = 'music-studio-scope';\n const analyser = this.strudel.analysers[analyserId];\n\n if (!analyser) {\n if (retryCount < 10) {\n setTimeout(\n () => {\n this.startWaveformVisualization(retryCount + 1);\n },\n 100 * (retryCount + 1),\n );\n }\n return;\n }\n\n const canvas = this.canvasElement;\n const ctx = canvas.getContext('2d');\n if (!ctx) return;\n\n const dataArray = new Uint8Array(analyser.fftSize);\n\n const draw = () => {\n this.animationFrameId = requestAnimationFrame(draw);\n\n analyser.getByteTimeDomainData(dataArray);\n\n // Clear canvas with dark background\n ctx.fillStyle = '#18181b';\n ctx.fillRect(0, 0, canvas.width, canvas.height);\n\n // Draw waveform\n ctx.lineWidth = 2;\n ctx.strokeStyle = '#3b82f6';\n ctx.beginPath();\n\n const sliceWidth = canvas.width / dataArray.length;\n let x = 0;\n\n for (let i = 0; i < dataArray.length; i++) {\n const v = dataArray[i] / 255.0;\n const y = v * canvas.height;\n\n if (i === 0) {\n ctx.moveTo(x, y);\n } else {\n ctx.lineTo(x, y);\n }\n\n x += sliceWidth;\n }\n\n ctx.stroke();\n };\n\n draw();\n }\n\n private stopWaveformVisualization() {\n if (this.animationFrameId !== null) {\n cancelAnimationFrame(this.animationFrameId);\n this.animationFrameId = null;\n }\n\n // Clear canvas\n if (this.canvasElement) {\n const ctx = this.canvasElement.getContext('2d');\n if (ctx) {\n ctx.fillStyle = '#18181b';\n ctx.fillRect(0, 0, this.canvasElement.width, this.canvasElement.height);\n }\n }\n }\n\n play = async () => {\n try {\n await this.ensureStrudel();\n const { evaluate } = this.strudel;\n\n const pattern = await evaluate(this.currentPattern, false);\n // Stop old player and visualization\n this.player?.stop();\n this.stopWaveformVisualization();\n\n // Add analyze() to the pattern chain and start with tempo\n const analyserId = 'music-studio-scope';\n this.player = pattern\n .analyze(analyserId)\n .cpm(this.currentBpm / 2)\n .play();\n\n this.isPlaying = true;\n this.errorMessage = '';\n\n // Wait a bit for Strudel to actually create the analyser, then start visualization\n setTimeout(() => {\n this.startWaveformVisualization();\n }, 100);\n } catch (error: any) {\n let errorMsg = error.message || 'Unknown error';\n\n this.errorMessage = errorMsg;\n this.isPlaying = false;\n console.error('Strudel error:', error);\n }\n };\n\n stop = () => {\n if (this.strudel?.hush) {\n this.strudel.hush();\n }\n this.player = null;\n this.isPlaying = false;\n this.stopWaveformVisualization();\n };\n\n update = async () => {\n if (!this.isPlaying) {\n await this.play();\n return;\n }\n\n this.stop();\n await this.play();\n };\n\n loadPreset = async (event: Event) => {\n const target = event.target as HTMLSelectElement;\n const selectedPattern = target.value;\n if (selectedPattern) {\n this.currentPattern = selectedPattern;\n this.args.model.pattern = selectedPattern;\n await this.stop();\n await this.play();\n }\n };\n\n updatePattern = (event: Event) => {\n const target = event.target as HTMLTextAreaElement;\n this.currentPattern = target.value;\n this.args.model.pattern = target.value;\n };\n\n updateBpm = (event: Event) => {\n const target = event.target as HTMLInputElement;\n const newBpm = parseInt(target.value, 10);\n this.currentBpm = newBpm;\n this.args.model.bpm = newBpm;\n };\n\n willDestroy() {\n if (this.strudel?.hush) {\n this.strudel.hush();\n }\n this.stopWaveformVisualization();\n super.willDestroy?.();\n }\n\n \n}\n\nexport class MusicCoder extends CardDef {\n static displayName = 'Music Coder';\n static icon = MusicIcon;\n\n @field cardTitle = contains(StringField);\n @field cardDescription = contains(TextAreaField);\n @field pattern = contains(TextAreaField);\n @field bpm = contains(NumberField);\n\n static isolated = MusicCoderIsolated;\n\n static embedded = class Embedded extends Component {\n \n };\n}\n" - }, - { - "filename": "Spec/6e428463-fc70-432e-9c88-59f61d4a2e48.json", - "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../music-coder/music-coder\",\n \"name\": \"MusicCoder\"\n },\n \"specType\": \"card\",\n \"containedExamples\": [],\n \"cardTitle\": \"MusicCoder\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" - }, - { - "filename": "music-coder/MusicCoder/demo-beat.json", - "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"MusicCoder\",\n \"module\": \"../music-coder\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"bpm\": 128,\n \"cardTitle\": \"Classic Video Game Music\",\n \"pattern\": \"stack(\\n // Main melody - the complete theme!\\n note(\\\"<[e5 e5 ~ e5 ~ c5 e5 ~] [g5 ~ ~ ~ g4 ~ ~ ~] [c5 ~ ~ g4 ~ ~ e4 ~] [a4 ~ b4 ~ bb4 a4 ~ ~] [g4 e5 ~ g5 a5 ~ f5 g5] [~ e5 ~ c5 d5 b4 ~ ~] [c5 ~ ~ g4 ~ ~ e4 ~] [a4 ~ b4 ~ bb4 a4 ~ ~] [g4 e5 ~ g5 a5 ~ f5 g5] [~ e5 ~ c5 d5 b4 ~ ~]>\\\").sound(\\\"square\\\").gain(0.5),\\n \\n // Bass line\\n note(\\\"<[c3 ~ ~ ~] [g2 ~ ~ ~] [c3 ~ ~ ~] [f2 ~ ~ ~] [c3 ~ ~ ~] [g2 ~ ~ ~] [c3 ~ ~ ~] [f2 ~ ~ ~] [c3 ~ ~ ~] [g2 ~ ~ ~]>\\\").sound(\\\"sawtooth\\\").gain(0.4).lpf(600),\\n \\n // Kick and snare pattern\\n s(\\\"bd ~ ~ ~, ~ ~ sd ~\\\").gain(0.5),\\n \\n // Hi-hat for groove\\n s(\\\"~ hh ~ hh\\\").gain(0.3)\\n)\",\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null\n },\n \"cardDescription\": \"A fun 8-bit style melody inspired by classic video game music\"\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" - }, - { - "filename": "music-coder/MusicCoder/f21c5c75-a9be-4db9-8a76-7cec46498236.json", - "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"MusicCoder\",\n \"module\": \"../music-coder\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"bpm\": 124,\n \"cardTitle\": \"House Groove\",\n \"pattern\": \"stack(note('<[c3 ~ c3 ~] [c3 ~ eb3 ~] [f3 ~ f3 ~] [g3 ~ g3 ab3]>').sound('sawtooth').lpf(800).gain(0.6), note('<[c5 ~ eb5 g5] [~ g5 f5 ~] [c5 ~ eb5 g5] [~ bb5 ab5 ~]>').sound('square').gain(0.4), s('bd ~ ~ ~, ~ ~ sd ~, [~ hh]*4, ~ ~ ~ cp').gain(0.7))\",\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null\n },\n \"cardDescription\": \"Classic house music beat with funky bassline and driving rhythm\"\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" - } - ] - }, - "relationships": { - "listing": { - "links": { - "self": "../CardListing/3b6b80ad-7df4-4e53-a360-9a6eb2c74565" - } - }, - "cardInfo.theme": { - "links": { - "self": null - } - } - } - } -} \ No newline at end of file diff --git a/packages/catalog-realm/SubmissionCard/98478514-df5d-4e6e-91ad-0764944e6ba1.json b/packages/catalog-realm/SubmissionCard/98478514-df5d-4e6e-91ad-0764944e6ba1.json deleted file mode 100644 index 6ba074bf5e3..00000000000 --- a/packages/catalog-realm/SubmissionCard/98478514-df5d-4e6e-91ad-0764944e6ba1.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "data": { - "meta": { - "adoptsFrom": { - "name": "SubmissionCard", - "module": "../submission-card/submission-card" - } - }, - "type": "card", - "attributes": { - "roomId": "!KOzaXPpicPKuawRZQs:localhost", - "cardInfo": { - "name": null, - "notes": null, - "summary": null, - "cardThumbnailURL": null - }, - "branchName": null, - "allFileContents": [ - { - "filename": "CardListing/139dcc13-6a07-40f4-b8aa-264f8eba8e59.json", - "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"CardListing\",\n \"module\": \"../catalog-app/listing/listing\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"name\": \"Online Customer Listing\",\n \"images\": [\n \"https://boxel-images.boxel.ai/app-assets/catalog/online-customer-listing/screenshot_01.png\"\n ],\n \"summary\": \"Displaying the updated Online Customer Management System listing\",\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": \"https://boxel-images.boxel.ai/app-assets/catalog/online-customer-listing/thumbnail.png\"\n }\n },\n \"relationships\": {\n \"tags\": {\n \"links\": {\n \"self\": null\n }\n },\n \"skills\": {\n \"links\": {\n \"self\": null\n }\n },\n \"license\": {\n \"links\": {\n \"self\": null\n }\n },\n \"specs.0\": {\n \"links\": {\n \"self\": \"../Spec/contact-link\"\n }\n },\n \"specs.1\": {\n \"links\": {\n \"self\": \"../Spec/3a7a139d-cc13-4a07-80f4-b8aa264f8eba\"\n }\n },\n \"publisher\": {\n \"links\": {\n \"self\": \"../Publisher/9d3ca05b-684b-408f-8d8c-dd353d9956e0\"\n }\n },\n \"examples.0\": {\n \"links\": {\n \"self\": \"../online-customer/OnlineCustomer/elena-vasquez\"\n }\n },\n \"examples.1\": {\n \"links\": {\n \"self\": \"../online-customer/OnlineCustomer/james-mitchell-brown\"\n }\n },\n \"examples.2\": {\n \"links\": {\n \"self\": \"../online-customer/OnlineCustomer/zara-ahmed\"\n }\n },\n \"categories.0\": {\n \"links\": {\n \"self\": \"../Category/e-commerce-online-sales\"\n }\n },\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" - }, - { - "filename": "fields/contact-link.gts", - "contents": "import {\n Component,\n field,\n contains,\n StringField,\n FieldDef,\n} from 'https://cardstack.com/base/card-api';\nimport UrlField from 'https://cardstack.com/base/url';\n\nimport {\n BoxelSelect,\n FieldContainer,\n Pill,\n} from '@cardstack/boxel-ui/components';\n\nimport type IconComponent from '@cardstack/boxel-icons/captions';\nimport Email from '@cardstack/boxel-icons/mail';\nimport Link from '@cardstack/boxel-icons/link';\nimport Phone from '@cardstack/boxel-icons/phone';\n\nexport interface ContactLink {\n type: 'email' | 'tel' | 'link' | string;\n label: string;\n icon: typeof IconComponent;\n cta: string;\n}\n\nconst contactValues: ContactLink[] = [\n {\n type: 'email',\n label: 'Email',\n icon: Email,\n cta: 'Email',\n },\n {\n type: 'tel',\n label: 'Phone',\n icon: Phone,\n cta: 'Contact',\n },\n {\n type: 'link',\n label: 'Other',\n icon: Link,\n cta: 'Connect',\n },\n];\n\nexport default class ContactLinkField extends FieldDef {\n static displayName = 'Contact Link';\n static values: ContactLink[] = contactValues;\n @field label = contains(StringField);\n @field value = contains(StringField);\n @field url = contains(UrlField, {\n computeVia: function (this: ContactLinkField) {\n switch (this.item?.type) {\n case 'email':\n return `mailto:${this.value}`;\n case 'tel':\n return `tel:${this.value}`;\n default:\n return this.value;\n }\n },\n });\n get items() {\n if (this.constructor && 'values' in this.constructor) {\n return this.constructor.values as ContactLink[];\n }\n return ContactLinkField.values;\n }\n get item() {\n return this.items?.find((val) => val.label === this.label);\n }\n static edit = class Edit extends Component {\n \n\n options = this.args.model.items;\n\n onSelect = (option: ContactLink) => (this.args.model.label = option.label);\n\n get selectedOption() {\n return this.options?.find(\n (option) => option.label === this.args.model.label,\n );\n }\n\n get label() {\n switch (this.selectedOption?.type) {\n case 'email':\n return 'Address';\n case 'tel':\n return 'Number';\n default:\n return 'Link';\n }\n }\n };\n static atom = class Atom extends Component {\n \n };\n static embedded = class Embedded extends Component {\n \n };\n}\n" - }, - { - "filename": "online-customer/online-customer.gts", - "contents": "import {\n CardDef,\n FieldDef,\n field,\n contains,\n Component,\n} from 'https://cardstack.com/base/card-api';\nimport StringField from 'https://cardstack.com/base/string';\nimport NumberField from 'https://cardstack.com/base/number';\nimport DatetimeField from 'https://cardstack.com/base/datetime';\nimport ContactLinkField from '../fields/contact-link';\n\nimport {\n formatCurrency,\n formatDateTime,\n formatNumber,\n} from '@cardstack/boxel-ui/helpers';\nimport { BoxelSelect } from '@cardstack/boxel-ui/components';\n\nimport CustomerIcon from '@cardstack/boxel-icons/user';\nimport { tracked } from '@glimmer/tracking';\nimport { action } from '@ember/object';\n\nclass LoyaltyTierFieldEdit extends Component {\n get initialTierName() {\n return this.args.model.name || 'Bronze';\n }\n\n @tracked selectedTier: { name: string } | null = {\n name: this.initialTierName,\n };\n @tracked tierOptions = [\n { name: 'Bronze' },\n { name: 'Silver' },\n { name: 'Gold' },\n { name: 'Platinum' },\n ];\n\n @action onSelectTier(tier: { name: string } | null) {\n this.selectedTier = tier;\n // Update the field's name property directly\n if (tier) {\n this.args.model.name = tier.name;\n }\n }\n\n \n}\n\nclass LoyaltyTierField extends FieldDef {\n static displayName = 'Loyalty Tier';\n @field name = contains(StringField);\n static edit = LoyaltyTierFieldEdit;\n}\n\nclass IsolatedTemplate extends Component {\n get initials() {\n try {\n const name = this.args.model?.customerName;\n if (!name) return '?';\n return name\n .split(' ')\n .map((n: string) => n[0])\n .join('')\n .toUpperCase()\n .slice(0, 2);\n } catch (e) {\n return '?';\n }\n }\n\n get customerTier() {\n try {\n return this.args.model?.loyaltyTier?.name ?? 'Bronze';\n } catch (e) {\n return 'Bronze';\n }\n }\n\n get isPremium() {\n return this.customerTier === 'Gold' || this.customerTier === 'Platinum';\n }\n\n \n}\n\nclass EmbeddedTemplate extends Component {\n get initials() {\n try {\n const name = this.args.model?.customerName;\n if (!name) return '?';\n return name\n .split(' ')\n .map((n: string) => n[0])\n .join('')\n .toUpperCase()\n .slice(0, 2);\n } catch (e) {\n return '?';\n }\n }\n\n get customerTier() {\n try {\n return this.args.model?.loyaltyTier?.name ?? 'Bronze';\n } catch (e) {\n return 'Bronze';\n }\n }\n\n get isPremium() {\n return this.customerTier === 'Gold' || this.customerTier === 'Platinum';\n }\n\n \n}\n\nclass FittedTemplate extends Component {\n get initials() {\n try {\n const name = this.args.model?.customerName;\n if (!name) return '?';\n return name\n .split(' ')\n .map((n) => n[0])\n .join('')\n .toUpperCase()\n .slice(0, 2);\n } catch (e) {\n return '?';\n }\n }\n\n get customerTier() {\n try {\n return this.args.model?.loyaltyTier?.name ?? 'Bronze';\n } catch (e) {\n return 'Bronze';\n }\n }\n\n get totalSpentFormatted() {\n try {\n return this.args.model?.totalSpent\n ? formatCurrency(this.args.model.totalSpent, {\n currency: 'USD',\n size: 'tiny',\n })\n : null;\n } catch (e) {\n return null;\n }\n }\n\n \n}\n\nexport class OnlineCustomer extends CardDef {\n static displayName = 'Customer';\n static icon = CustomerIcon;\n\n @field customerName = contains(StringField);\n @field email = contains(ContactLinkField);\n @field phone = contains(ContactLinkField);\n @field totalOrders = contains(NumberField);\n @field totalSpent = contains(NumberField);\n @field customerSince = contains(DatetimeField);\n @field loyaltyTier = contains(LoyaltyTierField);\n\n @field cardTitle = contains(StringField, {\n computeVia: function (this: OnlineCustomer) {\n try {\n const name = this.customerName ?? 'Customer';\n return name.length > 50 ? name.substring(0, 47) + '...' : name;\n } catch (e) {\n console.error('OnlineCustomer: Error computing title', e);\n return 'Customer';\n }\n },\n });\n\n static isolated = IsolatedTemplate;\n static embedded = EmbeddedTemplate;\n static fitted = FittedTemplate;\n}\n" - }, - { - "filename": "Spec/contact-link.json", - "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"Spec\",\n \"module\": \"https://cardstack.com/base/spec\"\n },\n \"fields\": {\n \"containedExamples\": [\n {\n \"adoptsFrom\": {\n \"module\": \"../fields/contact-link\",\n \"name\": \"default\"\n }\n }\n ]\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"ref\": {\n \"name\": \"default\",\n \"module\": \"../fields/contact-link\"\n },\n \"cardTitle\": \"ContactLinkField\",\n \"readMe\": null,\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null\n },\n \"specType\": \"field\",\n \"cardDescription\": null,\n \"containedExamples\": [\n {\n \"label\": null,\n \"value\": null\n }\n ]\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n },\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" - }, - { - "filename": "Spec/3a7a139d-cc13-4a07-80f4-b8aa264f8eba.json", - "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../online-customer/online-customer\",\n \"name\": \"OnlineCustomer\"\n },\n \"specType\": \"card\",\n \"containedExamples\": [],\n \"cardTitle\": \"OnlineCustomer\",\n \"cardDescription\": \"Spec of OnlineCustomer\",\n \"cardInfo\": {},\n \"cardThumbnailURL\": null\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}" - }, - { - "filename": "online-customer/OnlineCustomer/elena-vasquez.json", - "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"OnlineCustomer\",\n \"module\": \"../online-customer\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"email\": {\n \"label\": \"Email\",\n \"value\": \"elena.vasquez@artgallery.org\"\n },\n \"phone\": {\n \"label\": \"Phone\",\n \"value\": \"+1-212-555-3847\"\n },\n \"cardInfo\": {},\n \"totalSpent\": 12389.5,\n \"cardDescription\": null,\n \"loyaltyTier\": {\n \"name\": \"Silver\"\n },\n \"totalOrders\": 45,\n \"customerName\": \"Elena Vasquez\",\n \"cardThumbnailURL\": null,\n \"customerSince\": \"2023-09-12T16:20:00.000Z\"\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}" - }, - { - "filename": "online-customer/OnlineCustomer/james-mitchell-brown.json", - "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"OnlineCustomer\",\n \"module\": \"../online-customer\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"email\": {\n \"label\": \"Email\",\n \"value\": \"j.brown@quantumdev.io\"\n },\n \"phone\": {\n \"label\": \"Phone\",\n \"value\": \"+1-206-555-9204\"\n },\n \"cardInfo\": {},\n \"totalSpent\": 67823.94,\n \"cardDescription\": null,\n \"loyaltyTier\": {\n \"name\": \"Platinum\"\n },\n \"totalOrders\": 238,\n \"customerName\": \"James Mitchell-Brown\",\n \"cardThumbnailURL\": null,\n \"customerSince\": \"2021-11-03T08:45:00.000Z\"\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}" - }, - { - "filename": "online-customer/OnlineCustomer/zara-ahmed.json", - "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"OnlineCustomer\",\n \"module\": \"../online-customer\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"email\": {\n \"label\": \"Email\",\n \"value\": \"zara.ahmed@healthtechstartup.com\"\n },\n \"phone\": {\n \"label\": \"Phone\",\n \"value\": \"+1-647-555-1286\"\n },\n \"cardInfo\": {},\n \"totalSpent\": 2847.33,\n \"cardDescription\": null,\n \"loyaltyTier\": {\n \"name\": \"Bronze\"\n },\n \"totalOrders\": 12,\n \"customerName\": \"Zara Ahmed\",\n \"cardThumbnailURL\": null,\n \"customerSince\": \"2024-02-28T13:10:00.000Z\"\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}" - } - ] - }, - "relationships": { - "listing": { - "links": { - "self": "../CardListing/139dcc13-6a07-40f4-b8aa-264f8eba8e59" - } - }, - "cardInfo.theme": { - "links": { - "self": null - } - } - } - } -} \ No newline at end of file diff --git a/packages/catalog-realm/SubmissionCard/bdd6e8ab-66ea-4cb0-a575-a0edb59bdf91.json b/packages/catalog-realm/SubmissionCard/bdd6e8ab-66ea-4cb0-a575-a0edb59bdf91.json deleted file mode 100644 index 625641d68ed..00000000000 --- a/packages/catalog-realm/SubmissionCard/bdd6e8ab-66ea-4cb0-a575-a0edb59bdf91.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "data": { - "meta": { - "adoptsFrom": { - "name": "SubmissionCard", - "module": "../submission-card/submission-card" - } - }, - "type": "card", - "attributes": { - "roomId": "!dkXRRDVMhspnemshqZ:localhost", - "cardInfo": { - "name": null, - "notes": null, - "summary": null, - "cardThumbnailURL": null - }, - "branchName": null, - "allFileContents": [ - { - "filename": "CardListing/24d2eed4-9754-4636-9667-72f516ad6b00.json", - "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"CardListing\",\n \"module\": \"../catalog-app/listing/listing\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"name\": \"Photo Collage - Minimalist Photography Collection\",\n \"images\": [\n \"https://boxel-images.boxel.ai/app-assets/catalog/photo-collage---minimalist-photography-collection-listing/screenshot_01.png\",\n \"https://boxel-images.boxel.ai/app-assets/catalog/photo-collage---minimalist-photography-collection-listing/screenshot_02.png\"\n ],\n \"summary\": \"Dynamic photo collage with masonry grid layout - upload multiple images with captions while viewing an adaptive grid display. Features varied sizing patterns, hover-reveal captions, smooth scaling animations, and elegant dark mode aesthetics for professional photo presentations.\",\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": \"https://boxel-images.boxel.ai/app-assets/catalog/photo-collage---minimalist-photography-collection-listing/thumbnail.png\"\n }\n },\n \"relationships\": {\n \"tags\": {\n \"links\": {\n \"self\": null\n }\n },\n \"skills\": {\n \"links\": {\n \"self\": null\n }\n },\n \"license\": {\n \"links\": {\n \"self\": null\n }\n },\n \"specs.0\": {\n \"links\": {\n \"self\": \"../Spec/eb5d5424-d2ee-4497-94d6-36166772f516\"\n }\n },\n \"publisher\": {\n \"links\": {\n \"self\": \"../Publisher/9d3ca05b-684b-408f-8d8c-dd353d9956e0\"\n }\n },\n \"examples.0\": {\n \"links\": {\n \"self\": \"../photo-collage/PhotoCollage/a2c50a4a-67ee-4c84-9a53-fe17243bbc24\"\n }\n },\n \"categories.0\": {\n \"links\": {\n \"self\": \"../Category/design-creative\"\n }\n },\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" - }, - { - "filename": "photo-collage/photo-collage.gts", - "contents": "import BooleanField from 'https://cardstack.com/base/boolean';\nimport {\n CardDef,\n field,\n contains,\n containsMany,\n Component,\n FieldDef,\n} from 'https://cardstack.com/base/card-api';\nimport StringField from 'https://cardstack.com/base/string';\nimport UrlField from 'https://cardstack.com/base/url';\n\nexport class PhotoItem extends FieldDef {\n static displayName = 'Photo';\n\n @field url = contains(UrlField);\n @field caption = contains(StringField);\n @field alt = contains(StringField);\n}\n\nexport class PhotoCollage extends CardDef {\n static displayName = 'Photo Collage';\n static prefersWideFormat = true;\n\n @field cardTitle = contains(StringField);\n @field photos = containsMany(PhotoItem);\n @field darkMode = contains(BooleanField);\n\n static isolated = class Isolated extends Component {\n \n };\n}\n" - }, - { - "filename": "Spec/eb5d5424-d2ee-4497-94d6-36166772f516.json", - "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"name\": \"PhotoCollage\",\n \"module\": \"../photo-collage/photo-collage\"\n },\n \"specType\": \"card\",\n \"containedExamples\": [],\n \"cardTitle\": \"PhotoCollage\",\n \"cardDescription\": \"Spec of PhotoCollage\",\n \"cardThumbnailURL\": null\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}" - }, - { - "filename": "photo-collage/PhotoCollage/a2c50a4a-67ee-4c84-9a53-fe17243bbc24.json", - "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"cardTitle\": \"Minimalist Photography Collection\",\n \"photos\": [\n {\n \"url\": \"https://images.unsplash.com/photo-1507608616759-54f48f0af0ee\",\n \"caption\": \"Solitude\",\n \"alt\": \"Person standing alone in a foggy landscape\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1477414348463-c0eb7f1359b6\",\n \"caption\": \"Cityscape\",\n \"alt\": \"Urban skyline with modern architecture\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1682687982107-14492010e05e\",\n \"caption\": \"Abstract Light\",\n \"alt\": \"Abstract patterns of light and shadow\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1475924156734-496f6cac6ec1\",\n \"caption\": \"Mountain Lake\",\n \"alt\": \"Serene mountain lake with forest reflections\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1526779259212-939e64788e3c\",\n \"caption\": \"Minimalist Architecture\",\n \"alt\": \"Clean lines of modern building design\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1598128558393-70ff21433be0\",\n \"caption\": \"Shadows\",\n \"alt\": \"Dramatic shadows on a white wall\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1496181133206-80ce9b88a853\",\n \"caption\": \"Workspace\",\n \"alt\": \"Minimalist desktop with laptop\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1496449903678-68ddcb189a24\",\n \"caption\": \"Urban Exploration\",\n \"alt\": \"Person walking in modern architecture\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe\",\n \"caption\": \"Negative Space\",\n \"alt\": \"Simple white room with single chair emphasizing empty space\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1494438639946-1ebd1d20bf85\",\n \"caption\": \"Industrial\",\n \"alt\": \"Industrial architecture in black and white\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1511447333015-45b65e60f6d5\",\n \"caption\": \"Mountain Silhouette\",\n \"alt\": \"Person silhouette against mountain backdrop\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1478104718532-efe04cc3ff7f\",\n \"caption\": \"Geometric\",\n \"alt\": \"Abstract geometric patterns in architecture\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1507608869274-d3177c8bb4c7\",\n \"caption\": \"Texture\",\n \"alt\": \"Textured surfaces in monochrome\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1515549832467-8783363e19b6\",\n \"caption\": \"Aerial\",\n \"alt\": \"Aerial view of landscape with minimal elements\"\n },\n {\n \"url\": \"https://images.unsplash.com/photo-1605460375648-278bcbd579a6\",\n \"caption\": \"Simplicity\",\n \"alt\": \"Simple lines creating an abstract composition\"\n }\n ],\n \"darkMode\": false,\n \"cardDescription\": null,\n \"cardThumbnailURL\": null\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"../photo-collage\",\n \"name\": \"PhotoCollage\"\n }\n }\n }\n}" - } - ] - }, - "relationships": { - "listing": { - "links": { - "self": "../CardListing/24d2eed4-9754-4636-9667-72f516ad6b00" - } - }, - "cardInfo.theme": { - "links": { - "self": null - } - } - } - } -} \ No newline at end of file diff --git a/packages/catalog-realm/submission-card/submission-card.gts b/packages/catalog-realm/submission-card/submission-card.gts index dee626c745c..902a4e511a1 100644 --- a/packages/catalog-realm/submission-card/submission-card.gts +++ b/packages/catalog-realm/submission-card/submission-card.gts @@ -370,6 +370,7 @@ export class SubmissionCard extends CardDef { .meta-value { flex: 1; + text-align: left; font: 500 var(--boxel-font-size-xs) var(--boxel-monospace-font-family); color: var(--boxel-600); white-space: nowrap; From 4e9ba011968fa81ef0db63d6e3a6e6f050661e14 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 10 Mar 2026 23:02:23 +0800 Subject: [PATCH 08/19] query pr card in submission card --- .../41c6a6dd-6fee-4bf2-acdc-4d7ab52797df.json | 55 ++ .../6e89407c-05ee-4bdc-a5dc-7cc1035a4d22.json | 139 ++++ .../b535d5fb-8eef-44a6-8114-4bce6929b95a.json | 2 +- .../components/card-with-hydration.gts | 2 +- .../components/cards-display-section.gts | 2 +- .../catalog-realm/submission-card/README.md | 300 -------- .../submission-card-portal.gts | 77 +- .../submission-card/submission-card.gts | 669 +++++++++++------- 8 files changed, 647 insertions(+), 599 deletions(-) create mode 100644 packages/catalog-realm/SubmissionCard/41c6a6dd-6fee-4bf2-acdc-4d7ab52797df.json create mode 100644 packages/catalog-realm/SubmissionCard/6e89407c-05ee-4bdc-a5dc-7cc1035a4d22.json delete mode 100644 packages/catalog-realm/submission-card/README.md diff --git a/packages/catalog-realm/SubmissionCard/41c6a6dd-6fee-4bf2-acdc-4d7ab52797df.json b/packages/catalog-realm/SubmissionCard/41c6a6dd-6fee-4bf2-acdc-4d7ab52797df.json new file mode 100644 index 00000000000..489c2cabb11 --- /dev/null +++ b/packages/catalog-realm/SubmissionCard/41c6a6dd-6fee-4bf2-acdc-4d7ab52797df.json @@ -0,0 +1,55 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "SubmissionCard", + "module": "../submission-card/submission-card" + } + }, + "type": "card", + "attributes": { + "roomId": "!DYcDLMhVkhJzEraJFu:localhost", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, + "branchName": "room-IURZY0RMTWhWa2hKekVyYUpGdTpsb2NhbGhvc3Q/featured-image", + "allFileContents": [ + { + "filename": "FieldListing/749411c1-a704-496d-a156-bdd0b9558702.json", + "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"FieldListing\",\n \"module\": \"../catalog-app/listing/listing\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"name\": \"Featured Image\",\n \"images\": [\n \"https://boxel-images.boxel.ai/app-assets/catalog/featured-image-listing/screenshot_01.png\",\n \"https://boxel-images.boxel.ai/app-assets/catalog/featured-image-listing/screenshot_02.png\"\n ],\n \"summary\": \"The Featured Image Field is a versatile component that allows you to easily add and customize images in your application. Whether you need to display a simple image or a more detailed presentation with captions and credits, this component has you covered.\\n\\nKey Features\\n- Image Display: Add an image by providing its URL. The component will handle the rest.\\n- Accessibility: Include alternative text for images to ensure accessibility for all users.\\n- Captions and Credits: Add descriptive captions and credits to your images for better context and attribution.\\n- Flexible Sizing: Choose how your image is displayed with options like actual, contain, or cover.\\n- Custom Dimensions: Specify exact dimensions for your image to fit your design needs.\\n\\nDisplay Options\\n- Simple View: Display your image with basic styling.\\n- Detailed View: Use the embedded option to include captions and credits, making your images more informative.\",\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": \"https://boxel-images.boxel.ai/app-assets/catalog/featured-image-listing/thumbnail.png\"\n }\n },\n \"relationships\": {\n \"tags\": {\n \"links\": {\n \"self\": null\n }\n },\n \"skills\": {\n \"links\": {\n \"self\": null\n }\n },\n \"license\": {\n \"links\": {\n \"self\": null\n }\n },\n \"specs.0\": {\n \"links\": {\n \"self\": \"../Spec/featured-image\"\n }\n },\n \"publisher\": {\n \"links\": {\n \"self\": \"../Publisher/9d3ca05b-684b-408f-8d8c-dd353d9956e0\"\n }\n },\n \"examples.0\": {\n \"links\": {\n \"self\": \"../fields-preview/FeaturedImagePreview/fe965f83-6de4-4a65-bcc1-f1b0b0a57f8e\"\n }\n },\n \"categories.0\": {\n \"links\": {\n \"self\": \"../Category/developer-tools-code\"\n }\n },\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" + }, + { + "filename": "fields/featured-image.gts", + "contents": "import { hash } from '@ember/helper';\nimport { htmlSafe } from '@ember/template';\nimport {\n Component,\n field,\n contains,\n StringField,\n FieldDef,\n} from 'https://cardstack.com/base/card-api';\nimport NumberField from 'https://cardstack.com/base/number';\nimport { ImageSizeField } from 'https://cardstack.com/base/base64-image';\nimport UrlField from 'https://cardstack.com/base/url';\nimport { FieldContainer } from '@cardstack/boxel-ui/components';\nimport { FailureBordered } from '@cardstack/boxel-ui/icons';\nimport PhotoIcon from '@cardstack/boxel-icons/photo';\n\nconst setBackgroundImage = (backgroundURL: string | null | undefined) => {\n if (!backgroundURL) {\n return;\n }\n return htmlSafe(`background-image: url(${backgroundURL});`);\n};\n\nfunction cssForFeaturedImage({\n imageUrl,\n size,\n height,\n width,\n}: {\n imageUrl: string | undefined;\n size: 'actual' | 'contain' | 'cover' | undefined;\n height?: number;\n width?: number;\n}) {\n if (!imageUrl) {\n return undefined;\n }\n\n let css: string[] = [];\n css.push(`background-image: url(\"${imageUrl}\");`);\n if (size && ['contain', 'cover'].includes(size)) {\n css.push(`background-size: ${size};`);\n }\n if (height) {\n css.push(`height: ${height}px;`);\n }\n if (width) {\n css.push(`width: ${width}px`);\n } else {\n css.push(`width: 100%`);\n }\n return htmlSafe(css.join(' '));\n}\n\nexport default class FeaturedImageField extends FieldDef {\n static displayName = 'Featured Image';\n static icon = PhotoIcon;\n @field imageUrl = contains(UrlField);\n @field credit = contains(StringField);\n @field caption = contains(StringField);\n @field altText = contains(StringField);\n @field size = contains(ImageSizeField);\n @field height = contains(NumberField);\n @field width = contains(NumberField);\n static edit = class Edit extends Component {\n get usesActualSize() {\n return this.args.model.size === 'actual' || this.args.model.size == null;\n }\n\n get backgroundMaskStyle() {\n let css: string[] = [];\n if (this.args.model.height) {\n css.push(`height: ${this.args.model.height}px;`);\n }\n if (this.args.model.width) {\n css.push(`width: ${this.args.model.width}px`);\n }\n return htmlSafe(css.join(' '));\n }\n\n get needsHeight() {\n return (\n (this.args.model.size === 'contain' ||\n this.args.model.size === 'cover') &&\n !this.args.model.height\n );\n }\n\n \n };\n\n static atom = class Atom extends Component {\n \n };\n static embedded = class Embedded extends Component {\n get usesActualSize() {\n return this.args.model.size === 'actual' || this.args.model.size == null;\n }\n \n };\n}\n" + }, + { + "filename": "fields-preview/featured-image.gts", + "contents": "import FeaturedImageField from '../fields/featured-image';\n\nimport {\n CardDef,\n field,\n contains,\n containsMany,\n type BaseDefConstructor,\n type Field,\n} from 'https://cardstack.com/base/card-api';\nimport { Component } from 'https://cardstack.com/base/card-api';\nimport { FieldContainer } from '@cardstack/boxel-ui/components';\nimport { getField } from '@cardstack/runtime-common';\n\nexport class FeaturedImagePreview extends CardDef {\n @field featuredImage = contains(FeaturedImageField);\n @field images = containsMany(FeaturedImageField);\n\n static displayName = 'Featured Image Preview';\n static isolated = class Isolated extends Component {\n \n getFieldIcon = (key: string) => {\n const field: Field | undefined = getField(\n this.args.model.constructor!,\n key,\n );\n let fieldInstance = field?.card;\n return fieldInstance?.icon;\n };\n };\n}\n" + }, + { + "filename": "Spec/featured-image.json", + "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"name\": \"default\",\n \"module\": \"../fields/featured-image\"\n },\n \"specType\": \"field\",\n \"containedExamples\": [],\n \"cardTitle\": \"FeaturedImageField\",\n \"cardDescription\": null,\n \"cardThumbnailURL\": null\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" + }, + { + "filename": "fields-preview/FeaturedImagePreview/fe965f83-6de4-4a65-bcc1-f1b0b0a57f8e.json", + "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"featuredImage\": {\n \"imageUrl\": \"https://boxel-images.boxel.ai/app-assets/portraits/photo-1479936343636-73cdc5aae0c3.jpeg\",\n \"credit\": \"Photo by Unsplash\",\n \"caption\": \"It's a new day, it's a new life.\",\n \"altText\": \"Woman smiling with the glow of sun in the background\",\n \"size\": \"actual\",\n \"height\": null,\n \"width\": 300\n },\n \"images\": [\n {\n \"imageUrl\": \"https://boxel-images.boxel.ai/app-assets/portraits/photo-1521119989659-a83eee488004.jpeg\",\n \"credit\": \"Photo by Unsplash - Ut velit modi sed aliquid molestiae in unde voluptas.\",\n \"caption\": \"Nam voluptatem nostrum qui aperiam rerum non similique porro sed iusto placeat cum sequi dolore. \",\n \"altText\": \"Portrait of man on a rooftop\",\n \"size\": \"actual\",\n \"height\": null,\n \"width\": null\n },\n {\n \"imageUrl\": \"https://boxel-images.boxel.ai/app-assets/portraits/photo-1496345875659-11f7dd282d1d.jpeg\",\n \"credit\": \"Photo by Unsplash - Quis quibusdam rem rerum maiores 33 galisum quidem.\",\n \"caption\": \"Aut asperiores impedit nam aperiam dolore ex libero voluptate.\",\n \"altText\": \"Man with dark sunglasses\",\n \"size\": \"actual\",\n \"height\": null,\n \"width\": null\n }\n ],\n \"cardTitle\": \"Featured Image Preview\",\n \"cardDescription\": null,\n \"cardThumbnailURL\": null\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"../../fields-preview/featured-image\",\n \"name\": \"FeaturedImagePreview\"\n }\n }\n }\n}" + } + ] + }, + "relationships": { + "listing": { + "links": { + "self": "../FieldListing/749411c1-a704-496d-a156-bdd0b9558702" + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/packages/catalog-realm/SubmissionCard/6e89407c-05ee-4bdc-a5dc-7cc1035a4d22.json b/packages/catalog-realm/SubmissionCard/6e89407c-05ee-4bdc-a5dc-7cc1035a4d22.json new file mode 100644 index 00000000000..a452ff3e6e3 --- /dev/null +++ b/packages/catalog-realm/SubmissionCard/6e89407c-05ee-4bdc-a5dc-7cc1035a4d22.json @@ -0,0 +1,139 @@ +{ + "data": { + "meta": { + "adoptsFrom": { + "name": "SubmissionCard", + "module": "../submission-card/submission-card" + } + }, + "type": "card", + "attributes": { + "roomId": "!tUPLakQEhhvpjDoTjQ:localhost", + "cardInfo": { + "name": null, + "notes": null, + "summary": null, + "cardThumbnailURL": null + }, + "branchName": "room-IXRVUExha1FFaGh2cGpEb1RqUTpsb2NhbGhvc3Q/avatar-creator", + "allFileContents": [ + { + "filename": "CardListing/f0c0ad91-0194-46b9-a971-9e60d637a51a.json", + "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"CardListing\",\n \"module\": \"../catalog-app/listing/listing\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"name\": \"Avatar Creator\",\n \"images\": [\n \"https://boxel-images.boxel.ai/app-assets/catalog/avatar-creator-listing/screenshot_01.png\",\n \"https://boxel-images.boxel.ai/app-assets/catalog/avatar-creator-listing/screenshot_02.png\"\n ],\n \"summary\": \"Create customizable avatar- avatars with various appearance options including hair styles, colors, facial features, and clothing.\",\n \"cardInfo\": {\n \"notes\": \"Includes AI-powered avatar- suggestion feature for creative inspiration.\",\n \"name\": \"Avatar Creator\",\n \"summary\": \"An interactive tool for designing and customizing avatar- avatars. Choose from different hair styles, hair colors, facial expressions, skin tones, eyebrows, and clothing options to create unique avatars. Perfect for games, storytelling, or creative projects.\",\n \"cardThumbnailURL\": \"https://boxel-images.boxel.ai/app-assets/catalog/avatar-creator-listing/thumbnail.png\"\n }\n },\n \"relationships\": {\n \"skills\": {\n \"links\": {\n \"self\": null\n }\n },\n \"tags.0\": {\n \"links\": {\n \"self\": \"../Tag/140feda8-625b-4a24-9ddb-6f4da891aef2\"\n }\n },\n \"license\": {\n \"links\": {\n \"self\": null\n }\n },\n \"specs.0\": {\n \"links\": {\n \"self\": \"../Spec/1b023c94-c534-43bf-8a09-3db1f6f70967\"\n }\n },\n \"specs.1\": {\n \"links\": {\n \"self\": \"../Spec/94c53473-bfca-493d-b1f6-f70967d4714d\"\n }\n },\n \"specs.2\": {\n \"links\": {\n \"self\": \"../Spec/bfca093d-b1f6-4709-a7d4-714d86bdb90e\"\n }\n },\n \"specs.3\": {\n \"links\": {\n \"self\": \"../Spec/ca093db1-f6f7-4967-9471-4d86bdb90e9a\"\n }\n },\n \"specs.4\": {\n \"links\": {\n \"self\": \"../Spec/73bfca09-3db1-46f7-8967-d4714d86bdb9\"\n }\n },\n \"specs.5\": {\n \"links\": {\n \"self\": \"../Spec/093db1f6-f709-47d4-b14d-86bdb90e9a95\"\n }\n },\n \"specs.6\": {\n \"links\": {\n \"self\": \"../Spec/b1f6f709-67d4-414d-86bd-b90e9a95eb3e\"\n }\n },\n \"specs.7\": {\n \"links\": {\n \"self\": \"../Spec/3db1f6f7-0967-4471-8d86-bdb90e9a95eb\"\n }\n },\n \"specs.8\": {\n \"links\": {\n \"self\": \"../Spec/f6f70967-d471-4d86-bdb9-0e9a95eb3e97\"\n }\n },\n \"specs.9\": {\n \"links\": {\n \"self\": \"../Spec/938e0b02-5259-4ae3-956f-a568ea6adf2f\"\n }\n },\n \"specs.10\": {\n \"links\": {\n \"self\": \"../Spec/67d4714d-86bd-490e-9a95-eb3e97e8960c\"\n }\n },\n \"specs.11\": {\n \"links\": {\n \"self\": \"../Spec/4d86bdb9-0e9a-45eb-be97-e8960cfb907e\"\n }\n },\n \"specs.12\": {\n \"links\": {\n \"self\": \"../Spec/b90e9a95-eb3e-47e8-960c-fb907eed22d7\"\n }\n },\n \"specs.13\": {\n \"links\": {\n \"self\": \"../Spec/0252595a-e315-4fa5-a8ea-6adf2fda9ca6\"\n }\n },\n \"publisher\": {\n \"links\": {\n \"self\": \"../Publisher/5f27bc69-6e15-4027-bff5-5c893a2642d9\"\n }\n },\n \"examples.0\": {\n \"links\": {\n \"self\": \"../avatar-creator/AvatarCreator/luna-starweaver\"\n }\n },\n \"examples.1\": {\n \"links\": {\n \"self\": \"../avatar-creator/AvatarCreator/magnus-stormbeard\"\n }\n },\n \"examples.2\": {\n \"links\": {\n \"self\": \"../avatar-creator/AvatarCreator/pichu\"\n }\n },\n \"examples.3\": {\n \"links\": {\n \"self\": \"../avatar-creator/AvatarCreator/zara-nightshade\"\n }\n },\n \"categories.0\": {\n \"links\": {\n \"self\": \"../Category/design-creative\"\n }\n },\n \"categories.1\": {\n \"links\": {\n \"self\": \"../Category/ui-components-design\"\n }\n },\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" + }, + { + "filename": "avatar-creator/avatar-creator.gts", + "contents": "import {\n CardDef,\n Component,\n field,\n contains,\n} from 'https://cardstack.com/base/card-api';\nimport StringField from 'https://cardstack.com/base/string';\n\nimport UserIcon from '@cardstack/boxel-icons/user';\nimport Avatar from '../fields/avatar';\nimport AvatarCreatorComponent from './components/avatar-creator';\nimport { AvataaarsModel } from '../utils/external/avataar';\nimport { restartableTask } from 'ember-concurrency';\nimport { CreateRealImage } from '../commands/create-real-image';\n\nclass IsolatedTemplate extends Component {\n // Convert avatar field to the format expected by the component\n get avatarModel() {\n return {\n topType: this.args.model.avatar?.topType,\n accessoriesType: this.args.model.avatar?.accessoriesType,\n hairColor: this.args.model.avatar?.hairColor,\n facialHairType: this.args.model.avatar?.facialHairType,\n clotheType: this.args.model.avatar?.clotheType,\n eyeType: this.args.model.avatar?.eyeType,\n eyebrowType: this.args.model.avatar?.eyebrowType,\n mouthType: this.args.model.avatar?.mouthType,\n skinColor: this.args.model.avatar?.skinColor,\n };\n }\n\n updateAvatar = (model: AvataaarsModel) => {\n this.args.model.avatar = new Avatar(model);\n };\n\n _createRealImageTask = () => {\n this.createRealImageTask.perform();\n };\n\n private createRealImageTask = restartableTask(async () => {\n let commandContext = this.args.context?.commandContext;\n if (!commandContext) {\n throw new Error('No command context found');\n }\n\n const createRealImageCommand = new CreateRealImage(commandContext);\n\n await createRealImageCommand.execute({\n avatar: this.args.model.avatar, // Pass the Avatar field (not the plain model)\n avatarUrl: this.args.model?.cardThumbnailURL, // The thumbnailURL field is used in prompts as a reference image\n notes: this.args.model.cardInfo?.notes, // The cardInfo notes field is used in prompts as context\n });\n\n return createRealImageCommand.result;\n });\n\n get isImageGenerating() {\n return this.createRealImageTask.isRunning;\n }\n\n get generatedImage() {\n const result = this.createRealImageTask.lastSuccessful?.value;\n return result?.success && result?.imageUrl ? result.imageUrl : '';\n }\n\n get errorImageGenerating() {\n const result = this.createRealImageTask.last?.value;\n return result?.success ? '' : result?.error || '';\n }\n\n \n}\n\nexport class AvatarCreator extends CardDef {\n static displayName = 'Avatar Creator';\n static icon = UserIcon;\n static prefersWideFormat = true;\n\n @field avatar = contains(Avatar, {\n description: 'Avatar appearance configuration',\n });\n\n @field cardTitle = contains(StringField, {\n computeVia: function (this: AvatarCreator) {\n return 'Avatar';\n },\n });\n\n @field cardThumbnailURL = contains(StringField, {\n computeVia: function (this: AvatarCreator) {\n return this.avatar?.cardThumbnailURL || '';\n },\n });\n\n static isolated = IsolatedTemplate;\n}\n" + }, + { + "filename": "fields/avatar.gts", + "contents": "import {\n FieldDef,\n field,\n contains,\n Component,\n} from 'https://cardstack.com/base/card-api';\nimport StringField from 'https://cardstack.com/base/string';\nimport UserIcon from '@cardstack/boxel-icons/user';\nimport { getAvataarsUrl, AvataaarsModel } from '../utils/external/avataar';\nimport AvatarComponent from './components/avatar';\n\nclass EditTemplate extends Component {\n // Convert avatar field to the format expected by the component\n get avatarModel() {\n return {\n topType: this.args.model.topType,\n accessoriesType: this.args.model.accessoriesType,\n hairColor: this.args.model.hairColor,\n facialHairType: this.args.model.facialHairType,\n clotheType: this.args.model.clotheType,\n eyeType: this.args.model.eyeType,\n eyebrowType: this.args.model.eyebrowType,\n mouthType: this.args.model.mouthType,\n skinColor: this.args.model.skinColor,\n };\n }\n\n updateAvatar = (model: AvataaarsModel) => {\n this.args.model.topType = model.topType;\n this.args.model.accessoriesType = model.accessoriesType;\n this.args.model.hairColor = model.hairColor;\n this.args.model.facialHairType = model.facialHairType;\n this.args.model.clotheType = model.clotheType;\n this.args.model.eyeType = model.eyeType;\n this.args.model.eyebrowType = model.eyebrowType;\n this.args.model.mouthType = model.mouthType;\n this.args.model.skinColor = model.skinColor;\n };\n\n \n}\n\nexport default class Avatar extends FieldDef {\n static displayName = 'Avatar';\n static icon = UserIcon;\n\n @field topType = contains(StringField, {\n description: 'Selected hair/top style',\n });\n\n @field accessoriesType = contains(StringField, {\n description: 'Selected accessories type',\n });\n\n @field hairColor = contains(StringField, {\n description: 'Selected hair color',\n });\n\n @field facialHairType = contains(StringField, {\n description: 'Selected facial hair type',\n });\n\n @field clotheType = contains(StringField, {\n description: 'Selected clothing type',\n });\n\n @field eyeType = contains(StringField, {\n description: 'Selected eye type',\n });\n\n @field eyebrowType = contains(StringField, {\n description: 'Selected eyebrow type',\n });\n\n @field mouthType = contains(StringField, {\n description: 'Selected mouth type',\n });\n\n @field skinColor = contains(StringField, {\n description: 'Selected skin color',\n });\n\n @field cardThumbnailURL = contains(StringField, {\n computeVia: function (this: Avatar) {\n return getAvataarsUrl({\n topType: this.topType,\n accessoriesType: this.accessoriesType,\n hairColor: this.hairColor,\n facialHairType: this.facialHairType,\n clotheType: this.clotheType,\n eyeType: this.eyeType,\n eyebrowType: this.eyebrowType,\n mouthType: this.mouthType,\n skinColor: this.skinColor,\n });\n },\n });\n\n static embedded = EditTemplate;\n static edit = EditTemplate;\n}\n" + }, + { + "filename": "utils/external/avataar.gts", + "contents": "export interface AvataaarsModel {\n topType?: string;\n accessoriesType?: string;\n hairColor?: string;\n facialHairType?: string;\n clotheType?: string;\n eyeType?: string;\n eyebrowType?: string;\n mouthType?: string;\n skinColor?: string;\n}\n\nexport interface AvataaarsOption {\n value: string;\n label: string;\n}\n\nexport interface AvataaarsOptions {\n topType: AvataaarsOption[];\n hairColor: AvataaarsOption[];\n eyeType: AvataaarsOption[];\n eyebrowType: AvataaarsOption[];\n mouthType: AvataaarsOption[];\n skinColor: AvataaarsOption[];\n clotheType: AvataaarsOption[];\n}\n\n// Avataaars configuration options with comprehensive styling\nexport const AVATAAARS_OPTIONS: AvataaarsOptions = {\n topType: [\n { value: 'NoHair', label: 'Bald' },\n { value: 'Eyepatch', label: 'Eyepatch' },\n { value: 'Hat', label: 'Hat' },\n { value: 'Hijab', label: 'Hijab' },\n { value: 'Turban', label: 'Turban' },\n { value: 'WinterHat1', label: 'Winter Hat 1' },\n { value: 'WinterHat2', label: 'Winter Hat 2' },\n { value: 'WinterHat3', label: 'Winter Hat 3' },\n { value: 'WinterHat4', label: 'Winter Hat 4' },\n { value: 'LongHairBigHair', label: 'Big Hair' },\n { value: 'LongHairBob', label: 'Bob Cut' },\n { value: 'LongHairBun', label: 'Hair Bun' },\n { value: 'LongHairCurly', label: 'Curly Hair' },\n { value: 'LongHairCurvy', label: 'Curvy Hair' },\n { value: 'LongHairDreads', label: 'Dreadlocks' },\n { value: 'LongHairFro', label: 'Afro' },\n { value: 'LongHairFroBand', label: 'Afro with Band' },\n { value: 'LongHairNotTooLong', label: 'Medium Hair' },\n { value: 'LongHairShavedSides', label: 'Shaved Sides' },\n { value: 'LongHairMiaWallace', label: 'Mia Wallace' },\n { value: 'LongHairStraight', label: 'Straight Hair' },\n { value: 'LongHairStraight2', label: 'Straight Hair 2' },\n { value: 'LongHairStraightStrand', label: 'Hair Strand' },\n { value: 'ShortHairDreads01', label: 'Short Dreads 1' },\n { value: 'ShortHairDreads02', label: 'Short Dreads 2' },\n { value: 'ShortHairFrizzle', label: 'Frizzled Hair' },\n { value: 'ShortHairShaggyMullet', label: 'Shaggy Mullet' },\n { value: 'ShortHairShortCurly', label: 'Short Curly' },\n { value: 'ShortHairShortFlat', label: 'Short Flat' },\n { value: 'ShortHairShortRound', label: 'Short Round' },\n { value: 'ShortHairShortWaved', label: 'Short Waved' },\n { value: 'ShortHairSides', label: 'Hair Sides' },\n { value: 'ShortHairTheCaesar', label: 'Caesar Cut' },\n { value: 'ShortHairTheCaesarSidePart', label: 'Caesar Side Part' },\n ],\n hairColor: [\n { value: 'Auburn', label: 'Auburn' },\n { value: 'Black', label: 'Black' },\n { value: 'Blonde', label: 'Blonde' },\n { value: 'BlondeGolden', label: 'Golden Blonde' },\n { value: 'Brown', label: 'Brown' },\n { value: 'BrownDark', label: 'Dark Brown' },\n { value: 'PastelPink', label: 'Pastel Pink' },\n { value: 'Blue', label: 'Blue' },\n { value: 'Platinum', label: 'Platinum' },\n { value: 'Red', label: 'Red' },\n { value: 'SilverGray', label: 'Silver Gray' },\n ],\n eyeType: [\n { value: 'Close', label: 'Closed' },\n { value: 'Cry', label: 'Crying' },\n { value: 'Default', label: 'Default' },\n { value: 'Dizzy', label: 'Dizzy' },\n { value: 'EyeRoll', label: 'Eye Roll' },\n { value: 'Happy', label: 'Happy' },\n { value: 'Hearts', label: 'Hearts' },\n { value: 'Side', label: 'Side Glance' },\n { value: 'Squint', label: 'Squint' },\n { value: 'Surprised', label: 'Surprised' },\n { value: 'Wink', label: 'Wink' },\n { value: 'WinkWacky', label: 'Wacky Wink' },\n ],\n eyebrowType: [\n { value: 'Angry', label: 'Angry' },\n { value: 'AngryNatural', label: 'Angry Natural' },\n { value: 'Default', label: 'Default' },\n { value: 'DefaultNatural', label: 'Default Natural' },\n { value: 'FlatNatural', label: 'Flat Natural' },\n { value: 'RaisedExcited', label: 'Raised Excited' },\n { value: 'RaisedExcitedNatural', label: 'Raised Excited Natural' },\n { value: 'SadConcerned', label: 'Sad Concerned' },\n { value: 'SadConcernedNatural', label: 'Sad Concerned Natural' },\n { value: 'UnibrowNatural', label: 'Unibrow Natural' },\n { value: 'UpDown', label: 'Up Down' },\n { value: 'UpDownNatural', label: 'Up Down Natural' },\n ],\n mouthType: [\n { value: 'Concerned', label: 'Concerned' },\n { value: 'Default', label: 'Default' },\n { value: 'Disbelief', label: 'Disbelief' },\n { value: 'Eating', label: 'Eating' },\n { value: 'Grimace', label: 'Grimace' },\n { value: 'Sad', label: 'Sad' },\n { value: 'ScreamOpen', label: 'Scream Open' },\n { value: 'Serious', label: 'Serious' },\n { value: 'Smile', label: 'Smile' },\n { value: 'Tongue', label: 'Tongue Out' },\n { value: 'Twinkle', label: 'Twinkle' },\n { value: 'Vomit', label: 'Vomit' },\n ],\n skinColor: [\n { value: 'Tanned', label: 'Tanned' },\n { value: 'Yellow', label: 'Yellow' },\n { value: 'Pale', label: 'Pale' },\n { value: 'Light', label: 'Light' },\n { value: 'Brown', label: 'Brown' },\n { value: 'DarkBrown', label: 'Dark Brown' },\n { value: 'Black', label: 'Black' },\n ],\n clotheType: [\n { value: 'BlazerShirt', label: 'Blazer & Shirt' },\n { value: 'BlazerSweater', label: 'Blazer & Sweater' },\n { value: 'CollarSweater', label: 'Collar Sweater' },\n { value: 'GraphicShirt', label: 'Graphic Shirt' },\n { value: 'Hoodie', label: 'Hoodie' },\n { value: 'Overall', label: 'Overall' },\n { value: 'ShirtCrewNeck', label: 'Crew Neck Shirt' },\n { value: 'ShirtScoopNeck', label: 'Scoop Neck Shirt' },\n { value: 'ShirtVNeck', label: 'V-Neck Shirt' },\n ],\n};\n\n// = \nexport const CATEGORY_MAP: Record = {\n hair: 'topType',\n eyes: 'eyeType',\n eyebrows: 'eyebrowType',\n mouth: 'mouthType',\n skinTone: 'skinColor',\n clothes: 'clotheType',\n hairColor: 'hairColor',\n};\n\n// Default avatar values\nexport const DEFAULT_AVATAR_VALUES: Required = {\n topType: 'ShortHairShortFlat',\n accessoriesType: 'Blank',\n hairColor: 'Platinum',\n facialHairType: 'Blank',\n clotheType: 'BlazerShirt',\n eyeType: 'Default',\n eyebrowType: 'Default',\n mouthType: 'Default',\n skinColor: 'Light',\n};\n\n// Predefined avatar sets for quick selection\nexport const PRESET_AVATAR_SETS: {\n name: string;\n model: Required;\n}[] = [\n {\n name: 'Professional',\n model: {\n topType: 'ShortHairShortFlat',\n accessoriesType: 'Blank',\n hairColor: 'BrownDark',\n facialHairType: 'Blank',\n clotheType: 'BlazerShirt',\n eyeType: 'Default',\n eyebrowType: 'Default',\n mouthType: 'Smile',\n skinColor: 'Light',\n },\n },\n {\n name: 'Creative Artist',\n model: {\n topType: 'LongHairCurly',\n accessoriesType: 'Blank',\n hairColor: 'PastelPink',\n facialHairType: 'Blank',\n clotheType: 'GraphicShirt',\n eyeType: 'Happy',\n eyebrowType: 'RaisedExcited',\n mouthType: 'Smile',\n skinColor: 'Tanned',\n },\n },\n {\n name: 'Cool Dude',\n model: {\n topType: 'ShortHairDreads01',\n accessoriesType: 'Blank',\n hairColor: 'Black',\n facialHairType: 'Blank',\n clotheType: 'Hoodie',\n eyeType: 'Squint',\n eyebrowType: 'Default',\n mouthType: 'Serious',\n skinColor: 'DarkBrown',\n },\n },\n {\n name: 'Friendly Teacher',\n model: {\n topType: 'LongHairBob',\n accessoriesType: 'Blank',\n hairColor: 'Blonde',\n facialHairType: 'Blank',\n clotheType: 'CollarSweater',\n eyeType: 'Happy',\n eyebrowType: 'Default',\n mouthType: 'Smile',\n skinColor: 'Light',\n },\n },\n {\n name: 'Tech Enthusiast',\n model: {\n topType: 'ShortHairShortRound',\n accessoriesType: 'Blank',\n hairColor: 'Brown',\n facialHairType: 'Blank',\n clotheType: 'ShirtCrewNeck',\n eyeType: 'Default',\n eyebrowType: 'Default',\n mouthType: 'Default',\n skinColor: 'Pale',\n },\n },\n {\n name: 'Adventurous Spirit',\n model: {\n topType: 'LongHairStraight',\n accessoriesType: 'Blank',\n hairColor: 'Auburn',\n facialHairType: 'Blank',\n clotheType: 'Overall',\n eyeType: 'Surprised',\n eyebrowType: 'RaisedExcited',\n mouthType: 'Twinkle',\n skinColor: 'Brown',\n },\n },\n {\n name: 'Wise Mentor',\n model: {\n topType: 'ShortHairTheCaesar',\n accessoriesType: 'Blank',\n hairColor: 'SilverGray',\n facialHairType: 'Blank',\n clotheType: 'BlazerSweater',\n eyeType: 'Default',\n eyebrowType: 'Default',\n mouthType: 'Serious',\n skinColor: 'Light',\n },\n },\n {\n name: 'Cheerful Friend',\n model: {\n topType: 'LongHairFro',\n accessoriesType: 'Blank',\n hairColor: 'Black',\n facialHairType: 'Blank',\n clotheType: 'ShirtVNeck',\n eyeType: 'Happy',\n eyebrowType: 'Default',\n mouthType: 'Smile',\n skinColor: 'Black',\n },\n },\n];\n\n/**\n * Generates the Avataaars URL for a given avatar model\n */\nexport function getAvataarsUrl(model: AvataaarsModel): string {\n const {\n topType,\n accessoriesType,\n hairColor,\n facialHairType,\n clotheType,\n eyeType,\n eyebrowType,\n mouthType,\n skinColor,\n } = model;\n\n const params = [\n `topType=${encodeURIComponent(topType || DEFAULT_AVATAR_VALUES.topType)}`,\n `accessoriesType=${encodeURIComponent(accessoriesType || DEFAULT_AVATAR_VALUES.accessoriesType)}`,\n `hairColor=${encodeURIComponent(hairColor || DEFAULT_AVATAR_VALUES.hairColor)}`,\n `facialHairType=${encodeURIComponent(facialHairType || DEFAULT_AVATAR_VALUES.facialHairType)}`,\n `clotheType=${encodeURIComponent(clotheType || DEFAULT_AVATAR_VALUES.clotheType)}`,\n `eyeType=${encodeURIComponent(eyeType || DEFAULT_AVATAR_VALUES.eyeType)}`,\n `eyebrowType=${encodeURIComponent(eyebrowType || DEFAULT_AVATAR_VALUES.eyebrowType)}`,\n `mouthType=${encodeURIComponent(mouthType || DEFAULT_AVATAR_VALUES.mouthType)}`,\n `skinColor=${encodeURIComponent(skinColor || DEFAULT_AVATAR_VALUES.skinColor)}`,\n ];\n\n return `https://avataaars.io/?${params.join('&')}`;\n}\n\n/**\n * Generates a random avatar by selecting random options from each category\n */\nexport function generateRandomAvatarModel(): AvataaarsModel {\n const randomHair =\n AVATAAARS_OPTIONS.topType[\n Math.floor(Math.random() * AVATAAARS_OPTIONS.topType.length)\n ];\n const randomHairColor =\n AVATAAARS_OPTIONS.hairColor[\n Math.floor(Math.random() * AVATAAARS_OPTIONS.hairColor.length)\n ];\n const randomEyes =\n AVATAAARS_OPTIONS.eyeType[\n Math.floor(Math.random() * AVATAAARS_OPTIONS.eyeType.length)\n ];\n const randomEyebrows =\n AVATAAARS_OPTIONS.eyebrowType[\n Math.floor(Math.random() * AVATAAARS_OPTIONS.eyebrowType.length)\n ];\n const randomMouth =\n AVATAAARS_OPTIONS.mouthType[\n Math.floor(Math.random() * AVATAAARS_OPTIONS.mouthType.length)\n ];\n const randomSkinTone =\n AVATAAARS_OPTIONS.skinColor[\n Math.floor(Math.random() * AVATAAARS_OPTIONS.skinColor.length)\n ];\n const randomClothes =\n AVATAAARS_OPTIONS.clotheType[\n Math.floor(Math.random() * AVATAAARS_OPTIONS.clotheType.length)\n ];\n\n return {\n topType: randomHair.value,\n accessoriesType: DEFAULT_AVATAR_VALUES.accessoriesType,\n hairColor: randomHairColor.value,\n facialHairType: DEFAULT_AVATAR_VALUES.facialHairType,\n clotheType: randomClothes.value,\n eyeType: randomEyes.value,\n eyebrowType: randomEyebrows.value,\n mouthType: randomMouth.value,\n skinColor: randomSkinTone.value,\n };\n}\n\n/**\n * Gets the options for a specific category\n */\nexport function getCategoryOptions(category: string) {\n const paramName = CATEGORY_MAP[category];\n return AVATAAARS_OPTIONS[paramName] || [];\n}\n\n/**\n * Creates a preview URL for an avatar option by applying it to a base model\n */\nexport function getOptionPreviewUrl(\n baseModel: AvataaarsModel,\n category: string,\n optionValue: string,\n): string {\n const previewModel = { ...baseModel };\n\n switch (category) {\n case 'hair':\n previewModel.topType = optionValue;\n break;\n case 'hairColor':\n previewModel.hairColor = optionValue;\n break;\n case 'eyes':\n previewModel.eyeType = optionValue;\n break;\n case 'eyebrows':\n previewModel.eyebrowType = optionValue;\n break;\n case 'mouth':\n previewModel.mouthType = optionValue;\n break;\n case 'skinTone':\n previewModel.skinColor = optionValue;\n break;\n case 'clothes':\n previewModel.clotheType = optionValue;\n break;\n }\n\n return getAvataarsUrl(previewModel);\n}\n\n/**\n * Gets the current selection value for a category from an avatar model\n */\nexport function getCurrentSelectionForCategory(\n model: AvataaarsModel,\n category: string,\n): string | undefined {\n switch (category) {\n case 'hair':\n return model.topType;\n case 'hairColor':\n return model.hairColor;\n case 'eyes':\n return model.eyeType;\n case 'eyebrows':\n return model.eyebrowType;\n case 'mouth':\n return model.mouthType;\n case 'skinTone':\n return model.skinColor;\n case 'clothes':\n return model.clotheType;\n default:\n return undefined;\n }\n}\n\n/**\n * Updates an avatar model with a new option value for a specific category\n */\nexport function updateAvatarModelForCategory(\n model: AvataaarsModel,\n category: string,\n optionValue: string,\n): AvataaarsModel {\n const updatedModel = { ...model };\n\n switch (category) {\n case 'hair':\n updatedModel.topType = optionValue;\n break;\n case 'hairColor':\n updatedModel.hairColor = optionValue;\n break;\n case 'eyes':\n updatedModel.eyeType = optionValue;\n break;\n case 'eyebrows':\n updatedModel.eyebrowType = optionValue;\n break;\n case 'mouth':\n updatedModel.mouthType = optionValue;\n break;\n case 'skinTone':\n updatedModel.skinColor = optionValue;\n break;\n case 'clothes':\n updatedModel.clotheType = optionValue;\n break;\n }\n\n return updatedModel;\n}\n\n/**\n * Creates a click sound using Web Audio API\n */\nexport function playClickSound(): void {\n try {\n const audioContext = new (window.AudioContext ||\n (window as any).webkitAudioContext)();\n\n // Create oscillator for the click sound\n const oscillator = audioContext.createOscillator();\n const gainNode = audioContext.createGain();\n\n // Connect nodes\n oscillator.connect(gainNode);\n gainNode.connect(audioContext.destination);\n\n // Configure the sound - a short, crisp click\n oscillator.frequency.setValueAtTime(800, audioContext.currentTime); // High frequency for crisp sound\n oscillator.frequency.exponentialRampToValueAtTime(\n 400,\n audioContext.currentTime + 0.1,\n );\n\n // Set volume envelope for a quick click\n gainNode.gain.setValueAtTime(0, audioContext.currentTime);\n gainNode.gain.linearRampToValueAtTime(0.3, audioContext.currentTime + 0.01); // Quick attack\n gainNode.gain.exponentialRampToValueAtTime(\n 0.01,\n audioContext.currentTime + 0.1,\n ); // Quick decay\n\n // Play the sound\n oscillator.start(audioContext.currentTime);\n oscillator.stop(audioContext.currentTime + 0.1);\n } catch (error) {\n console.error('Audio not supported or failed:', error);\n }\n}\n\n/**\n * Interface for createRealImage function parameters\n */\nexport interface CreateRealParams {\n avatar: AvataaarsModel;\n avatarUrl?: string;\n cardInfo?: {\n notes?: string;\n };\n sendRequestCommand: {\n execute: (input: {\n url: string;\n method: string;\n requestBody: string;\n headers?: Record;\n }) => Promise<{\n response: Response;\n }>;\n };\n}\n\n/**\n * Interface for createRealImage result\n */\nexport interface CreateRealResult {\n success: boolean;\n imageUrl?: string;\n error?: string;\n}\n\n/**\n * Builds AI interpretation cues based on avatar configuration\n */\nexport function buildAICues(avatarModel: AvataaarsModel): string {\n const cuesList = [];\n\n // Check mouth type\n if (avatarModel.mouthType === 'Grimace') {\n cuesList.push('- Grimace should show teeth with a stretched mouth');\n }\n if (avatarModel.mouthType === 'Vomit') {\n cuesList.push(\n '- Vomit should be pretending to vomit, as if seeing something revolting',\n );\n }\n\n // Check hair/top type\n if (avatarModel.topType === 'WinterHat1') {\n cuesList.push('- Winter Hat 1 has sides that covers ears and cheeks');\n }\n if (avatarModel.topType === 'WinterHat2') {\n cuesList.push('- Winter Hat 2 is knit');\n }\n if (avatarModel.topType === 'WinterHat3') {\n cuesList.push('- Winter Hat 3 is a beanie');\n }\n if (avatarModel.topType === 'WinterHat4') {\n cuesList.push('- Winter Hat 4 is a Christmas hat');\n }\n if (avatarModel.topType === 'NoHair') {\n cuesList.push('- nohair is bald');\n }\n if (avatarModel.topType === 'ShortHairSides') {\n cuesList.push(\n '- ShortHairSides person should be 90% bald with male pattern baldness',\n );\n }\n\n // Check eye type\n if (avatarModel.eyeType === 'Hearts') {\n cuesList.push(\n \"- hearts eye: don't draw hearts, just make their eyes big and doe-y with affection and attraction\",\n );\n }\n if (avatarModel.eyeType === 'Dizzy') {\n cuesList.push('- dizzy eye should be an overall emotion');\n }\n\n return cuesList.length > 0\n ? '\\n\\nAI Interpretation Cues:\\n' + cuesList.join('\\n')\n : '';\n}\n" + }, + { + "filename": "fields/components/avatar.gts", + "contents": "import { fn } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport { eq, gt } from '@cardstack/boxel-ui/helpers';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { TrackedMap } from 'tracked-built-ins';\nimport { task } from 'ember-concurrency';\nimport {\n FilterList,\n BoxelButton,\n BoxelInput,\n} from '@cardstack/boxel-ui/components';\n\nimport {\n AvataaarsModel,\n DEFAULT_AVATAR_VALUES,\n CATEGORY_MAP,\n PRESET_AVATAR_SETS,\n getAvataarsUrl,\n generateRandomAvatarModel,\n getCategoryOptions,\n getOptionPreviewUrl,\n getCurrentSelectionForCategory,\n updateAvatarModelForCategory,\n playClickSound,\n} from '../../utils/external/avataar';\n\nimport { SuggestAvatar } from '../../commands/suggest-avatar';\n\ninterface AvatarCreatorArgs {\n model: AvataaarsModel;\n context?: any;\n onUpdate?: (model: AvataaarsModel) => void;\n}\n\nexport default class AvatarComponent extends Component {\n @tracked selectedCategory = 'hair';\n @tracked activeFilter: any = null;\n @tracked copySuccess = false;\n @tracked currentMode: 'presets' | 'customized' = 'presets';\n\n // Store filter objects to maintain reference equality\n private presetFilter = {\n displayName: 'Presets',\n mode: 'presets' as const,\n };\n\n private customizedFilter = {\n displayName: 'Customized',\n filters: [] as any[], // Will be populated in getter\n isExpanded: false,\n };\n\n constructor(owner: any, args: AvatarCreatorArgs) {\n super(owner, args);\n // Set initial active filter to Presets\n this.activeFilter = this.presetFilter;\n }\n\n // Generate categories from CATEGORY_MAP to keep things DRY\n get categories() {\n const categoryLabels: Record = {\n hair: 'Hair Style',\n hairColor: 'Hair Color',\n eyes: 'Eyes',\n eyebrows: 'Eyebrows',\n mouth: 'Mouth',\n skinTone: 'Skin Tone',\n clothes: 'Clothes',\n };\n\n return Object.keys(CATEGORY_MAP).map((key) => ({\n key,\n label: categoryLabels[key] || key,\n }));\n }\n\n // Store category filter objects to maintain reference equality\n private _categoryFilters = new Map();\n\n private getCategoryFilter(category: { key: string; label: string }) {\n if (!this._categoryFilters.has(category.key)) {\n this._categoryFilters.set(category.key, {\n displayName: category.label,\n categoryKey: category.key,\n mode: 'customized' as const,\n });\n }\n return this._categoryFilters.get(category.key);\n }\n\n // Transform into FilterList format with Presets and Customized\n get avatarFilters() {\n // Custom category filters with stable references\n const categoryFilters = this.categories.map((category) =>\n this.getCategoryFilter(category),\n );\n\n // Update the customized filter with current state\n this.customizedFilter.filters = categoryFilters;\n this.customizedFilter.isExpanded = this.currentMode === 'customized';\n\n return [this.presetFilter, this.customizedFilter];\n }\n\n // Internal mutable avatar state using TrackedMap\n @tracked currentModel = new TrackedMap([\n ['topType', this.args.model?.topType || DEFAULT_AVATAR_VALUES.topType],\n [\n 'accessoriesType',\n this.args.model?.accessoriesType || DEFAULT_AVATAR_VALUES.accessoriesType,\n ],\n [\n 'hairColor',\n this.args.model?.hairColor || DEFAULT_AVATAR_VALUES.hairColor,\n ],\n [\n 'facialHairType',\n this.args.model?.facialHairType || DEFAULT_AVATAR_VALUES.facialHairType,\n ],\n [\n 'clotheType',\n this.args.model?.clotheType || DEFAULT_AVATAR_VALUES.clotheType,\n ],\n ['eyeType', this.args.model?.eyeType || DEFAULT_AVATAR_VALUES.eyeType],\n [\n 'eyebrowType',\n this.args.model?.eyebrowType || DEFAULT_AVATAR_VALUES.eyebrowType,\n ],\n [\n 'mouthType',\n this.args.model?.mouthType || DEFAULT_AVATAR_VALUES.mouthType,\n ],\n [\n 'skinColor',\n this.args.model?.skinColor || DEFAULT_AVATAR_VALUES.skinColor,\n ],\n ]);\n\n // Get Avataaars URL for the image\n get avataaarsUrl() {\n // Convert TrackedMap to object for getAvataarsUrl function\n const modelObj = Object.fromEntries(this.currentModel.entries());\n return getAvataarsUrl(modelObj as AvataaarsModel);\n }\n\n get currentCategoryOptions() {\n return getCategoryOptions(this.selectedCategory);\n }\n\n // Get preset avatar sets with their URLs for display\n get presetAvatarOptions() {\n return PRESET_AVATAR_SETS.map((avatarSet) => ({\n name: avatarSet.name,\n url: getAvataarsUrl(avatarSet.model),\n model: avatarSet.model,\n }));\n }\n\n onFilterChanged = (filter: any) => {\n // Handle presets selection\n if (filter.mode === 'presets') {\n this.currentMode = 'presets';\n this.activeFilter = this.presetFilter;\n }\n // Handle customized category filter selection\n else if (filter.mode === 'customized' && filter.categoryKey) {\n this.currentMode = 'customized';\n this.selectedCategory = filter.categoryKey;\n this.activeFilter = filter; // This should now be from our cached objects\n }\n // Handle customized parent selection (expand/collapse)\n else if (filter.displayName === 'Customized') {\n // Don't change activeFilter for the parent, just toggle expansion\n this.currentMode = 'customized';\n }\n };\n\n generateRandomAvatar = () => {\n // Play click sound\n playClickSound();\n\n // Generate random avatar using the utility function\n const randomAvatar = generateRandomAvatarModel();\n\n // Apply random selections to internal state - reassign entire TrackedMap\n this.currentModel = new TrackedMap(Object.entries(randomAvatar));\n\n // Notify parent component of the change\n this.args.onUpdate?.(randomAvatar);\n };\n\n selectAvataaarsOption = (option: { value: string; label: string }) => {\n // Get current model as object\n const currentModelObj = Object.fromEntries(\n this.currentModel.entries(),\n ) as AvataaarsModel;\n\n // Update using the utility function\n const updatedModel = updateAvatarModelForCategory(\n currentModelObj,\n this.selectedCategory,\n option.value,\n );\n\n // Update internal state\n this.currentModel = new TrackedMap(Object.entries(updatedModel));\n\n // Notify parent component of the change\n this.args.onUpdate?.(updatedModel);\n };\n\n copyAvataaarsUrl = () => {\n try {\n // Play click sound\n playClickSound();\n navigator.clipboard.writeText(this.avataaarsUrl);\n this.copySuccess = true;\n // Reset success state after 2 seconds\n setTimeout(() => {\n this.copySuccess = false;\n }, 2000);\n } catch (error) {\n console.error('Failed to copy URL:', error);\n }\n };\n\n // Generate preview URL for each option\n getOptionPreviewUrl = (option: { value: string; label: string }) => {\n const currentModelObj = Object.fromEntries(\n this.currentModel.entries(),\n ) as AvataaarsModel;\n return getOptionPreviewUrl(\n currentModelObj,\n this.selectedCategory,\n option.value,\n );\n };\n\n // Getters for template use - these properly track TrackedMap changes\n get topType() {\n return this.currentModel.get('topType');\n }\n\n get hairColor() {\n return this.currentModel.get('hairColor');\n }\n\n get mouthType() {\n return this.currentModel.get('mouthType');\n }\n\n get skinColor() {\n return this.currentModel.get('skinColor');\n }\n\n get eyeType() {\n return this.currentModel.get('eyeType');\n }\n\n get eyebrowType() {\n return this.currentModel.get('eyebrowType');\n }\n\n get clotheType() {\n return this.currentModel.get('clotheType');\n }\n\n get currentSelection() {\n try {\n const currentModelObj = Object.fromEntries(\n this.currentModel.entries(),\n ) as AvataaarsModel;\n return getCurrentSelectionForCategory(\n currentModelObj,\n this.selectedCategory,\n );\n } catch (error) {\n console.warn('Error getting current selection:', error);\n return null;\n }\n }\n\n _suggestAvatar = task(async () => {\n try {\n let commandContext = this.args.context?.commandContext;\n if (!commandContext) {\n throw new Error(\n 'Command context does not exist. Please switch to Interact Mode',\n );\n }\n\n let suggestCommand = new SuggestAvatar(commandContext);\n await suggestCommand.execute({\n name: 'Avatar',\n });\n } catch (error) {\n console.error('Error suggesting avatar:', error);\n alert('There was an error getting avatar suggestions. Please try again.');\n }\n });\n\n suggestAvatar = () => {\n this._suggestAvatar.perform();\n };\n\n isOptionSelected = (option: { value: string; label: string }) => {\n return this.currentSelection === option.value;\n };\n\n selectPresetAvatar = (avatarOption: any) => {\n playClickSound();\n // Apply the selected preset avatar\n this.currentModel = new TrackedMap(Object.entries(avatarOption.model));\n this.args.onUpdate?.(avatarOption.model);\n };\n\n // Check if a preset avatar is currently selected\n isPresetSelected = (avatarOption: any) => {\n const currentModelObj = Object.fromEntries(this.currentModel.entries());\n\n // Compare all avatar properties to see if this preset matches current state\n return (\n currentModelObj.topType === avatarOption.model.topType &&\n currentModelObj.accessoriesType === avatarOption.model.accessoriesType &&\n currentModelObj.hairColor === avatarOption.model.hairColor &&\n currentModelObj.facialHairType === avatarOption.model.facialHairType &&\n currentModelObj.clotheType === avatarOption.model.clotheType &&\n currentModelObj.eyeType === avatarOption.model.eyeType &&\n currentModelObj.eyebrowType === avatarOption.model.eyebrowType &&\n currentModelObj.mouthType === avatarOption.model.mouthType &&\n currentModelObj.skinColor === avatarOption.model.skinColor\n );\n };\n\n \n}\n" + }, + { + "filename": "commands/suggest-avatar.gts", + "contents": "import { CardDef, field, contains } from 'https://cardstack.com/base/card-api';\nimport StringField from 'https://cardstack.com/base/string';\nimport UseAiAssistantCommand from '@cardstack/boxel-host/commands/ai-assistant';\nimport SetActiveLLMCommand from '@cardstack/boxel-host/commands/set-active-llm';\nimport { Command, DEFAULT_CODING_LLM } from '@cardstack/runtime-common';\n\nclass SuggestAvatarInput extends CardDef {\n @field name = contains(StringField, {\n description: 'Name to use for the avatar suggestion room',\n });\n}\n\nexport class SuggestAvatar extends Command<\n typeof SuggestAvatarInput,\n undefined\n> {\n static actionVerb = 'Generate';\n static displayName = 'Suggest Avatar';\n\n async getInputType() {\n return SuggestAvatarInput;\n }\n\n protected async run(input: SuggestAvatarInput): Promise {\n let { name } = input;\n\n let skillCardId = new URL('../Skill/avatar-suggestion', import.meta.url)\n .href;\n\n try {\n let useAiAssistantCommand = new UseAiAssistantCommand(\n this.commandContext,\n );\n let result = await useAiAssistantCommand.execute({\n roomName: `Avatar Suggestions: ${name || 'Unnamed Avatar'}`,\n openRoom: true,\n prompt: `Please suggest two example avatar prompts: one describing a visual style and one referencing a celebrity's look.`,\n skillCardIds: [skillCardId],\n llmModel: DEFAULT_CODING_LLM,\n });\n\n if (result.roomId) {\n let setActiveLLMCommand = new SetActiveLLMCommand(this.commandContext);\n await setActiveLLMCommand.execute({\n roomId: result.roomId,\n mode: 'ask',\n });\n }\n } catch (error: any) {\n throw new Error(`❌ Failed to suggest avatar: ${error.message}`);\n }\n }\n}\n" + }, + { + "filename": "avatar-creator/components/avatar-creator.gts", + "contents": "import { fn } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport { eq, gt } from '@cardstack/boxel-ui/helpers';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { TrackedMap } from 'tracked-built-ins';\nimport { task } from 'ember-concurrency';\nimport { BoxelButton, BoxelInput } from '@cardstack/boxel-ui/components';\n\nimport {\n AvataaarsModel,\n DEFAULT_AVATAR_VALUES,\n getAvataarsUrl,\n generateRandomAvatarModel,\n getCategoryOptions,\n getOptionPreviewUrl,\n getCurrentSelectionForCategory,\n updateAvatarModelForCategory,\n playClickSound,\n} from '../../utils/external/avataar';\n\nimport { SuggestAvatar } from '../../commands/suggest-avatar';\n\ninterface AvatarCreatorArgs {\n model: AvataaarsModel;\n context?: any;\n onUpdate?: (model: AvataaarsModel) => void;\n isImageGenerating?: boolean;\n generatedImage?: string;\n errorImageGenerating?: string;\n onCreateRealImage?: () => void;\n}\n\nexport default class AvatarCreatorComponent extends Component {\n @tracked selectedCategory = 'hair';\n @tracked copySuccess = false;\n\n // Internal mutable avatar state using TrackedMap\n @tracked currentModel = new TrackedMap([\n ['topType', this.args.model?.topType || DEFAULT_AVATAR_VALUES.topType],\n [\n 'accessoriesType',\n this.args.model?.accessoriesType || DEFAULT_AVATAR_VALUES.accessoriesType,\n ],\n [\n 'hairColor',\n this.args.model?.hairColor || DEFAULT_AVATAR_VALUES.hairColor,\n ],\n [\n 'facialHairType',\n this.args.model?.facialHairType || DEFAULT_AVATAR_VALUES.facialHairType,\n ],\n [\n 'clotheType',\n this.args.model?.clotheType || DEFAULT_AVATAR_VALUES.clotheType,\n ],\n ['eyeType', this.args.model?.eyeType || DEFAULT_AVATAR_VALUES.eyeType],\n [\n 'eyebrowType',\n this.args.model?.eyebrowType || DEFAULT_AVATAR_VALUES.eyebrowType,\n ],\n [\n 'mouthType',\n this.args.model?.mouthType || DEFAULT_AVATAR_VALUES.mouthType,\n ],\n [\n 'skinColor',\n this.args.model?.skinColor || DEFAULT_AVATAR_VALUES.skinColor,\n ],\n ]);\n\n // Get Avataaars URL for the image\n get avataaarsUrl() {\n // Convert TrackedMap to object for getAvataarsUrl function\n const modelObj = Object.fromEntries(this.currentModel.entries());\n return getAvataarsUrl(modelObj as AvataaarsModel);\n }\n\n get currentCategoryOptions() {\n return getCategoryOptions(this.selectedCategory);\n }\n\n selectCategory = (category: string) => {\n this.selectedCategory = category;\n };\n\n generateRandomAvatar = () => {\n // Play click sound\n playClickSound();\n\n // Generate random avatar using the utility function\n const randomAvatar = generateRandomAvatarModel();\n\n // Apply random selections to internal state - reassign entire TrackedMap\n this.currentModel = new TrackedMap(Object.entries(randomAvatar));\n\n // Notify parent component of the change\n this.args.onUpdate?.(randomAvatar);\n };\n\n selectAvataaarsOption = (option: { value: string; label: string }) => {\n // Get current model as object\n const currentModelObj = Object.fromEntries(\n this.currentModel.entries(),\n ) as AvataaarsModel;\n\n // Update using the utility function\n const updatedModel = updateAvatarModelForCategory(\n currentModelObj,\n this.selectedCategory,\n option.value,\n );\n\n // Update internal state\n this.currentModel = new TrackedMap(Object.entries(updatedModel));\n\n // Notify parent component of the change\n this.args.onUpdate?.(updatedModel);\n };\n\n copyAvataaarsUrl = () => {\n try {\n // Play click sound\n playClickSound();\n navigator.clipboard.writeText(this.avataaarsUrl);\n this.copySuccess = true;\n // Reset success state after 2 seconds\n setTimeout(() => {\n this.copySuccess = false;\n }, 2000);\n } catch (error) {\n console.error('Failed to copy URL:', error);\n }\n };\n\n // Generate preview URL for each option\n getOptionPreviewUrl = (option: { value: string; label: string }) => {\n const currentModelObj = Object.fromEntries(\n this.currentModel.entries(),\n ) as AvataaarsModel;\n return getOptionPreviewUrl(\n currentModelObj,\n this.selectedCategory,\n option.value,\n );\n };\n\n // Getters for template use - these properly track TrackedMap changes\n get topType() {\n return this.currentModel.get('topType');\n }\n\n get hairColor() {\n return this.currentModel.get('hairColor');\n }\n\n get mouthType() {\n return this.currentModel.get('mouthType');\n }\n\n get skinColor() {\n return this.currentModel.get('skinColor');\n }\n\n get eyeType() {\n return this.currentModel.get('eyeType');\n }\n\n get eyebrowType() {\n return this.currentModel.get('eyebrowType');\n }\n\n get clotheType() {\n return this.currentModel.get('clotheType');\n }\n\n get currentSelection() {\n try {\n const currentModelObj = Object.fromEntries(\n this.currentModel.entries(),\n ) as AvataaarsModel;\n return getCurrentSelectionForCategory(\n currentModelObj,\n this.selectedCategory,\n );\n } catch (error) {\n console.warn('Error getting current selection:', error);\n return null;\n }\n }\n\n _suggestAvatar = task(async () => {\n try {\n let commandContext = this.args.context?.commandContext;\n if (!commandContext) {\n throw new Error(\n 'Command context does not exist. Please switch to Interact Mode',\n );\n }\n\n let suggestCommand = new SuggestAvatar(commandContext);\n await suggestCommand.execute({\n name: 'Avatar',\n });\n } catch (error) {\n console.error('Error suggesting avatar:', error);\n alert('There was an error getting avatar suggestions. Please try again.');\n }\n });\n\n suggestAvatar = () => {\n this._suggestAvatar.perform();\n };\n\n isOptionSelected = (option: { value: string; label: string }) => {\n return this.currentSelection === option.value;\n };\n\n \n}\n" + }, + { + "filename": "commands/create-real-image.gts", + "contents": "import { CardDef, field, contains } from 'https://cardstack.com/base/card-api';\nimport StringField from 'https://cardstack.com/base/string';\n\nimport { Command } from '@cardstack/runtime-common';\nimport SendRequestViaProxyCommand from '@cardstack/boxel-host/commands/send-request-via-proxy';\n\nimport { buildAICues } from '../utils/external/avataar';\nimport Avatar from '../fields/avatar';\n\nclass CreateRealImageInput extends CardDef {\n @field avatar = contains(Avatar, {\n description: 'Avatar model configuration',\n });\n\n @field avatarUrl = contains(StringField, {\n description: 'URL of the avatar image to use as reference',\n });\n\n @field notes = contains(StringField, {\n description: 'Optional notes string used for schema/context',\n });\n}\n\nexport class CreateRealImage extends Command<\n typeof CreateRealImageInput,\n undefined\n> {\n static actionVerb = 'Generate';\n static displayName = 'Create Real Image';\n\n result?: { success: boolean; imageUrl?: string; error?: string };\n\n async getInputType() {\n return CreateRealImageInput;\n }\n\n protected async run(input: CreateRealImageInput): Promise {\n const { avatar, avatarUrl, notes } = input;\n\n if (!avatarUrl) {\n throw new Error('No avatar URL available');\n }\n\n const aiCues = buildAICues(avatar);\n const configSchema = notes || '';\n\n const prompt = `Imagine this avatar as a beloved main character in a modern, live-action, TV-14 series that appeals to both kids and adults. Render a realistic, high-quality headshot portrait (1:1 aspect ratio, facing forward) as if photographed with a Sony A74 and professionally retouched for a poster.\n\n - Guess and visually express: age, sex, location, time of year, religion, race/ethnicity, mood (be authentic—even unusual emotions are welcome), and current situation, based on the avatar's features. Ensure broad and inclusive representation.\n - Exaggerate emotions as a talented actor would.\n - Highlight subtle details as a skilled makeup artist would: e.g., eyes open or closed, tongue, hair color, etc.\n - Any special effects (SFX) should be photorealistic and seamlessly composited.\n - Use a natural, unobtrusive background. No borders or text.\n\n ${aiCues}\n\n IMPORTANT: Only use valid avatar configuration values. Reference this schema for accurate interpretations:\n\n ${configSchema}\n\n ${avatarUrl}`;\n\n // Send the request via host proxy\n const sendRequestCommand = new SendRequestViaProxyCommand(\n this.commandContext,\n );\n const result = await sendRequestCommand.execute({\n url: 'https://openrouter.ai/api/v1/chat/completions',\n method: 'POST',\n requestBody: JSON.stringify({\n model: 'google/gemini-2.5-flash-image-preview',\n messages: [\n {\n role: 'user',\n content: prompt,\n },\n ],\n }),\n });\n\n if (!result.response.ok) {\n this.result = {\n success: false,\n error: `Failed to make request: ${result.response.statusText}`,\n };\n return;\n }\n\n try {\n const responseData = await result.response.json();\n if (responseData.error) {\n const errorMsg = responseData.error.message || responseData.error;\n this.result = { success: false, error: `API Error: ${errorMsg}` };\n return;\n }\n\n const messageContent = responseData.choices?.[0]?.message;\n const images = messageContent?.images;\n if (!Array.isArray(images) || images.length === 0) {\n this.result = { success: false, error: 'No images found in response' };\n return;\n }\n\n const firstValidImage = images.find(\n (img: any) =>\n img?.image_url?.url && img.image_url.url.startsWith('data:image/'),\n );\n\n if (firstValidImage?.image_url?.url) {\n this.result = {\n success: true,\n imageUrl: firstValidImage.image_url.url,\n };\n return;\n }\n\n this.result = {\n success: false,\n error: 'No valid images generated in response',\n };\n } catch (e: any) {\n this.result = {\n success: false,\n error: e?.message || 'Unknown error occurred',\n };\n }\n }\n}\n" + }, + { + "filename": "Spec/1b023c94-c534-43bf-8a09-3db1f6f70967.json", + "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../avatar-creator/avatar-creator\",\n \"name\": \"AvatarCreator\"\n },\n \"specType\": \"card\",\n \"containedExamples\": [],\n \"cardTitle\": \"AvatarCreator\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" + }, + { + "filename": "Spec/94c53473-bfca-493d-b1f6-f70967d4714d.json", + "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../fields/avatar\",\n \"name\": \"default\"\n },\n \"specType\": \"field\",\n \"containedExamples\": [],\n \"cardTitle\": \"default\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" + }, + { + "filename": "Spec/bfca093d-b1f6-4709-a7d4-714d86bdb90e.json", + "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../utils/external/avataar\",\n \"name\": \"getAvataarsUrl\"\n },\n \"specType\": null,\n \"containedExamples\": [],\n \"cardTitle\": \"getAvataarsUrl\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" + }, + { + "filename": "Spec/ca093db1-f6f7-4967-9471-4d86bdb90e9a.json", + "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../utils/external/avataar\",\n \"name\": \"generateRandomAvatarModel\"\n },\n \"specType\": null,\n \"containedExamples\": [],\n \"cardTitle\": \"generateRandomAvatarModel\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" + }, + { + "filename": "Spec/73bfca09-3db1-46f7-8967-d4714d86bdb9.json", + "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../utils/external/avataar\",\n \"name\": \"getCategoryOptions\"\n },\n \"specType\": null,\n \"containedExamples\": [],\n \"cardTitle\": \"getCategoryOptions\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" + }, + { + "filename": "Spec/093db1f6-f709-47d4-b14d-86bdb90e9a95.json", + "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../utils/external/avataar\",\n \"name\": \"getOptionPreviewUrl\"\n },\n \"specType\": null,\n \"containedExamples\": [],\n \"cardTitle\": \"getOptionPreviewUrl\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" + }, + { + "filename": "Spec/b1f6f709-67d4-414d-86bd-b90e9a95eb3e.json", + "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../utils/external/avataar\",\n \"name\": \"getCurrentSelectionForCategory\"\n },\n \"specType\": null,\n \"containedExamples\": [],\n \"cardTitle\": \"getCurrentSelectionForCategory\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" + }, + { + "filename": "Spec/3db1f6f7-0967-4471-8d86-bdb90e9a95eb.json", + "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../utils/external/avataar\",\n \"name\": \"updateAvatarModelForCategory\"\n },\n \"specType\": null,\n \"containedExamples\": [],\n \"cardTitle\": \"updateAvatarModelForCategory\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" + }, + { + "filename": "Spec/f6f70967-d471-4d86-bdb9-0e9a95eb3e97.json", + "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../utils/external/avataar\",\n \"name\": \"playClickSound\"\n },\n \"specType\": null,\n \"containedExamples\": [],\n \"cardTitle\": \"playClickSound\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" + }, + { + "filename": "Spec/938e0b02-5259-4ae3-956f-a568ea6adf2f.json", + "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../utils/external/avataar\",\n \"name\": \"buildAICues\"\n },\n \"specType\": null,\n \"containedExamples\": [],\n \"cardTitle\": \"buildAICues\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" + }, + { + "filename": "Spec/67d4714d-86bd-490e-9a95-eb3e97e8960c.json", + "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../fields/components/avatar\",\n \"name\": \"default\"\n },\n \"specType\": \"component\",\n \"containedExamples\": [],\n \"cardTitle\": \"default\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" + }, + { + "filename": "Spec/4d86bdb9-0e9a-45eb-be97-e8960cfb907e.json", + "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../commands/suggest-avatar\",\n \"name\": \"SuggestAvatar\"\n },\n \"specType\": \"command\",\n \"containedExamples\": [],\n \"cardTitle\": \"SuggestAvatar\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" + }, + { + "filename": "Spec/b90e9a95-eb3e-47e8-960c-fb907eed22d7.json", + "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../avatar-creator/components/avatar-creator\",\n \"name\": \"default\"\n },\n \"specType\": \"component\",\n \"containedExamples\": [],\n \"cardTitle\": \"default\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" + }, + { + "filename": "Spec/0252595a-e315-4fa5-a8ea-6adf2fda9ca6.json", + "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../commands/create-real-image\",\n \"name\": \"CreateRealImage\"\n },\n \"specType\": \"command\",\n \"containedExamples\": [],\n \"cardTitle\": \"CreateRealImage\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" + }, + { + "filename": "avatar-creator/AvatarCreator/luna-starweaver.json", + "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"AvatarCreator\",\n \"module\": \"../avatar-creator\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"avatar\": {\n \"eyeType\": \"Surprised\",\n \"topType\": \"Hat\",\n \"hairColor\": \"SilverGray\",\n \"mouthType\": \"ScreamOpen\",\n \"skinColor\": \"Brown\",\n \"clotheType\": \"ShirtVNeck\",\n \"eyebrowType\": \"FlatNatural\",\n \"facialHairType\": \"Blank\",\n \"accessoriesType\": \"Blank\"\n },\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null\n }\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" + }, + { + "filename": "avatar-creator/AvatarCreator/magnus-stormbeard.json", + "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"AvatarCreator\",\n \"module\": \"../avatar-creator\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"avatar\": {\n \"eyeType\": \"Happy\",\n \"topType\": \"WinterHat1\",\n \"hairColor\": \"Auburn\",\n \"mouthType\": \"Serious\",\n \"skinColor\": \"Brown\",\n \"clotheType\": \"Hoodie\",\n \"eyebrowType\": \"RaisedExcitedNatural\",\n \"facialHairType\": \"Blank\",\n \"accessoriesType\": \"Blank\"\n },\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null\n }\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" + }, + { + "filename": "avatar-creator/AvatarCreator/pichu.json", + "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"AvatarCreator\",\n \"module\": \"../avatar-creator\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"avatar\": {\n \"eyeType\": \"Default\",\n \"topType\": \"WinterHat2\",\n \"hairColor\": \"BrownDark\",\n \"mouthType\": \"Smile\",\n \"skinColor\": \"Light\",\n \"clotheType\": \"BlazerShirt\",\n \"eyebrowType\": \"Default\",\n \"facialHairType\": \"Blank\",\n \"accessoriesType\": \"Blank\"\n },\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null\n }\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" + }, + { + "filename": "avatar-creator/AvatarCreator/zara-nightshade.json", + "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"AvatarCreator\",\n \"module\": \"../avatar-creator\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"avatar\": {\n \"eyeType\": \"Default\",\n \"topType\": \"LongHairShavedSides\",\n \"hairColor\": \"BrownDark\",\n \"mouthType\": \"Smile\",\n \"skinColor\": \"Pale\",\n \"clotheType\": \"Overall\",\n \"eyebrowType\": \"Default\",\n \"facialHairType\": \"Blank\",\n \"accessoriesType\": \"Blank\"\n },\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null\n }\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" + } + ] + }, + "relationships": { + "listing": { + "links": { + "self": "../CardListing/f0c0ad91-0194-46b9-a971-9e60d637a51a" + } + }, + "cardInfo.theme": { + "links": { + "self": null + } + } + } + } +} \ No newline at end of file diff --git a/packages/catalog-realm/SubmissionCardPortal/b535d5fb-8eef-44a6-8114-4bce6929b95a.json b/packages/catalog-realm/SubmissionCardPortal/b535d5fb-8eef-44a6-8114-4bce6929b95a.json index fba3757b124..b7dae599f01 100644 --- a/packages/catalog-realm/SubmissionCardPortal/b535d5fb-8eef-44a6-8114-4bce6929b95a.json +++ b/packages/catalog-realm/SubmissionCardPortal/b535d5fb-8eef-44a6-8114-4bce6929b95a.json @@ -20,7 +20,7 @@ "relationships": { "cardInfo.theme": { "links": { - "self": null + "self": "../Theme/cardstack" } } } diff --git a/packages/catalog-realm/catalog-app/components/card-with-hydration.gts b/packages/catalog-realm/catalog-app/components/card-with-hydration.gts index 61416a54361..c9cb6c23501 100644 --- a/packages/catalog-realm/catalog-app/components/card-with-hydration.gts +++ b/packages/catalog-realm/catalog-app/components/card-with-hydration.gts @@ -77,7 +77,7 @@ export class CardWithHydration extends GlimmerComponent { .card:hover { cursor: pointer; - border: 1px solid var(--boxel-purple); + border: 1px solid var(--primary, var(--boxel-purple)); } .cards :deep(.field-component-card.fitted-format) { diff --git a/packages/catalog-realm/submission-card/README.md b/packages/catalog-realm/submission-card/README.md deleted file mode 100644 index b4214e94f86..00000000000 --- a/packages/catalog-realm/submission-card/README.md +++ /dev/null @@ -1,300 +0,0 @@ -# Submission Card Portal - -## Overview - -A `SubmissionCard` is an index card that records a single listing submission. It lives in the **same workspace realm where the user was working on that listing** — wherever that listing PR originated from. - -The `SubmissionCardPortal` is an app-level card that aggregates all submission cards across the user's realms into a single view. It can live anywhere; it is currently placed in the catalog realm as a temporary home. - -The portal hides all GitHub technical details. Users care about *"Did my submission pass?"*, not *"What is the PR number?"*. - ---- - -## System Architecture - -``` -[ GitHub Repo ] - ↓ (webhook) -[ PR Card ] ──────────── Data Source Realm (read-only, auto-generated, no user access) - ↓ (referenced by) -[ Submission Card ] ───── Same realm the user submitted the listing PR from - ↓ (aggregated by) -[ Submission Portal ] ─── Anywhere (currently: catalog realm, temporary) -``` - -### Data Source Realm — PR Card - -| Property | Value | -|----------|-------| -| Location | Data Source Realm (backend) | -| Driven by | GitHub webhook events | -| User access | Read-only — cannot be edited, deleted, or mutated | -| Purpose | Reflects the raw GitHub PR state | - -### User Realm — Submission Card - -| Property | Value | -|----------|-------| -| Location | The realm the user submitted the listing PR from | -| User access | Create, delete, archive | -| Purpose | Index card recording the user's submission and its status | -| Note | Deleting a Submission Card does **not** delete the PR — it only removes the reference | - -### Submission Portal - -| Property | Value | -|----------|-------| -| Location | Anywhere — currently catalog realm (temporary) | -| Queries | Only the user's own submissions, across their realms | -| Realm scope | User-selectable — can toggle which of their realms to include | - ---- - -## Code Location - -Both card definitions live in the catalog realm: - -``` -submission-card/ -├── README.md ← this file -├── submission-card-portal.gts ← portal app card (currently: catalog realm) -└── submission-card.gts ← submission card (instances: user's own realm) -``` - ---- - -## Submission Card — Permissions - -| Action | Allowed | Notes | -|--------|---------|-------| -| Create | Yes | Auto-created when user triggers PR submission | -| Delete | Yes | Removes reference only — PR still exists on GitHub | -| Archive | Yes | | -| Edit user fields | Partial | Only user-facing fields; `branchName` and `githubURL` are computed | -| Edit PR Card | No | PR Card lives in read-only Data Source Realm | - ---- - -## Submission Card — What to Show - -Show only user-facing status. **Hide all GitHub technical details.** - -| Field | Show | Notes | -|-------|------|-------| -| Listing name | Yes | `listing.name` — primary title | -| Submitted date | Yes | Card generation date (`_createdAt` from card metadata) | -| Branch name | Optional | Secondary, small label | -| GitHub PR link | Optional | Icon link only | -| Reviewer comments | No | Too technical | -| Commit history | No | Too technical | -| File contents | No | Too technical | - -> Users say *"I submitted something"* — not *"I created a PR"*. The card reflects submission state, not GitHub internals. - ---- - -## SubmissionCard Schema - -### FileContentField - -| Field | Type | Description | -|-------|------|-------------| -| `filename` | `StringField` | Name of the submitted file | -| `contents` | `StringField` | Raw file contents | - -### SubmissionCard - -| Field | Type | Description | -|-------|------|-------------| -| `cardTitle` | `StringField` (computed) | Derived from `listing.name` or `listing.cardTitle`, falls back to `'Untitled Submission'` | -| `roomId` | `StringField` | Matrix room ID used for bot status updates | -| `branchName` | `StringField` | GitHub branch name for the submitted PR | -| `githubURL` | `StringField` (computed) | Built from `branchName` using `GITHUB_BRANCH_URL_PREFIX` + URL-encoded segments | -| `listing` | `linksTo(Listing)` | The catalog listing being submitted | -| `allFileContents` | `containsMany(FileContentField)` | All files included in the PR submission | - -### Computed Field Notes - -- **`cardTitle`** — read-only, auto-derived from the linked listing. -- **`githubURL`** — read-only, auto-derived from `branchName`. Each `/`-separated segment is individually `encodeURIComponent`-encoded. - -### Edge Cases - -| Scenario | Behaviour | -|----------|-----------| -| No submissions yet | Portal shows empty state UI | -| `listing` deleted | `cardTitle` falls back to `'Untitled Submission'` | -| `branchName` empty | `githubURL` returns `undefined` — hide link in fitted card | -| `allFileContents` empty | Hide file count badge | -| Submission deleted | PR still exists on GitHub — only the reference is removed | - ---- - -## Portal — Card Structure - -### SubmissionCardPortal Fields - -| Field | Type | Description | -|-------|------|-------------| -| `title` | `StringField` | Portal display name | -| `description` | `StringField` | Short description shown in the header | - -> The portal queries `SubmissionCard` instances dynamically across the user's realms — it does **not** use `linksTo` or `containsMany`. - ---- - -## Portal — Realm Scope - -Because `SubmissionCard` instances can live in any of the user's workspaces, the portal must query across multiple realms. The intended design is: - -- Default: query **all of the user's realms** -- UI: a realm toggle/filter so the user can narrow to a specific workspace - -**Implementation:** The portal uses `commandData` + `GetAllRealmMetasCommand` to load all writable user realms. It queries all of them by default. When the user selects specific realms via the toggle pills, only those realms are queried. While realm data is loading, the portal falls back to its own realm URL (`this.args.model[realmURL]`). - ---- - -## Portal — Isolated Template Layout - -``` -┌──────────────────────────────────────────────────────────┐ -│ Header │ -│ Title: "Submissions" │ -│ │ -│ [ Search by card title... ] [Grid][Strip] │ -│ [■ Realm A] [□ Realm B] [□ Realm C] ← shown when 2+ │ -├──────────────────────────────────────────────────────────┤ -│ Grid (default) │ -│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ -│ │Submission│ │Submission│ │Submission│ │ -│ │ Card │ │ Card │ │ Card │ │ -│ │ (fitted) │ │ (fitted) │ │ (fitted) │ │ -│ └──────────┘ └──────────┘ └──────────┘ │ -├──────────────────────────────────────────────────────────┤ -│ Strip (alternate view) │ -│ ┌────────────────────────────────────────────────────┐ │ -│ │ Submission Card (fitted — wide strip layout) │ │ -│ └────────────────────────────────────────────────────┘ │ -│ ┌────────────────────────────────────────────────────┐ │ -│ │ Submission Card (fitted — wide strip layout) │ │ -│ └────────────────────────────────────────────────────┘ │ -└──────────────────────────────────────────────────────────┘ -``` - ---- - -## Portal — Search Functionality - -Filters by `cardTitle` (the computed title on `SubmissionCard`) using a text input in the portal header. Input is debounced 300ms. - -```ts -get query() { - const baseFilter = { - type: { - module: new URL('./submission-card', import.meta.url).href, - name: 'SubmissionCard', - }, - }; - - if (!this.searchText) { - return { filter: baseFilter }; - } - - return { - filter: { - every: [ - baseFilter, - { - any: [{ contains: { cardTitle: this.searchText } }], - }, - ], - }, - }; -} -``` - ---- - -## Portal — View Switcher (Grid vs Strip) - -| View | Layout | Fitted card size | -|------|--------|-----------------| -| `grid` | `repeat(auto-fill, 300px)` columns | `300×380px` tile | -| `strip` | Full-width single column (`1fr`) | `120px` tall strip | - ---- - -## Portal — Grid Rendering Pattern - -Uses the `CardList` base component which handles live querying, loading state, and grid/strip layout via `@viewOption`. - -```hbs - -``` - -CSS overrides size the list items so fitted container queries resolve correctly: - -```css -/* grid view */ -.portal-content :deep(.grid-view) { - --item-width: 300px; - --item-height: 380px; -} - -/* strip view */ -.portal-content :deep(.strip-view) { - --item-height: 120px; - grid-template-columns: 1fr; -} -``` - ---- - -## SubmissionCard — Fitted Template Sizes - -Fitted cards define multiple layouts using `@container fitted-card` queries. - -### Size 1 — Tiny badge (`height <= 58px`) -``` -┌──────────────────────┐ -│ [icon] Title │ -└──────────────────────┘ -``` -Show: icon + `cardTitle` only. - -### Size 2 — Square tile (default grid, `300×380px`) -``` -┌──────────────┐ -│ [icon] │ -│ Title │ -│ listing name │ -│ [status pill]│ -└──────────────┘ -``` -Show: icon, `cardTitle`, `listing.name`, status pill. - -### Size 3 — Wide strip (`aspect-ratio > 2.0`, `height < 115px`) -``` -┌────────────────────────────────────────────────────────┐ -│ [icon] Title listing name [status pill] [→] │ -└────────────────────────────────────────────────────────┘ -``` -Show: icon, `cardTitle`, `listing.name`, status pill, GitHub link icon. - ---- - -## Static Config - -```ts -static displayName = 'Submission Card Portal'; -static prefersWideFormat = true; -static headerColor = '#e5f0ff'; -static icon = BotIcon; // from @cardstack/boxel-icons/bot -``` diff --git a/packages/catalog-realm/submission-card/submission-card-portal.gts b/packages/catalog-realm/submission-card/submission-card-portal.gts index 7705948b479..abaadb6bfb8 100644 --- a/packages/catalog-realm/submission-card/submission-card-portal.gts +++ b/packages/catalog-realm/submission-card/submission-card-portal.gts @@ -4,6 +4,8 @@ import { on } from '@ember/modifier'; import { fn } from '@ember/helper'; import { debounce } from 'lodash'; +import GlimmerComponent from '@glimmer/component'; + import { CardDef, Component, @@ -20,17 +22,20 @@ import type { } from 'https://cardstack.com/base/command'; import GetAllRealmMetasCommand from '@cardstack/boxel-host/commands/get-all-realm-metas'; -import GlimmerComponent from '@glimmer/component'; - import { eq, gt } from '@cardstack/boxel-ui/helpers'; -import { BoxelInput, ViewSelector, Pill } from '@cardstack/boxel-ui/components'; -import { type ViewItem } from '@cardstack/boxel-ui/components'; -import CardList from 'https://cardstack.com/base/components/card-list'; -import BotIcon from '@cardstack/boxel-icons/bot'; +import { + BoxelInput, + ViewSelector, + Pill, + type ViewItem, +} from '@cardstack/boxel-ui/components'; import { Grid3x3 as GridIcon, Rows4 as StripIcon, } from '@cardstack/boxel-ui/icons'; +import BotIcon from '@cardstack/boxel-icons/bot'; + +import { CardsGrid } from '../catalog-app/components/grid'; type ViewOption = 'strip' | 'grid'; @@ -81,15 +86,20 @@ class RealmTabs extends GlimmerComponent { --pill-border-radius: 50px; --pill-font: var(--boxel-font-sm); --pill-padding: var(--boxel-sp-5xs) var(--boxel-sp); + background-color: var(--card, #ffffff); + color: var(--foreground, #1f2328); + border: 1px solid var(--border, #d0d7de); } .realm-pill.active { - background-color: var(--boxel-dark); - color: var(--boxel-light); + background-color: var(--foreground, #1f2328); + color: var(--card, #ffffff); + border-color: var(--foreground, #1f2328); } .realm-pill:not(.active):hover { - background-color: var(--boxel-300); + background-color: var(--muted, #f6f8fa); + border-color: var(--muted-foreground, #656d76); } @@ -225,7 +235,7 @@ class Isolated extends Component { @items={{SUBMISSION_VIEW_OPTIONS}} />
- {{#if (gt this.availableRealms.length 1)}} + {{#if (gt this.availableRealms.length 0)}} {
-
@@ -251,7 +259,7 @@ class Isolated extends Component { display: flex; flex-direction: column; height: 100%; - background: var(--boxel-light); + background: var(--muted, #f6f8fa); } .portal-header { @@ -259,14 +267,18 @@ class Isolated extends Component { flex-direction: column; gap: var(--boxel-sp-sm); padding: var(--boxel-sp-lg) var(--boxel-sp-xl); - background: var(--boxel-200); - border-bottom: 1px solid var(--boxel-200); + background: color-mix( + in srgb, + var(--primary, #e5f0ff) 12%, + var(--card, #ffffff) + ); + border-bottom: 1px solid var(--border, #d0d7de); } .portal-title { margin: 0; font: 700 var(--boxel-font-xl); - color: var(--boxel-dark); + color: var(--foreground, #1f2328); } .portal-controls { @@ -277,30 +289,36 @@ class Isolated extends Component { .search-input { flex: 1; + --boxel-input-search-background-color: var(--foreground, #1f2328); + --boxel-input-search-color: var(--card, #ffffff); + --boxel-input-search-icon-color: var(--primary-foreground, #ffffff); + --border: var(--foreground, #1f2328); + --muted-foreground: color-mix( + in srgb, + var(--card, #ffffff) 72%, + transparent + ); } .portal-content { flex: 1; overflow-y: auto; padding: var(--boxel-sp); + background: var(--card, #ffffff); } .portal-view-selector { margin-left: auto; flex-shrink: 0; --boxel-view-option-group-column-gap: var(--boxel-sp-2xs); + color: var(--muted-foreground, #656d76); } - /* Each list item must be a sized container so fitted template - container queries (@container fitted-card) resolve correctly */ - .portal-content :deep(.grid-view) { - --item-width: 300px; - --item-height: 380px; - } - - .portal-content :deep(.strip-view) { - --item-height: 120px; - grid-template-columns: 1fr; + .portal-content :deep(.cards) { + --grid-view-min-width: 300px; + --grid-view-height: 420px; + --strip-view-min-width: 100%; + --strip-view-height: 120px; } @@ -311,8 +329,9 @@ export class SubmissionCardPortal extends CardDef { static prefersWideFormat = true; static headerColor = '#e5f0ff'; static icon = BotIcon; - static isolated = Isolated; @field title = contains(StringField); @field description = contains(StringField); + + static isolated = Isolated; } diff --git a/packages/catalog-realm/submission-card/submission-card.gts b/packages/catalog-realm/submission-card/submission-card.gts index 902a4e511a1..b6874ec9b0f 100644 --- a/packages/catalog-realm/submission-card/submission-card.gts +++ b/packages/catalog-realm/submission-card/submission-card.gts @@ -1,3 +1,5 @@ +import { on } from '@ember/modifier'; + import { CardDef, FieldDef, @@ -6,27 +8,22 @@ import { containsMany, field, linksTo, + realmURL, } from 'https://cardstack.com/base/card-api'; +import StringField from 'https://cardstack.com/base/string'; +import type { Query } from '@cardstack/runtime-common'; + import { or } from '@cardstack/boxel-ui/helpers'; -import { on } from '@ember/modifier'; import { BoxelButton } from '@cardstack/boxel-ui/components'; -import StringField from 'https://cardstack.com/base/string'; + import BotIcon from '@cardstack/boxel-icons/bot'; -import BrandGithubIcon from '@cardstack/boxel-icons/brand-github'; import FileCodeIcon from '@cardstack/boxel-icons/file-code'; import GitBranchIcon from '@cardstack/boxel-icons/git-branch'; +import GitPullRequestIcon from '@cardstack/boxel-icons/git-pull-request'; import MessageIcon from '@cardstack/boxel-icons/message'; -import { Listing } from '../catalog-app/listing/listing'; -const GITHUB_BRANCH_URL_PREFIX = - 'https://github.com/cardstack/boxel-catalog/tree/'; - -function encodeBranchName(branchName: string): string { - return branchName - .split('/') - .map((segment) => encodeURIComponent(segment)) - .join('/'); -} +import { Listing } from '../catalog-app/listing/listing'; +import { buildRealmHrefs } from '../pr-card/utils'; export class FileContentField extends FieldDef { @field filename = contains(StringField); @@ -48,7 +45,7 @@ export class FileContentField extends FieldDef { align-items: center; gap: 4px; padding: 2px 6px; - background: var(--boxel-200); + background: var(--muted, #f6f8fa); border-radius: var(--boxel-border-radius-sm); max-width: 100%; overflow: hidden; @@ -56,14 +53,14 @@ export class FileContentField extends FieldDef { .file-atom-icon { flex-shrink: 0; - color: var(--boxel-450); + color: var(--muted-foreground, #656d76); } .file-atom-name { font-size: var(--boxel-font-size-2xs); font-weight: 500; font-family: var(--boxel-monospace-font-family); - color: var(--boxel-600); + color: var(--foreground, #1f2328); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -106,10 +103,10 @@ export class FileContentField extends FieldDef { .file-embedded { display: flex; flex-direction: column; - border: 1px solid var(--boxel-border-color); + border: 1px solid var(--border, #d0d7de); border-radius: var(--boxel-border-radius); overflow: hidden; - background: var(--boxel-light); + background: var(--card, #ffffff); } .file-header { @@ -117,14 +114,14 @@ export class FileContentField extends FieldDef { align-items: center; gap: var(--boxel-sp-4xs); padding: var(--boxel-sp-4xs) var(--boxel-sp-xs); - background: var(--boxel-200); - border-bottom: 1px solid var(--boxel-border-color); + background: var(--muted, #f6f8fa); + border-bottom: 1px solid var(--border, #d0d7de); min-width: 0; } .file-header-icon { flex-shrink: 0; - color: var(--boxel-450); + color: var(--muted-foreground, #656d76); } .file-name { @@ -132,7 +129,7 @@ export class FileContentField extends FieldDef { font-size: var(--boxel-font-size-xs); font-weight: 500; font-family: var(--boxel-monospace-font-family); - color: var(--boxel-dark); + color: var(--foreground, #1f2328); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -146,8 +143,8 @@ export class FileContentField extends FieldDef { font-family: var(--boxel-font-family); letter-spacing: var(--boxel-lsp-sm); padding: 1px 5px; - background: var(--boxel-300); - color: var(--boxel-600); + background: var(--muted, #f6f8fa); + color: var(--muted-foreground, #656d76); border-radius: var(--boxel-border-radius-sm); white-space: nowrap; } @@ -159,7 +156,7 @@ export class FileContentField extends FieldDef { font-weight: 400; font-family: var(--boxel-monospace-font-family); line-height: 1.6; - color: var(--boxel-600); + color: var(--muted-foreground, #656d76); white-space: pre; overflow: hidden; display: -webkit-box; @@ -184,22 +181,10 @@ export class SubmissionCard extends CardDef { }); @field roomId = contains(StringField); @field branchName = contains(StringField); - @field githubURL = contains(StringField, { - computeVia: function (this: SubmissionCard) { - if (!this.branchName) { - return undefined; - } - return `${GITHUB_BRANCH_URL_PREFIX}${encodeBranchName(this.branchName)}`; - }, - }); @field listing = linksTo(() => Listing); @field allFileContents = containsMany(FileContentField); static fitted = class Fitted extends Component { - get fileCount() { - return this.args.model.allFileContents?.length ?? 0; - } - get listingName() { return ( this.args.model.listing?.name ?? this.args.model.listing?.cardTitle @@ -210,10 +195,6 @@ export class SubmissionCard extends CardDef { return this.args.model.cardTitle; } - get githubURL() { - return this.args.model.githubURL; - } - get branchName() { return this.args.model.branchName; } @@ -226,9 +207,58 @@ export class SubmissionCard extends CardDef { return this.args.model.listing?.images?.[0]; } + openListing = (e: Event) => { + e.stopPropagation(); + const listing = this.args.model.listing; + if (listing) { + this.args.viewCard?.(listing, 'isolated'); + } + }; + + // ── PrCard live query ── + get realmHrefs() { + return buildRealmHrefs(this.args.model[realmURL]?.href); + } + + get prCardQuery(): Query | undefined { + if (!this.args.model.branchName) return undefined; + return { + filter: { + on: { + module: new URL('../pr-card/pr-card', import.meta.url).href, + name: 'PrCard', + }, + eq: { branchName: this.args.model.branchName }, + }, + }; + } + + prCardData = this.args.context?.getCards( + this, + () => this.prCardQuery, + () => this.realmHrefs, + { isLive: true }, + ); + + get prCardInstance() { + return this.prCardData?.instances?.[0] ?? null; + } + + openPrCard = (e: Event) => { + e.stopPropagation(); + if (this.prCardInstance) { + this.args.viewCard?.(this.prCardInstance, 'isolated'); + } + }; + + openSubmission = (e: Event) => { + e.stopPropagation(); + this.args.viewCard?.(this.args.model, 'isolated'); + }; + +} diff --git a/packages/catalog-realm/submission-card/components/card/isolated-template.gts b/packages/catalog-realm/submission-card/components/card/isolated-template.gts new file mode 100644 index 00000000000..68268342a1d --- /dev/null +++ b/packages/catalog-realm/submission-card/components/card/isolated-template.gts @@ -0,0 +1,410 @@ +import { on } from '@ember/modifier'; + +import { Component, realmURL } from 'https://cardstack.com/base/card-api'; +import type { Query } from '@cardstack/runtime-common'; + +import { or } from '@cardstack/boxel-ui/helpers'; +import { BoxelButton } from '@cardstack/boxel-ui/components'; + +import FileCodeIcon from '@cardstack/boxel-icons/file-code'; +import GitPullRequestIcon from '@cardstack/boxel-icons/git-pull-request'; +import MessageIcon from '@cardstack/boxel-icons/message'; +import GitBranchIcon from '@cardstack/boxel-icons/git-branch'; + +import { buildRealmHrefs } from '../../../pr-card/utils'; +import type { SubmissionCard } from '../../submission-card'; + +export class IsolatedTemplate extends Component { + get fileCount() { + return this.args.model.allFileContents?.length ?? 0; + } + + get listingName() { + return this.args.model.listing?.name ?? this.args.model.listing?.cardTitle; + } + + get title() { + return this.args.model.cardTitle; + } + + get branchName() { + return this.args.model.branchName; + } + + get roomId() { + return this.args.model.roomId; + } + + get listingImage() { + return this.args.model.listing?.images?.[0]; + } + + openListing = () => { + const listing = this.args.model.listing; + if (listing) { + this.args.viewCard?.(listing, 'isolated'); + } + }; + + get realmHrefs() { + return buildRealmHrefs(this.args.model[realmURL]?.href); + } + + get prCardQuery(): Query | undefined { + if (!this.args.model.branchName) return undefined; + return { + filter: { + on: { + module: new URL('../../../pr-card/pr-card', import.meta.url).href, + name: 'PrCard', + }, + eq: { branchName: this.args.model.branchName }, + }, + }; + } + + prCardData = this.args.context?.getCards( + this, + () => this.prCardQuery, + () => this.realmHrefs, + { isLive: true }, + ); + + get prCardInstance() { + return this.prCardData?.instances?.[0] ?? null; + } + + openPrCard = () => { + if (this.prCardInstance) { + this.args.viewCard?.(this.prCardInstance, 'isolated'); + } + }; + + +} + +function isPlural(count: number): boolean { + return count !== 1; +} diff --git a/packages/catalog-realm/submission-card/components/portal/realm-tabs.gts b/packages/catalog-realm/submission-card/components/portal/realm-tabs.gts new file mode 100644 index 00000000000..6ca2bd26a65 --- /dev/null +++ b/packages/catalog-realm/submission-card/components/portal/realm-tabs.gts @@ -0,0 +1,70 @@ +import GlimmerComponent from '@glimmer/component'; +import { on } from '@ember/modifier'; +import { fn } from '@ember/helper'; + +import type { RealmMetaField } from 'https://cardstack.com/base/command'; + +import { eq } from '@cardstack/boxel-ui/helpers'; +import { Pill } from '@cardstack/boxel-ui/components'; + +interface RealmTabsSignature { + Args: { + realms: RealmMetaField[]; + selectedRealm: string | null; + onChange: (realm: string | null) => void; + }; +} + +export class RealmTabs extends GlimmerComponent { + +} diff --git a/packages/catalog-realm/submission-card/submission-card-portal.gts b/packages/catalog-realm/submission-card/submission-card-portal.gts index abaadb6bfb8..0dd28d7cf07 100644 --- a/packages/catalog-realm/submission-card/submission-card-portal.gts +++ b/packages/catalog-realm/submission-card/submission-card-portal.gts @@ -1,11 +1,7 @@ import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; -import { on } from '@ember/modifier'; -import { fn } from '@ember/helper'; import { debounce } from 'lodash'; -import GlimmerComponent from '@glimmer/component'; - import { CardDef, Component, @@ -14,19 +10,18 @@ import { realmURL, } from 'https://cardstack.com/base/card-api'; import StringField from 'https://cardstack.com/base/string'; -import { type Query, type getCards } from '@cardstack/runtime-common'; import { commandData } from 'https://cardstack.com/base/resources/command-data'; import type { GetAllRealmMetasResult, RealmMetaField, } from 'https://cardstack.com/base/command'; +import { type Query, type getCards } from '@cardstack/runtime-common'; import GetAllRealmMetasCommand from '@cardstack/boxel-host/commands/get-all-realm-metas'; -import { eq, gt } from '@cardstack/boxel-ui/helpers'; +import { gt } from '@cardstack/boxel-ui/helpers'; import { BoxelInput, ViewSelector, - Pill, type ViewItem, } from '@cardstack/boxel-ui/components'; import { @@ -36,6 +31,7 @@ import { import BotIcon from '@cardstack/boxel-icons/bot'; import { CardsGrid } from '../catalog-app/components/grid'; +import { RealmTabs } from './components/portal/realm-tabs'; type ViewOption = 'strip' | 'grid'; @@ -44,67 +40,6 @@ const SUBMISSION_VIEW_OPTIONS: ViewItem[] = [ { id: 'grid', icon: GridIcon }, ]; -interface RealmTabsSignature { - Args: { - realms: RealmMetaField[]; - selectedRealm: string | null; - onChange: (realm: string | null) => void; - }; -} - -class RealmTabs extends GlimmerComponent { - -} - class Isolated extends Component { @tracked searchText: string = ''; @tracked selectedView: string = 'grid'; @@ -148,7 +83,7 @@ class Isolated extends Component { } // Query SubmissionCards across all known realms so we can see which ones - // actually have instances (via instancesByRealm) + // The filter uses adoptsFrom type matching — it looks for cards whose module/name matches SubmissionCard submissionDiscovery: ReturnType | undefined = this.args.context?.getCards( this, @@ -168,33 +103,33 @@ class Isolated extends Component { }; } + private get currentRealmHrefs(): string[] { + const url = this.args.model[realmURL]; + return url ? [url.href] : []; + } + + private get allRealmMetas(): RealmMetaField[] { + if (!this.allRealmsInfoResource?.isSuccess) return []; + return ( + (this.allRealmsInfoResource.cardResult as GetAllRealmMetasResult) + ?.results ?? [] + ); + } + // Only realms that actually have SubmissionCard instances, with full meta get availableRealms(): RealmMetaField[] { - const realmsWithCards = new Set( + const realmUrlsWithCards = new Set( (this.submissionDiscovery?.instancesByRealm ?? []).map((r) => r.realm), ); - const allMetas = - (this.allRealmsInfoResource?.cardResult as GetAllRealmMetasResult) - ?.results ?? []; - return allMetas.filter((r) => realmsWithCards.has(r.url)); + return this.allRealmMetas.filter((r) => realmUrlsWithCards.has(r.url)); } get realmHrefs(): string[] { - // Fall back to own realm while realm data is loading - if (!this.allRealmsInfoResource?.isSuccess) { - const url = this.args.model[realmURL]; - return url ? [url.href] : []; - } + if (!this.allRealmsInfoResource?.isSuccess) return this.currentRealmHrefs; + if (this.selectedRealm) return [this.selectedRealm]; - if (this.selectedRealm) { - return [this.selectedRealm]; - } - - // All realms selected — query every realm that has submissions - const urls = this.availableRealms.map((r) => r.url); - if (urls.length > 0) return urls; - const url = this.args.model[realmURL]; - return url ? [url.href] : []; + const availableUrls = this.availableRealms.map((r) => r.url); + return availableUrls.length > 0 ? availableUrls : this.currentRealmHrefs; } get query(): Query { @@ -327,7 +262,7 @@ class Isolated extends Component { export class SubmissionCardPortal extends CardDef { static displayName = 'Submission Card Portal'; static prefersWideFormat = true; - static headerColor = '#e5f0ff'; + static headerColor = '#00ffba'; static icon = BotIcon; @field title = contains(StringField); diff --git a/packages/catalog-realm/submission-card/submission-card.gts b/packages/catalog-realm/submission-card/submission-card.gts index b6874ec9b0f..6771d2b6824 100644 --- a/packages/catalog-realm/submission-card/submission-card.gts +++ b/packages/catalog-realm/submission-card/submission-card.gts @@ -1,29 +1,21 @@ -import { on } from '@ember/modifier'; - import { CardDef, - FieldDef, Component, + FieldDef, contains, containsMany, field, linksTo, - realmURL, } from 'https://cardstack.com/base/card-api'; import StringField from 'https://cardstack.com/base/string'; -import type { Query } from '@cardstack/runtime-common'; - -import { or } from '@cardstack/boxel-ui/helpers'; -import { BoxelButton } from '@cardstack/boxel-ui/components'; import BotIcon from '@cardstack/boxel-icons/bot'; import FileCodeIcon from '@cardstack/boxel-icons/file-code'; -import GitBranchIcon from '@cardstack/boxel-icons/git-branch'; -import GitPullRequestIcon from '@cardstack/boxel-icons/git-pull-request'; -import MessageIcon from '@cardstack/boxel-icons/message'; import { Listing } from '../catalog-app/listing/listing'; -import { buildRealmHrefs } from '../pr-card/utils'; + +import { FittedTemplate } from './components/card/fitted-template'; +import { IsolatedTemplate } from './components/card/isolated-template'; export class FileContentField extends FieldDef { @field filename = contains(StringField); @@ -172,6 +164,9 @@ export class SubmissionCard extends CardDef { static displayName = 'SubmissionCard'; static icon = BotIcon; + static fitted = FittedTemplate; + static isolated = IsolatedTemplate; + @field cardTitle = contains(StringField, { computeVia: function (this: SubmissionCard) { return ( @@ -183,963 +178,6 @@ export class SubmissionCard extends CardDef { @field branchName = contains(StringField); @field listing = linksTo(() => Listing); @field allFileContents = containsMany(FileContentField); - - static fitted = class Fitted extends Component { - get listingName() { - return ( - this.args.model.listing?.name ?? this.args.model.listing?.cardTitle - ); - } - - get title() { - return this.args.model.cardTitle; - } - - get branchName() { - return this.args.model.branchName; - } - - get roomId() { - return this.args.model.roomId; - } - - get listingImage() { - return this.args.model.listing?.images?.[0]; - } - - openListing = (e: Event) => { - e.stopPropagation(); - const listing = this.args.model.listing; - if (listing) { - this.args.viewCard?.(listing, 'isolated'); - } - }; - - // ── PrCard live query ── - get realmHrefs() { - return buildRealmHrefs(this.args.model[realmURL]?.href); - } - - get prCardQuery(): Query | undefined { - if (!this.args.model.branchName) return undefined; - return { - filter: { - on: { - module: new URL('../pr-card/pr-card', import.meta.url).href, - name: 'PrCard', - }, - eq: { branchName: this.args.model.branchName }, - }, - }; - } - - prCardData = this.args.context?.getCards( - this, - () => this.prCardQuery, - () => this.realmHrefs, - { isLive: true }, - ); - - get prCardInstance() { - return this.prCardData?.instances?.[0] ?? null; - } - - openPrCard = (e: Event) => { - e.stopPropagation(); - if (this.prCardInstance) { - this.args.viewCard?.(this.prCardInstance, 'isolated'); - } - }; - - openSubmission = (e: Event) => { - e.stopPropagation(); - this.args.viewCard?.(this.args.model, 'isolated'); - }; - - - }; - - static isolated = class Isolated extends Component { - get fileCount() { - return this.args.model.allFileContents?.length ?? 0; - } - - get listingName() { - return ( - this.args.model.listing?.name ?? this.args.model.listing?.cardTitle - ); - } - - get title() { - return this.args.model.cardTitle; - } - - get branchName() { - return this.args.model.branchName; - } - - get roomId() { - return this.args.model.roomId; - } - - get listingImage() { - return this.args.model.listing?.images?.[0]; - } - - openListing = () => { - const listing = this.args.model.listing; - if (listing) { - this.args.viewCard?.(listing, 'isolated'); - } - }; - - // ── PrCard live query ── - get realmHrefs() { - return buildRealmHrefs(this.args.model[realmURL]?.href); - } - - get prCardQuery(): Query | undefined { - if (!this.args.model.branchName) return undefined; - return { - filter: { - on: { - module: new URL('../pr-card/pr-card', import.meta.url).href, - name: 'PrCard', - }, - eq: { branchName: this.args.model.branchName }, - }, - }; - } - - prCardData = this.args.context?.getCards( - this, - () => this.prCardQuery, - () => this.realmHrefs, - { isLive: true }, - ); - - get prCardInstance() { - return this.prCardData?.instances?.[0] ?? null; - } - - openPrCard = () => { - if (this.prCardInstance) { - this.args.viewCard?.(this.prCardInstance, 'isolated'); - } - }; - - - }; } function isPlural(count: number): boolean { From ca1ad8657d1aa71c62b2c57294c5068c0e5d3ba0 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 11 Mar 2026 12:02:13 +0800 Subject: [PATCH 10/19] query should have sorting --- .../components/card/fitted-template.gts | 1 + .../components/card/isolated-template.gts | 1 + .../submission-card-portal.gts | 35 +++++++++++++------ 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/packages/catalog-realm/submission-card/components/card/fitted-template.gts b/packages/catalog-realm/submission-card/components/card/fitted-template.gts index 8eb3abbc361..0dcc7f96532 100644 --- a/packages/catalog-realm/submission-card/components/card/fitted-template.gts +++ b/packages/catalog-realm/submission-card/components/card/fitted-template.gts @@ -55,6 +55,7 @@ export class FittedTemplate extends Component { }, eq: { branchName: this.args.model.branchName }, }, + sort: [{ by: 'lastModified', direction: 'desc' }], }; } diff --git a/packages/catalog-realm/submission-card/components/card/isolated-template.gts b/packages/catalog-realm/submission-card/components/card/isolated-template.gts index 68268342a1d..b5df0543658 100644 --- a/packages/catalog-realm/submission-card/components/card/isolated-template.gts +++ b/packages/catalog-realm/submission-card/components/card/isolated-template.gts @@ -60,6 +60,7 @@ export class IsolatedTemplate extends Component { }, eq: { branchName: this.args.model.branchName }, }, + sort: [{ by: 'lastModified', direction: 'desc' }], }; } diff --git a/packages/catalog-realm/submission-card/submission-card-portal.gts b/packages/catalog-realm/submission-card/submission-card-portal.gts index 0dd28d7cf07..077ecf66433 100644 --- a/packages/catalog-realm/submission-card/submission-card-portal.gts +++ b/packages/catalog-realm/submission-card/submission-card-portal.gts @@ -21,6 +21,7 @@ import GetAllRealmMetasCommand from '@cardstack/boxel-host/commands/get-all-real import { gt } from '@cardstack/boxel-ui/helpers'; import { BoxelInput, + LoadingIndicator, ViewSelector, type ViewItem, } from '@cardstack/boxel-ui/components'; @@ -100,6 +101,7 @@ class Isolated extends Component { name: 'SubmissionCard', }, }, + sort: [{ by: 'createdAt', direction: 'desc' }], }; } @@ -124,19 +126,27 @@ class Isolated extends Component { return this.allRealmMetas.filter((r) => realmUrlsWithCards.has(r.url)); } + get isRealmsReady(): boolean { + return ( + this.allRealmsInfoResource?.isSuccess === true && + this.submissionDiscovery?.isLoading === false + ); + } + get realmHrefs(): string[] { - if (!this.allRealmsInfoResource?.isSuccess) return this.currentRealmHrefs; + const fallback = this.currentRealmHrefs; + if (!this.allRealmsInfoResource?.isSuccess) return fallback; if (this.selectedRealm) return [this.selectedRealm]; const availableUrls = this.availableRealms.map((r) => r.url); - return availableUrls.length > 0 ? availableUrls : this.currentRealmHrefs; + return availableUrls.length > 0 ? availableUrls : fallback; } get query(): Query { - const baseFilter = this.baseTypeFilter.filter!; + const { filter: baseFilter, sort } = this.baseTypeFilter; if (!this.searchText) { - return { filter: baseFilter }; + return { filter: baseFilter, sort }; } return { @@ -148,6 +158,7 @@ class Isolated extends Component { }, ], }, + sort, }; } @@ -180,12 +191,16 @@ class Isolated extends Component {
- + {{#if this.isRealmsReady}} + + {{else}} + + {{/if}}
From bca36d67fd23a1ffce8150af22c82f19fb660f65 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 11 Mar 2026 12:47:09 +0800 Subject: [PATCH 11/19] add a pending review display --- .../components/isolated/review-section.gts | 24 ++++- packages/catalog-realm/pr-card/pr-card.gts | 10 ++ .../components/card/fitted-template.gts | 96 ++++++++++++++++++- .../components/card/isolated-template.gts | 92 +++++++++++++++++- 4 files changed, 215 insertions(+), 7 deletions(-) diff --git a/packages/catalog-realm/pr-card/components/isolated/review-section.gts b/packages/catalog-realm/pr-card/components/isolated/review-section.gts index 6e42a87522a..0a79ea5c52c 100644 --- a/packages/catalog-realm/pr-card/components/isolated/review-section.gts +++ b/packages/catalog-realm/pr-card/components/isolated/review-section.gts @@ -13,18 +13,22 @@ class ReviewStateBadge extends GlimmerComponent { if (this.args.state === 'changes_requested') return 'review-state-badge--changes'; if (this.args.state === 'approved') return 'review-state-badge--approved'; + if (this.args.state === 'unknown') return 'review-state-badge--pending'; return ''; } get label() { if (this.args.state === 'changes_requested') return 'Changes Requested'; if (this.args.state === 'approved') return 'Approved'; + if (this.args.state === 'unknown') return 'Pending Review'; return ''; } get hasState() { return ( - this.args.state === 'changes_requested' || this.args.state === 'approved' + this.args.state === 'changes_requested' || + this.args.state === 'approved' || + this.args.state === 'unknown' ); } @@ -70,6 +74,11 @@ class ReviewStateBadge extends GlimmerComponent { border: 1px solid color-mix(in srgb, var(--chart-1, #28a745) 35%, var(--card, #ffffff)); } + .review-state-badge--pending { + background: color-mix(in srgb, #9a6700 10%, var(--card, #ffffff)); + color: #9a6700; + border: 1px solid color-mix(in srgb, #9a6700 30%, var(--card, #ffffff)); + } } @@ -94,6 +103,9 @@ export class ReviewSection extends GlimmerComponent { if (this.args.reviewState === 'approved') { return 'review-item--approved'; } + if (this.args.reviewState === 'unknown') { + return 'review-item--pending'; + } return ''; } @@ -130,7 +142,7 @@ export class ReviewSection extends GlimmerComponent { - - + Pending Review {{/if}} @@ -280,6 +292,14 @@ export class ReviewSection extends GlimmerComponent { var(--card, #ffffff) ); } + .review-item--pending { + background: color-mix(in srgb, #9a6700 8%, var(--card, #ffffff)); + border-color: color-mix(in srgb, #9a6700 25%, var(--card, #ffffff)); + } + .review-item--pending .empty-state-text { + color: #9a6700; + font-weight: 600; + } } diff --git a/packages/catalog-realm/pr-card/pr-card.gts b/packages/catalog-realm/pr-card/pr-card.gts index 09cd13f779b..f118809c856 100644 --- a/packages/catalog-realm/pr-card/pr-card.gts +++ b/packages/catalog-realm/pr-card/pr-card.gts @@ -532,6 +532,10 @@ class FittedTemplate extends Component {
Approved
+ {{else}} +
+ Pending Review +
{{/if}}
@@ -742,6 +746,9 @@ class FittedTemplate extends Component { var(--card, #ffffff) ); } + .review-status-row--pending { + background: color-mix(in srgb, #9a6700 8%, var(--card, #ffffff)); + } .review-status-label { font-size: var(--boxel-font-sm); font-weight: 600; @@ -749,6 +756,9 @@ class FittedTemplate extends Component { .review-status-row--changes .review-status-label { color: var(--destructive, #d73a49); } + .review-status-row--pending .review-status-label { + color: #9a6700; + } .review-status-row--approved .review-status-label { color: var(--chart-1, #28a745); } diff --git a/packages/catalog-realm/submission-card/components/card/fitted-template.gts b/packages/catalog-realm/submission-card/components/card/fitted-template.gts index 0dcc7f96532..36777268f5a 100644 --- a/packages/catalog-realm/submission-card/components/card/fitted-template.gts +++ b/packages/catalog-realm/submission-card/components/card/fitted-template.gts @@ -3,13 +3,22 @@ import { on } from '@ember/modifier'; import { Component, realmURL } from 'https://cardstack.com/base/card-api'; import type { Query } from '@cardstack/runtime-common'; +import { eq } from '@cardstack/boxel-ui/helpers'; import { BoxelButton } from '@cardstack/boxel-ui/components'; +import CheckCircleIcon from '@cardstack/boxel-icons/check-circle'; +import ClockIcon from '@cardstack/boxel-icons/clock'; import GitBranchIcon from '@cardstack/boxel-icons/git-branch'; import GitPullRequestIcon from '@cardstack/boxel-icons/git-pull-request'; import MessageIcon from '@cardstack/boxel-icons/message'; - -import { buildRealmHrefs } from '../../../pr-card/utils'; +import XCircleIcon from '@cardstack/boxel-icons/x-circle'; + +import { + buildRealmHrefs, + buildLatestReviewByReviewer, + computeLatestReviewState, + searchEventQuery, +} from '../../../pr-card/utils'; import type { SubmissionCard } from '../../submission-card'; export class FittedTemplate extends Component { @@ -70,6 +79,39 @@ export class FittedTemplate extends Component { return this.prCardData?.instances?.[0] ?? null; } + get githubEventCardRef() { + return { + module: new URL('../../../github-event/github-event', import.meta.url) + .href, + name: 'GithubEventCard' as const, + }; + } + + get prReviewEventQuery(): Query | undefined { + const prNumber = this.prCardInstance?.prNumber; + if (!prNumber) return undefined; + return searchEventQuery( + this.githubEventCardRef, + prNumber, + 'pull_request_review', + ); + } + + prReviewEventData = this.args.context?.getCards( + this, + () => this.prReviewEventQuery, + () => this.realmHrefs, + { isLive: true }, + ); + + get reviewState() { + if (!this.prCardInstance) return null; + const reviews = buildLatestReviewByReviewer( + this.prReviewEventData?.instances ?? [], + ); + return computeLatestReviewState(reviews); + } + openPrCard = (e: Event) => { e.stopPropagation(); if (this.prCardInstance) { @@ -96,6 +138,27 @@ export class FittedTemplate extends Component { {{else}} <@model.constructor.icon class='card-icon' /> {{/if}} + {{#if this.reviewState}} + + {{#if (eq this.reviewState 'approved')}} + + {{else if (eq this.reviewState 'changes_requested')}} + + {{else}} + + {{/if}} + + {{/if}} {{#if @model.listing}}
{ --boxel-button-border: 1px solid var(--border, #d0d7de); } + .review-corner-badge { + position: absolute; + top: var(--boxel-sp-4xs); + right: var(--boxel-sp-4xs); + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 50%; + z-index: 1; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.45), 0 0 0 1.5px rgba(255, 255, 255, 0.9); + } + + .review-corner-badge--approved { + background: #d4edda; + color: #1a7f37; + } + + .review-corner-badge--changes { + background: #fde8ea; + color: #d73a49; + } + + .review-corner-badge--pending { + background: #fff3cd; + color: #9a6700; + } + @container fitted-card (aspect-ratio <= 1.0) { .submission-fitted { flex-direction: column; diff --git a/packages/catalog-realm/submission-card/components/card/isolated-template.gts b/packages/catalog-realm/submission-card/components/card/isolated-template.gts index b5df0543658..1bbe38c32ed 100644 --- a/packages/catalog-realm/submission-card/components/card/isolated-template.gts +++ b/packages/catalog-realm/submission-card/components/card/isolated-template.gts @@ -3,15 +3,23 @@ import { on } from '@ember/modifier'; import { Component, realmURL } from 'https://cardstack.com/base/card-api'; import type { Query } from '@cardstack/runtime-common'; -import { or } from '@cardstack/boxel-ui/helpers'; +import { eq, or } from '@cardstack/boxel-ui/helpers'; import { BoxelButton } from '@cardstack/boxel-ui/components'; import FileCodeIcon from '@cardstack/boxel-icons/file-code'; import GitPullRequestIcon from '@cardstack/boxel-icons/git-pull-request'; import MessageIcon from '@cardstack/boxel-icons/message'; import GitBranchIcon from '@cardstack/boxel-icons/git-branch'; - -import { buildRealmHrefs } from '../../../pr-card/utils'; +import CheckCircleIcon from '@cardstack/boxel-icons/check-circle'; +import XCircleIcon from '@cardstack/boxel-icons/x-circle'; +import ClockIcon from '@cardstack/boxel-icons/clock'; + +import { + buildRealmHrefs, + buildLatestReviewByReviewer, + computeLatestReviewState, + searchEventQuery, +} from '../../../pr-card/utils'; import type { SubmissionCard } from '../../submission-card'; export class IsolatedTemplate extends Component { @@ -75,6 +83,39 @@ export class IsolatedTemplate extends Component { return this.prCardData?.instances?.[0] ?? null; } + get githubEventCardRef() { + return { + module: new URL('../../../github-event/github-event', import.meta.url) + .href, + name: 'GithubEventCard' as const, + }; + } + + get prReviewEventQuery(): Query | undefined { + const prNumber = this.prCardInstance?.prNumber; + if (!prNumber) return undefined; + return searchEventQuery( + this.githubEventCardRef, + prNumber, + 'pull_request_review', + ); + } + + prReviewEventData = this.args.context?.getCards( + this, + () => this.prReviewEventQuery, + () => this.realmHrefs, + { isLive: true }, + ); + + get reviewState() { + if (!this.prCardInstance) return null; + const reviews = buildLatestReviewByReviewer( + this.prReviewEventData?.instances ?? [], + ); + return computeLatestReviewState(reviews); + } + openPrCard = () => { if (this.prCardInstance) { this.args.viewCard?.(this.prCardInstance, 'isolated'); @@ -168,6 +209,26 @@ export class IsolatedTemplate extends Component {
+ {{#if this.reviewState}} +
+ {{#if (eq this.reviewState 'approved')}} + Approved + {{else if (eq this.reviewState 'changes_requested')}} + Changes Requested + {{else}} + Pending Review + {{/if}} +
+ {{/if}} + {{#if this.fileCount}}

@@ -296,6 +357,31 @@ export class IsolatedTemplate extends Component { white-space: nowrap; } + .review-banner { + display: flex; + align-items: center; + gap: var(--boxel-sp-xs); + padding: var(--boxel-sp-xs) var(--boxel-sp-xl); + font-size: var(--boxel-font-size-sm); + font-weight: 600; + flex-shrink: 0; + } + + .review-banner--approved { + background: #d4edda; + color: #1a7f37; + } + + .review-banner--changes { + background: #fde8ea; + color: #d73a49; + } + + .review-banner--pending { + background: #fff3cd; + color: #9a6700; + } + .view-pr-btn { display: inline-flex; align-items: center; From 4ce0212b8030491d9c887ba55577772c899d3945 Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 11 Mar 2026 12:58:43 +0800 Subject: [PATCH 12/19] fix lint --- .../submission-card/components/card/fitted-template.gts | 9 +++++---- .../components/card/isolated-template.gts | 9 +++++---- .../submission-card/submission-card-portal.gts | 2 +- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/catalog-realm/submission-card/components/card/fitted-template.gts b/packages/catalog-realm/submission-card/components/card/fitted-template.gts index 36777268f5a..7cc051c6bca 100644 --- a/packages/catalog-realm/submission-card/components/card/fitted-template.gts +++ b/packages/catalog-realm/submission-card/components/card/fitted-template.gts @@ -6,12 +6,12 @@ import type { Query } from '@cardstack/runtime-common'; import { eq } from '@cardstack/boxel-ui/helpers'; import { BoxelButton } from '@cardstack/boxel-ui/components'; -import CheckCircleIcon from '@cardstack/boxel-icons/check-circle'; +import CheckCircleIcon from '@cardstack/boxel-icons/circle-check'; import ClockIcon from '@cardstack/boxel-icons/clock'; import GitBranchIcon from '@cardstack/boxel-icons/git-branch'; import GitPullRequestIcon from '@cardstack/boxel-icons/git-pull-request'; import MessageIcon from '@cardstack/boxel-icons/message'; -import XCircleIcon from '@cardstack/boxel-icons/x-circle'; +import XCircleIcon from '@cardstack/boxel-icons/circle-x'; import { buildRealmHrefs, @@ -19,6 +19,7 @@ import { computeLatestReviewState, searchEventQuery, } from '../../../pr-card/utils'; +import type { PrCard } from '../../../pr-card/pr-card'; import type { SubmissionCard } from '../../submission-card'; export class FittedTemplate extends Component { @@ -75,8 +76,8 @@ export class FittedTemplate extends Component { { isLive: true }, ); - get prCardInstance() { - return this.prCardData?.instances?.[0] ?? null; + get prCardInstance(): PrCard | null { + return (this.prCardData?.instances?.[0] as PrCard) ?? null; } get githubEventCardRef() { diff --git a/packages/catalog-realm/submission-card/components/card/isolated-template.gts b/packages/catalog-realm/submission-card/components/card/isolated-template.gts index 1bbe38c32ed..1f512ad6209 100644 --- a/packages/catalog-realm/submission-card/components/card/isolated-template.gts +++ b/packages/catalog-realm/submission-card/components/card/isolated-template.gts @@ -10,8 +10,8 @@ import FileCodeIcon from '@cardstack/boxel-icons/file-code'; import GitPullRequestIcon from '@cardstack/boxel-icons/git-pull-request'; import MessageIcon from '@cardstack/boxel-icons/message'; import GitBranchIcon from '@cardstack/boxel-icons/git-branch'; -import CheckCircleIcon from '@cardstack/boxel-icons/check-circle'; -import XCircleIcon from '@cardstack/boxel-icons/x-circle'; +import CheckCircleIcon from '@cardstack/boxel-icons/circle-check'; +import XCircleIcon from '@cardstack/boxel-icons/circle-x'; import ClockIcon from '@cardstack/boxel-icons/clock'; import { @@ -20,6 +20,7 @@ import { computeLatestReviewState, searchEventQuery, } from '../../../pr-card/utils'; +import type { PrCard } from '../../../pr-card/pr-card'; import type { SubmissionCard } from '../../submission-card'; export class IsolatedTemplate extends Component { @@ -79,8 +80,8 @@ export class IsolatedTemplate extends Component { { isLive: true }, ); - get prCardInstance() { - return this.prCardData?.instances?.[0] ?? null; + get prCardInstance(): PrCard | null { + return (this.prCardData?.instances?.[0] as PrCard) ?? null; } get githubEventCardRef() { diff --git a/packages/catalog-realm/submission-card/submission-card-portal.gts b/packages/catalog-realm/submission-card/submission-card-portal.gts index 077ecf66433..aa90638470d 100644 --- a/packages/catalog-realm/submission-card/submission-card-portal.gts +++ b/packages/catalog-realm/submission-card/submission-card-portal.gts @@ -152,7 +152,7 @@ class Isolated extends Component { return { filter: { every: [ - baseFilter, + baseFilter!, { any: [{ contains: { cardTitle: this.searchText } }], }, From 3b3554fabde4990cc45a2a96d8c7ee6544e379fa Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 11 Mar 2026 14:02:27 +0800 Subject: [PATCH 13/19] remove testing instances --- .../41c6a6dd-6fee-4bf2-acdc-4d7ab52797df.json | 55 ------- .../6e89407c-05ee-4bdc-a5dc-7cc1035a4d22.json | 139 ------------------ .../components/card/fitted-template.gts | 118 +-------------- 3 files changed, 7 insertions(+), 305 deletions(-) delete mode 100644 packages/catalog-realm/SubmissionCard/41c6a6dd-6fee-4bf2-acdc-4d7ab52797df.json delete mode 100644 packages/catalog-realm/SubmissionCard/6e89407c-05ee-4bdc-a5dc-7cc1035a4d22.json diff --git a/packages/catalog-realm/SubmissionCard/41c6a6dd-6fee-4bf2-acdc-4d7ab52797df.json b/packages/catalog-realm/SubmissionCard/41c6a6dd-6fee-4bf2-acdc-4d7ab52797df.json deleted file mode 100644 index 489c2cabb11..00000000000 --- a/packages/catalog-realm/SubmissionCard/41c6a6dd-6fee-4bf2-acdc-4d7ab52797df.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "data": { - "meta": { - "adoptsFrom": { - "name": "SubmissionCard", - "module": "../submission-card/submission-card" - } - }, - "type": "card", - "attributes": { - "roomId": "!DYcDLMhVkhJzEraJFu:localhost", - "cardInfo": { - "name": null, - "notes": null, - "summary": null, - "cardThumbnailURL": null - }, - "branchName": "room-IURZY0RMTWhWa2hKekVyYUpGdTpsb2NhbGhvc3Q/featured-image", - "allFileContents": [ - { - "filename": "FieldListing/749411c1-a704-496d-a156-bdd0b9558702.json", - "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"FieldListing\",\n \"module\": \"../catalog-app/listing/listing\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"name\": \"Featured Image\",\n \"images\": [\n \"https://boxel-images.boxel.ai/app-assets/catalog/featured-image-listing/screenshot_01.png\",\n \"https://boxel-images.boxel.ai/app-assets/catalog/featured-image-listing/screenshot_02.png\"\n ],\n \"summary\": \"The Featured Image Field is a versatile component that allows you to easily add and customize images in your application. Whether you need to display a simple image or a more detailed presentation with captions and credits, this component has you covered.\\n\\nKey Features\\n- Image Display: Add an image by providing its URL. The component will handle the rest.\\n- Accessibility: Include alternative text for images to ensure accessibility for all users.\\n- Captions and Credits: Add descriptive captions and credits to your images for better context and attribution.\\n- Flexible Sizing: Choose how your image is displayed with options like actual, contain, or cover.\\n- Custom Dimensions: Specify exact dimensions for your image to fit your design needs.\\n\\nDisplay Options\\n- Simple View: Display your image with basic styling.\\n- Detailed View: Use the embedded option to include captions and credits, making your images more informative.\",\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": \"https://boxel-images.boxel.ai/app-assets/catalog/featured-image-listing/thumbnail.png\"\n }\n },\n \"relationships\": {\n \"tags\": {\n \"links\": {\n \"self\": null\n }\n },\n \"skills\": {\n \"links\": {\n \"self\": null\n }\n },\n \"license\": {\n \"links\": {\n \"self\": null\n }\n },\n \"specs.0\": {\n \"links\": {\n \"self\": \"../Spec/featured-image\"\n }\n },\n \"publisher\": {\n \"links\": {\n \"self\": \"../Publisher/9d3ca05b-684b-408f-8d8c-dd353d9956e0\"\n }\n },\n \"examples.0\": {\n \"links\": {\n \"self\": \"../fields-preview/FeaturedImagePreview/fe965f83-6de4-4a65-bcc1-f1b0b0a57f8e\"\n }\n },\n \"categories.0\": {\n \"links\": {\n \"self\": \"../Category/developer-tools-code\"\n }\n },\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" - }, - { - "filename": "fields/featured-image.gts", - "contents": "import { hash } from '@ember/helper';\nimport { htmlSafe } from '@ember/template';\nimport {\n Component,\n field,\n contains,\n StringField,\n FieldDef,\n} from 'https://cardstack.com/base/card-api';\nimport NumberField from 'https://cardstack.com/base/number';\nimport { ImageSizeField } from 'https://cardstack.com/base/base64-image';\nimport UrlField from 'https://cardstack.com/base/url';\nimport { FieldContainer } from '@cardstack/boxel-ui/components';\nimport { FailureBordered } from '@cardstack/boxel-ui/icons';\nimport PhotoIcon from '@cardstack/boxel-icons/photo';\n\nconst setBackgroundImage = (backgroundURL: string | null | undefined) => {\n if (!backgroundURL) {\n return;\n }\n return htmlSafe(`background-image: url(${backgroundURL});`);\n};\n\nfunction cssForFeaturedImage({\n imageUrl,\n size,\n height,\n width,\n}: {\n imageUrl: string | undefined;\n size: 'actual' | 'contain' | 'cover' | undefined;\n height?: number;\n width?: number;\n}) {\n if (!imageUrl) {\n return undefined;\n }\n\n let css: string[] = [];\n css.push(`background-image: url(\"${imageUrl}\");`);\n if (size && ['contain', 'cover'].includes(size)) {\n css.push(`background-size: ${size};`);\n }\n if (height) {\n css.push(`height: ${height}px;`);\n }\n if (width) {\n css.push(`width: ${width}px`);\n } else {\n css.push(`width: 100%`);\n }\n return htmlSafe(css.join(' '));\n}\n\nexport default class FeaturedImageField extends FieldDef {\n static displayName = 'Featured Image';\n static icon = PhotoIcon;\n @field imageUrl = contains(UrlField);\n @field credit = contains(StringField);\n @field caption = contains(StringField);\n @field altText = contains(StringField);\n @field size = contains(ImageSizeField);\n @field height = contains(NumberField);\n @field width = contains(NumberField);\n static edit = class Edit extends Component {\n get usesActualSize() {\n return this.args.model.size === 'actual' || this.args.model.size == null;\n }\n\n get backgroundMaskStyle() {\n let css: string[] = [];\n if (this.args.model.height) {\n css.push(`height: ${this.args.model.height}px;`);\n }\n if (this.args.model.width) {\n css.push(`width: ${this.args.model.width}px`);\n }\n return htmlSafe(css.join(' '));\n }\n\n get needsHeight() {\n return (\n (this.args.model.size === 'contain' ||\n this.args.model.size === 'cover') &&\n !this.args.model.height\n );\n }\n\n \n };\n\n static atom = class Atom extends Component {\n \n };\n static embedded = class Embedded extends Component {\n get usesActualSize() {\n return this.args.model.size === 'actual' || this.args.model.size == null;\n }\n \n };\n}\n" - }, - { - "filename": "fields-preview/featured-image.gts", - "contents": "import FeaturedImageField from '../fields/featured-image';\n\nimport {\n CardDef,\n field,\n contains,\n containsMany,\n type BaseDefConstructor,\n type Field,\n} from 'https://cardstack.com/base/card-api';\nimport { Component } from 'https://cardstack.com/base/card-api';\nimport { FieldContainer } from '@cardstack/boxel-ui/components';\nimport { getField } from '@cardstack/runtime-common';\n\nexport class FeaturedImagePreview extends CardDef {\n @field featuredImage = contains(FeaturedImageField);\n @field images = containsMany(FeaturedImageField);\n\n static displayName = 'Featured Image Preview';\n static isolated = class Isolated extends Component {\n \n getFieldIcon = (key: string) => {\n const field: Field | undefined = getField(\n this.args.model.constructor!,\n key,\n );\n let fieldInstance = field?.card;\n return fieldInstance?.icon;\n };\n };\n}\n" - }, - { - "filename": "Spec/featured-image.json", - "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"name\": \"default\",\n \"module\": \"../fields/featured-image\"\n },\n \"specType\": \"field\",\n \"containedExamples\": [],\n \"cardTitle\": \"FeaturedImageField\",\n \"cardDescription\": null,\n \"cardThumbnailURL\": null\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" - }, - { - "filename": "fields-preview/FeaturedImagePreview/fe965f83-6de4-4a65-bcc1-f1b0b0a57f8e.json", - "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"featuredImage\": {\n \"imageUrl\": \"https://boxel-images.boxel.ai/app-assets/portraits/photo-1479936343636-73cdc5aae0c3.jpeg\",\n \"credit\": \"Photo by Unsplash\",\n \"caption\": \"It's a new day, it's a new life.\",\n \"altText\": \"Woman smiling with the glow of sun in the background\",\n \"size\": \"actual\",\n \"height\": null,\n \"width\": 300\n },\n \"images\": [\n {\n \"imageUrl\": \"https://boxel-images.boxel.ai/app-assets/portraits/photo-1521119989659-a83eee488004.jpeg\",\n \"credit\": \"Photo by Unsplash - Ut velit modi sed aliquid molestiae in unde voluptas.\",\n \"caption\": \"Nam voluptatem nostrum qui aperiam rerum non similique porro sed iusto placeat cum sequi dolore. \",\n \"altText\": \"Portrait of man on a rooftop\",\n \"size\": \"actual\",\n \"height\": null,\n \"width\": null\n },\n {\n \"imageUrl\": \"https://boxel-images.boxel.ai/app-assets/portraits/photo-1496345875659-11f7dd282d1d.jpeg\",\n \"credit\": \"Photo by Unsplash - Quis quibusdam rem rerum maiores 33 galisum quidem.\",\n \"caption\": \"Aut asperiores impedit nam aperiam dolore ex libero voluptate.\",\n \"altText\": \"Man with dark sunglasses\",\n \"size\": \"actual\",\n \"height\": null,\n \"width\": null\n }\n ],\n \"cardTitle\": \"Featured Image Preview\",\n \"cardDescription\": null,\n \"cardThumbnailURL\": null\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"../../fields-preview/featured-image\",\n \"name\": \"FeaturedImagePreview\"\n }\n }\n }\n}" - } - ] - }, - "relationships": { - "listing": { - "links": { - "self": "../FieldListing/749411c1-a704-496d-a156-bdd0b9558702" - } - }, - "cardInfo.theme": { - "links": { - "self": null - } - } - } - } -} \ No newline at end of file diff --git a/packages/catalog-realm/SubmissionCard/6e89407c-05ee-4bdc-a5dc-7cc1035a4d22.json b/packages/catalog-realm/SubmissionCard/6e89407c-05ee-4bdc-a5dc-7cc1035a4d22.json deleted file mode 100644 index a452ff3e6e3..00000000000 --- a/packages/catalog-realm/SubmissionCard/6e89407c-05ee-4bdc-a5dc-7cc1035a4d22.json +++ /dev/null @@ -1,139 +0,0 @@ -{ - "data": { - "meta": { - "adoptsFrom": { - "name": "SubmissionCard", - "module": "../submission-card/submission-card" - } - }, - "type": "card", - "attributes": { - "roomId": "!tUPLakQEhhvpjDoTjQ:localhost", - "cardInfo": { - "name": null, - "notes": null, - "summary": null, - "cardThumbnailURL": null - }, - "branchName": "room-IXRVUExha1FFaGh2cGpEb1RqUTpsb2NhbGhvc3Q/avatar-creator", - "allFileContents": [ - { - "filename": "CardListing/f0c0ad91-0194-46b9-a971-9e60d637a51a.json", - "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"CardListing\",\n \"module\": \"../catalog-app/listing/listing\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"name\": \"Avatar Creator\",\n \"images\": [\n \"https://boxel-images.boxel.ai/app-assets/catalog/avatar-creator-listing/screenshot_01.png\",\n \"https://boxel-images.boxel.ai/app-assets/catalog/avatar-creator-listing/screenshot_02.png\"\n ],\n \"summary\": \"Create customizable avatar- avatars with various appearance options including hair styles, colors, facial features, and clothing.\",\n \"cardInfo\": {\n \"notes\": \"Includes AI-powered avatar- suggestion feature for creative inspiration.\",\n \"name\": \"Avatar Creator\",\n \"summary\": \"An interactive tool for designing and customizing avatar- avatars. Choose from different hair styles, hair colors, facial expressions, skin tones, eyebrows, and clothing options to create unique avatars. Perfect for games, storytelling, or creative projects.\",\n \"cardThumbnailURL\": \"https://boxel-images.boxel.ai/app-assets/catalog/avatar-creator-listing/thumbnail.png\"\n }\n },\n \"relationships\": {\n \"skills\": {\n \"links\": {\n \"self\": null\n }\n },\n \"tags.0\": {\n \"links\": {\n \"self\": \"../Tag/140feda8-625b-4a24-9ddb-6f4da891aef2\"\n }\n },\n \"license\": {\n \"links\": {\n \"self\": null\n }\n },\n \"specs.0\": {\n \"links\": {\n \"self\": \"../Spec/1b023c94-c534-43bf-8a09-3db1f6f70967\"\n }\n },\n \"specs.1\": {\n \"links\": {\n \"self\": \"../Spec/94c53473-bfca-493d-b1f6-f70967d4714d\"\n }\n },\n \"specs.2\": {\n \"links\": {\n \"self\": \"../Spec/bfca093d-b1f6-4709-a7d4-714d86bdb90e\"\n }\n },\n \"specs.3\": {\n \"links\": {\n \"self\": \"../Spec/ca093db1-f6f7-4967-9471-4d86bdb90e9a\"\n }\n },\n \"specs.4\": {\n \"links\": {\n \"self\": \"../Spec/73bfca09-3db1-46f7-8967-d4714d86bdb9\"\n }\n },\n \"specs.5\": {\n \"links\": {\n \"self\": \"../Spec/093db1f6-f709-47d4-b14d-86bdb90e9a95\"\n }\n },\n \"specs.6\": {\n \"links\": {\n \"self\": \"../Spec/b1f6f709-67d4-414d-86bd-b90e9a95eb3e\"\n }\n },\n \"specs.7\": {\n \"links\": {\n \"self\": \"../Spec/3db1f6f7-0967-4471-8d86-bdb90e9a95eb\"\n }\n },\n \"specs.8\": {\n \"links\": {\n \"self\": \"../Spec/f6f70967-d471-4d86-bdb9-0e9a95eb3e97\"\n }\n },\n \"specs.9\": {\n \"links\": {\n \"self\": \"../Spec/938e0b02-5259-4ae3-956f-a568ea6adf2f\"\n }\n },\n \"specs.10\": {\n \"links\": {\n \"self\": \"../Spec/67d4714d-86bd-490e-9a95-eb3e97e8960c\"\n }\n },\n \"specs.11\": {\n \"links\": {\n \"self\": \"../Spec/4d86bdb9-0e9a-45eb-be97-e8960cfb907e\"\n }\n },\n \"specs.12\": {\n \"links\": {\n \"self\": \"../Spec/b90e9a95-eb3e-47e8-960c-fb907eed22d7\"\n }\n },\n \"specs.13\": {\n \"links\": {\n \"self\": \"../Spec/0252595a-e315-4fa5-a8ea-6adf2fda9ca6\"\n }\n },\n \"publisher\": {\n \"links\": {\n \"self\": \"../Publisher/5f27bc69-6e15-4027-bff5-5c893a2642d9\"\n }\n },\n \"examples.0\": {\n \"links\": {\n \"self\": \"../avatar-creator/AvatarCreator/luna-starweaver\"\n }\n },\n \"examples.1\": {\n \"links\": {\n \"self\": \"../avatar-creator/AvatarCreator/magnus-stormbeard\"\n }\n },\n \"examples.2\": {\n \"links\": {\n \"self\": \"../avatar-creator/AvatarCreator/pichu\"\n }\n },\n \"examples.3\": {\n \"links\": {\n \"self\": \"../avatar-creator/AvatarCreator/zara-nightshade\"\n }\n },\n \"categories.0\": {\n \"links\": {\n \"self\": \"../Category/design-creative\"\n }\n },\n \"categories.1\": {\n \"links\": {\n \"self\": \"../Category/ui-components-design\"\n }\n },\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" - }, - { - "filename": "avatar-creator/avatar-creator.gts", - "contents": "import {\n CardDef,\n Component,\n field,\n contains,\n} from 'https://cardstack.com/base/card-api';\nimport StringField from 'https://cardstack.com/base/string';\n\nimport UserIcon from '@cardstack/boxel-icons/user';\nimport Avatar from '../fields/avatar';\nimport AvatarCreatorComponent from './components/avatar-creator';\nimport { AvataaarsModel } from '../utils/external/avataar';\nimport { restartableTask } from 'ember-concurrency';\nimport { CreateRealImage } from '../commands/create-real-image';\n\nclass IsolatedTemplate extends Component {\n // Convert avatar field to the format expected by the component\n get avatarModel() {\n return {\n topType: this.args.model.avatar?.topType,\n accessoriesType: this.args.model.avatar?.accessoriesType,\n hairColor: this.args.model.avatar?.hairColor,\n facialHairType: this.args.model.avatar?.facialHairType,\n clotheType: this.args.model.avatar?.clotheType,\n eyeType: this.args.model.avatar?.eyeType,\n eyebrowType: this.args.model.avatar?.eyebrowType,\n mouthType: this.args.model.avatar?.mouthType,\n skinColor: this.args.model.avatar?.skinColor,\n };\n }\n\n updateAvatar = (model: AvataaarsModel) => {\n this.args.model.avatar = new Avatar(model);\n };\n\n _createRealImageTask = () => {\n this.createRealImageTask.perform();\n };\n\n private createRealImageTask = restartableTask(async () => {\n let commandContext = this.args.context?.commandContext;\n if (!commandContext) {\n throw new Error('No command context found');\n }\n\n const createRealImageCommand = new CreateRealImage(commandContext);\n\n await createRealImageCommand.execute({\n avatar: this.args.model.avatar, // Pass the Avatar field (not the plain model)\n avatarUrl: this.args.model?.cardThumbnailURL, // The thumbnailURL field is used in prompts as a reference image\n notes: this.args.model.cardInfo?.notes, // The cardInfo notes field is used in prompts as context\n });\n\n return createRealImageCommand.result;\n });\n\n get isImageGenerating() {\n return this.createRealImageTask.isRunning;\n }\n\n get generatedImage() {\n const result = this.createRealImageTask.lastSuccessful?.value;\n return result?.success && result?.imageUrl ? result.imageUrl : '';\n }\n\n get errorImageGenerating() {\n const result = this.createRealImageTask.last?.value;\n return result?.success ? '' : result?.error || '';\n }\n\n \n}\n\nexport class AvatarCreator extends CardDef {\n static displayName = 'Avatar Creator';\n static icon = UserIcon;\n static prefersWideFormat = true;\n\n @field avatar = contains(Avatar, {\n description: 'Avatar appearance configuration',\n });\n\n @field cardTitle = contains(StringField, {\n computeVia: function (this: AvatarCreator) {\n return 'Avatar';\n },\n });\n\n @field cardThumbnailURL = contains(StringField, {\n computeVia: function (this: AvatarCreator) {\n return this.avatar?.cardThumbnailURL || '';\n },\n });\n\n static isolated = IsolatedTemplate;\n}\n" - }, - { - "filename": "fields/avatar.gts", - "contents": "import {\n FieldDef,\n field,\n contains,\n Component,\n} from 'https://cardstack.com/base/card-api';\nimport StringField from 'https://cardstack.com/base/string';\nimport UserIcon from '@cardstack/boxel-icons/user';\nimport { getAvataarsUrl, AvataaarsModel } from '../utils/external/avataar';\nimport AvatarComponent from './components/avatar';\n\nclass EditTemplate extends Component {\n // Convert avatar field to the format expected by the component\n get avatarModel() {\n return {\n topType: this.args.model.topType,\n accessoriesType: this.args.model.accessoriesType,\n hairColor: this.args.model.hairColor,\n facialHairType: this.args.model.facialHairType,\n clotheType: this.args.model.clotheType,\n eyeType: this.args.model.eyeType,\n eyebrowType: this.args.model.eyebrowType,\n mouthType: this.args.model.mouthType,\n skinColor: this.args.model.skinColor,\n };\n }\n\n updateAvatar = (model: AvataaarsModel) => {\n this.args.model.topType = model.topType;\n this.args.model.accessoriesType = model.accessoriesType;\n this.args.model.hairColor = model.hairColor;\n this.args.model.facialHairType = model.facialHairType;\n this.args.model.clotheType = model.clotheType;\n this.args.model.eyeType = model.eyeType;\n this.args.model.eyebrowType = model.eyebrowType;\n this.args.model.mouthType = model.mouthType;\n this.args.model.skinColor = model.skinColor;\n };\n\n \n}\n\nexport default class Avatar extends FieldDef {\n static displayName = 'Avatar';\n static icon = UserIcon;\n\n @field topType = contains(StringField, {\n description: 'Selected hair/top style',\n });\n\n @field accessoriesType = contains(StringField, {\n description: 'Selected accessories type',\n });\n\n @field hairColor = contains(StringField, {\n description: 'Selected hair color',\n });\n\n @field facialHairType = contains(StringField, {\n description: 'Selected facial hair type',\n });\n\n @field clotheType = contains(StringField, {\n description: 'Selected clothing type',\n });\n\n @field eyeType = contains(StringField, {\n description: 'Selected eye type',\n });\n\n @field eyebrowType = contains(StringField, {\n description: 'Selected eyebrow type',\n });\n\n @field mouthType = contains(StringField, {\n description: 'Selected mouth type',\n });\n\n @field skinColor = contains(StringField, {\n description: 'Selected skin color',\n });\n\n @field cardThumbnailURL = contains(StringField, {\n computeVia: function (this: Avatar) {\n return getAvataarsUrl({\n topType: this.topType,\n accessoriesType: this.accessoriesType,\n hairColor: this.hairColor,\n facialHairType: this.facialHairType,\n clotheType: this.clotheType,\n eyeType: this.eyeType,\n eyebrowType: this.eyebrowType,\n mouthType: this.mouthType,\n skinColor: this.skinColor,\n });\n },\n });\n\n static embedded = EditTemplate;\n static edit = EditTemplate;\n}\n" - }, - { - "filename": "utils/external/avataar.gts", - "contents": "export interface AvataaarsModel {\n topType?: string;\n accessoriesType?: string;\n hairColor?: string;\n facialHairType?: string;\n clotheType?: string;\n eyeType?: string;\n eyebrowType?: string;\n mouthType?: string;\n skinColor?: string;\n}\n\nexport interface AvataaarsOption {\n value: string;\n label: string;\n}\n\nexport interface AvataaarsOptions {\n topType: AvataaarsOption[];\n hairColor: AvataaarsOption[];\n eyeType: AvataaarsOption[];\n eyebrowType: AvataaarsOption[];\n mouthType: AvataaarsOption[];\n skinColor: AvataaarsOption[];\n clotheType: AvataaarsOption[];\n}\n\n// Avataaars configuration options with comprehensive styling\nexport const AVATAAARS_OPTIONS: AvataaarsOptions = {\n topType: [\n { value: 'NoHair', label: 'Bald' },\n { value: 'Eyepatch', label: 'Eyepatch' },\n { value: 'Hat', label: 'Hat' },\n { value: 'Hijab', label: 'Hijab' },\n { value: 'Turban', label: 'Turban' },\n { value: 'WinterHat1', label: 'Winter Hat 1' },\n { value: 'WinterHat2', label: 'Winter Hat 2' },\n { value: 'WinterHat3', label: 'Winter Hat 3' },\n { value: 'WinterHat4', label: 'Winter Hat 4' },\n { value: 'LongHairBigHair', label: 'Big Hair' },\n { value: 'LongHairBob', label: 'Bob Cut' },\n { value: 'LongHairBun', label: 'Hair Bun' },\n { value: 'LongHairCurly', label: 'Curly Hair' },\n { value: 'LongHairCurvy', label: 'Curvy Hair' },\n { value: 'LongHairDreads', label: 'Dreadlocks' },\n { value: 'LongHairFro', label: 'Afro' },\n { value: 'LongHairFroBand', label: 'Afro with Band' },\n { value: 'LongHairNotTooLong', label: 'Medium Hair' },\n { value: 'LongHairShavedSides', label: 'Shaved Sides' },\n { value: 'LongHairMiaWallace', label: 'Mia Wallace' },\n { value: 'LongHairStraight', label: 'Straight Hair' },\n { value: 'LongHairStraight2', label: 'Straight Hair 2' },\n { value: 'LongHairStraightStrand', label: 'Hair Strand' },\n { value: 'ShortHairDreads01', label: 'Short Dreads 1' },\n { value: 'ShortHairDreads02', label: 'Short Dreads 2' },\n { value: 'ShortHairFrizzle', label: 'Frizzled Hair' },\n { value: 'ShortHairShaggyMullet', label: 'Shaggy Mullet' },\n { value: 'ShortHairShortCurly', label: 'Short Curly' },\n { value: 'ShortHairShortFlat', label: 'Short Flat' },\n { value: 'ShortHairShortRound', label: 'Short Round' },\n { value: 'ShortHairShortWaved', label: 'Short Waved' },\n { value: 'ShortHairSides', label: 'Hair Sides' },\n { value: 'ShortHairTheCaesar', label: 'Caesar Cut' },\n { value: 'ShortHairTheCaesarSidePart', label: 'Caesar Side Part' },\n ],\n hairColor: [\n { value: 'Auburn', label: 'Auburn' },\n { value: 'Black', label: 'Black' },\n { value: 'Blonde', label: 'Blonde' },\n { value: 'BlondeGolden', label: 'Golden Blonde' },\n { value: 'Brown', label: 'Brown' },\n { value: 'BrownDark', label: 'Dark Brown' },\n { value: 'PastelPink', label: 'Pastel Pink' },\n { value: 'Blue', label: 'Blue' },\n { value: 'Platinum', label: 'Platinum' },\n { value: 'Red', label: 'Red' },\n { value: 'SilverGray', label: 'Silver Gray' },\n ],\n eyeType: [\n { value: 'Close', label: 'Closed' },\n { value: 'Cry', label: 'Crying' },\n { value: 'Default', label: 'Default' },\n { value: 'Dizzy', label: 'Dizzy' },\n { value: 'EyeRoll', label: 'Eye Roll' },\n { value: 'Happy', label: 'Happy' },\n { value: 'Hearts', label: 'Hearts' },\n { value: 'Side', label: 'Side Glance' },\n { value: 'Squint', label: 'Squint' },\n { value: 'Surprised', label: 'Surprised' },\n { value: 'Wink', label: 'Wink' },\n { value: 'WinkWacky', label: 'Wacky Wink' },\n ],\n eyebrowType: [\n { value: 'Angry', label: 'Angry' },\n { value: 'AngryNatural', label: 'Angry Natural' },\n { value: 'Default', label: 'Default' },\n { value: 'DefaultNatural', label: 'Default Natural' },\n { value: 'FlatNatural', label: 'Flat Natural' },\n { value: 'RaisedExcited', label: 'Raised Excited' },\n { value: 'RaisedExcitedNatural', label: 'Raised Excited Natural' },\n { value: 'SadConcerned', label: 'Sad Concerned' },\n { value: 'SadConcernedNatural', label: 'Sad Concerned Natural' },\n { value: 'UnibrowNatural', label: 'Unibrow Natural' },\n { value: 'UpDown', label: 'Up Down' },\n { value: 'UpDownNatural', label: 'Up Down Natural' },\n ],\n mouthType: [\n { value: 'Concerned', label: 'Concerned' },\n { value: 'Default', label: 'Default' },\n { value: 'Disbelief', label: 'Disbelief' },\n { value: 'Eating', label: 'Eating' },\n { value: 'Grimace', label: 'Grimace' },\n { value: 'Sad', label: 'Sad' },\n { value: 'ScreamOpen', label: 'Scream Open' },\n { value: 'Serious', label: 'Serious' },\n { value: 'Smile', label: 'Smile' },\n { value: 'Tongue', label: 'Tongue Out' },\n { value: 'Twinkle', label: 'Twinkle' },\n { value: 'Vomit', label: 'Vomit' },\n ],\n skinColor: [\n { value: 'Tanned', label: 'Tanned' },\n { value: 'Yellow', label: 'Yellow' },\n { value: 'Pale', label: 'Pale' },\n { value: 'Light', label: 'Light' },\n { value: 'Brown', label: 'Brown' },\n { value: 'DarkBrown', label: 'Dark Brown' },\n { value: 'Black', label: 'Black' },\n ],\n clotheType: [\n { value: 'BlazerShirt', label: 'Blazer & Shirt' },\n { value: 'BlazerSweater', label: 'Blazer & Sweater' },\n { value: 'CollarSweater', label: 'Collar Sweater' },\n { value: 'GraphicShirt', label: 'Graphic Shirt' },\n { value: 'Hoodie', label: 'Hoodie' },\n { value: 'Overall', label: 'Overall' },\n { value: 'ShirtCrewNeck', label: 'Crew Neck Shirt' },\n { value: 'ShirtScoopNeck', label: 'Scoop Neck Shirt' },\n { value: 'ShirtVNeck', label: 'V-Neck Shirt' },\n ],\n};\n\n// = \nexport const CATEGORY_MAP: Record = {\n hair: 'topType',\n eyes: 'eyeType',\n eyebrows: 'eyebrowType',\n mouth: 'mouthType',\n skinTone: 'skinColor',\n clothes: 'clotheType',\n hairColor: 'hairColor',\n};\n\n// Default avatar values\nexport const DEFAULT_AVATAR_VALUES: Required = {\n topType: 'ShortHairShortFlat',\n accessoriesType: 'Blank',\n hairColor: 'Platinum',\n facialHairType: 'Blank',\n clotheType: 'BlazerShirt',\n eyeType: 'Default',\n eyebrowType: 'Default',\n mouthType: 'Default',\n skinColor: 'Light',\n};\n\n// Predefined avatar sets for quick selection\nexport const PRESET_AVATAR_SETS: {\n name: string;\n model: Required;\n}[] = [\n {\n name: 'Professional',\n model: {\n topType: 'ShortHairShortFlat',\n accessoriesType: 'Blank',\n hairColor: 'BrownDark',\n facialHairType: 'Blank',\n clotheType: 'BlazerShirt',\n eyeType: 'Default',\n eyebrowType: 'Default',\n mouthType: 'Smile',\n skinColor: 'Light',\n },\n },\n {\n name: 'Creative Artist',\n model: {\n topType: 'LongHairCurly',\n accessoriesType: 'Blank',\n hairColor: 'PastelPink',\n facialHairType: 'Blank',\n clotheType: 'GraphicShirt',\n eyeType: 'Happy',\n eyebrowType: 'RaisedExcited',\n mouthType: 'Smile',\n skinColor: 'Tanned',\n },\n },\n {\n name: 'Cool Dude',\n model: {\n topType: 'ShortHairDreads01',\n accessoriesType: 'Blank',\n hairColor: 'Black',\n facialHairType: 'Blank',\n clotheType: 'Hoodie',\n eyeType: 'Squint',\n eyebrowType: 'Default',\n mouthType: 'Serious',\n skinColor: 'DarkBrown',\n },\n },\n {\n name: 'Friendly Teacher',\n model: {\n topType: 'LongHairBob',\n accessoriesType: 'Blank',\n hairColor: 'Blonde',\n facialHairType: 'Blank',\n clotheType: 'CollarSweater',\n eyeType: 'Happy',\n eyebrowType: 'Default',\n mouthType: 'Smile',\n skinColor: 'Light',\n },\n },\n {\n name: 'Tech Enthusiast',\n model: {\n topType: 'ShortHairShortRound',\n accessoriesType: 'Blank',\n hairColor: 'Brown',\n facialHairType: 'Blank',\n clotheType: 'ShirtCrewNeck',\n eyeType: 'Default',\n eyebrowType: 'Default',\n mouthType: 'Default',\n skinColor: 'Pale',\n },\n },\n {\n name: 'Adventurous Spirit',\n model: {\n topType: 'LongHairStraight',\n accessoriesType: 'Blank',\n hairColor: 'Auburn',\n facialHairType: 'Blank',\n clotheType: 'Overall',\n eyeType: 'Surprised',\n eyebrowType: 'RaisedExcited',\n mouthType: 'Twinkle',\n skinColor: 'Brown',\n },\n },\n {\n name: 'Wise Mentor',\n model: {\n topType: 'ShortHairTheCaesar',\n accessoriesType: 'Blank',\n hairColor: 'SilverGray',\n facialHairType: 'Blank',\n clotheType: 'BlazerSweater',\n eyeType: 'Default',\n eyebrowType: 'Default',\n mouthType: 'Serious',\n skinColor: 'Light',\n },\n },\n {\n name: 'Cheerful Friend',\n model: {\n topType: 'LongHairFro',\n accessoriesType: 'Blank',\n hairColor: 'Black',\n facialHairType: 'Blank',\n clotheType: 'ShirtVNeck',\n eyeType: 'Happy',\n eyebrowType: 'Default',\n mouthType: 'Smile',\n skinColor: 'Black',\n },\n },\n];\n\n/**\n * Generates the Avataaars URL for a given avatar model\n */\nexport function getAvataarsUrl(model: AvataaarsModel): string {\n const {\n topType,\n accessoriesType,\n hairColor,\n facialHairType,\n clotheType,\n eyeType,\n eyebrowType,\n mouthType,\n skinColor,\n } = model;\n\n const params = [\n `topType=${encodeURIComponent(topType || DEFAULT_AVATAR_VALUES.topType)}`,\n `accessoriesType=${encodeURIComponent(accessoriesType || DEFAULT_AVATAR_VALUES.accessoriesType)}`,\n `hairColor=${encodeURIComponent(hairColor || DEFAULT_AVATAR_VALUES.hairColor)}`,\n `facialHairType=${encodeURIComponent(facialHairType || DEFAULT_AVATAR_VALUES.facialHairType)}`,\n `clotheType=${encodeURIComponent(clotheType || DEFAULT_AVATAR_VALUES.clotheType)}`,\n `eyeType=${encodeURIComponent(eyeType || DEFAULT_AVATAR_VALUES.eyeType)}`,\n `eyebrowType=${encodeURIComponent(eyebrowType || DEFAULT_AVATAR_VALUES.eyebrowType)}`,\n `mouthType=${encodeURIComponent(mouthType || DEFAULT_AVATAR_VALUES.mouthType)}`,\n `skinColor=${encodeURIComponent(skinColor || DEFAULT_AVATAR_VALUES.skinColor)}`,\n ];\n\n return `https://avataaars.io/?${params.join('&')}`;\n}\n\n/**\n * Generates a random avatar by selecting random options from each category\n */\nexport function generateRandomAvatarModel(): AvataaarsModel {\n const randomHair =\n AVATAAARS_OPTIONS.topType[\n Math.floor(Math.random() * AVATAAARS_OPTIONS.topType.length)\n ];\n const randomHairColor =\n AVATAAARS_OPTIONS.hairColor[\n Math.floor(Math.random() * AVATAAARS_OPTIONS.hairColor.length)\n ];\n const randomEyes =\n AVATAAARS_OPTIONS.eyeType[\n Math.floor(Math.random() * AVATAAARS_OPTIONS.eyeType.length)\n ];\n const randomEyebrows =\n AVATAAARS_OPTIONS.eyebrowType[\n Math.floor(Math.random() * AVATAAARS_OPTIONS.eyebrowType.length)\n ];\n const randomMouth =\n AVATAAARS_OPTIONS.mouthType[\n Math.floor(Math.random() * AVATAAARS_OPTIONS.mouthType.length)\n ];\n const randomSkinTone =\n AVATAAARS_OPTIONS.skinColor[\n Math.floor(Math.random() * AVATAAARS_OPTIONS.skinColor.length)\n ];\n const randomClothes =\n AVATAAARS_OPTIONS.clotheType[\n Math.floor(Math.random() * AVATAAARS_OPTIONS.clotheType.length)\n ];\n\n return {\n topType: randomHair.value,\n accessoriesType: DEFAULT_AVATAR_VALUES.accessoriesType,\n hairColor: randomHairColor.value,\n facialHairType: DEFAULT_AVATAR_VALUES.facialHairType,\n clotheType: randomClothes.value,\n eyeType: randomEyes.value,\n eyebrowType: randomEyebrows.value,\n mouthType: randomMouth.value,\n skinColor: randomSkinTone.value,\n };\n}\n\n/**\n * Gets the options for a specific category\n */\nexport function getCategoryOptions(category: string) {\n const paramName = CATEGORY_MAP[category];\n return AVATAAARS_OPTIONS[paramName] || [];\n}\n\n/**\n * Creates a preview URL for an avatar option by applying it to a base model\n */\nexport function getOptionPreviewUrl(\n baseModel: AvataaarsModel,\n category: string,\n optionValue: string,\n): string {\n const previewModel = { ...baseModel };\n\n switch (category) {\n case 'hair':\n previewModel.topType = optionValue;\n break;\n case 'hairColor':\n previewModel.hairColor = optionValue;\n break;\n case 'eyes':\n previewModel.eyeType = optionValue;\n break;\n case 'eyebrows':\n previewModel.eyebrowType = optionValue;\n break;\n case 'mouth':\n previewModel.mouthType = optionValue;\n break;\n case 'skinTone':\n previewModel.skinColor = optionValue;\n break;\n case 'clothes':\n previewModel.clotheType = optionValue;\n break;\n }\n\n return getAvataarsUrl(previewModel);\n}\n\n/**\n * Gets the current selection value for a category from an avatar model\n */\nexport function getCurrentSelectionForCategory(\n model: AvataaarsModel,\n category: string,\n): string | undefined {\n switch (category) {\n case 'hair':\n return model.topType;\n case 'hairColor':\n return model.hairColor;\n case 'eyes':\n return model.eyeType;\n case 'eyebrows':\n return model.eyebrowType;\n case 'mouth':\n return model.mouthType;\n case 'skinTone':\n return model.skinColor;\n case 'clothes':\n return model.clotheType;\n default:\n return undefined;\n }\n}\n\n/**\n * Updates an avatar model with a new option value for a specific category\n */\nexport function updateAvatarModelForCategory(\n model: AvataaarsModel,\n category: string,\n optionValue: string,\n): AvataaarsModel {\n const updatedModel = { ...model };\n\n switch (category) {\n case 'hair':\n updatedModel.topType = optionValue;\n break;\n case 'hairColor':\n updatedModel.hairColor = optionValue;\n break;\n case 'eyes':\n updatedModel.eyeType = optionValue;\n break;\n case 'eyebrows':\n updatedModel.eyebrowType = optionValue;\n break;\n case 'mouth':\n updatedModel.mouthType = optionValue;\n break;\n case 'skinTone':\n updatedModel.skinColor = optionValue;\n break;\n case 'clothes':\n updatedModel.clotheType = optionValue;\n break;\n }\n\n return updatedModel;\n}\n\n/**\n * Creates a click sound using Web Audio API\n */\nexport function playClickSound(): void {\n try {\n const audioContext = new (window.AudioContext ||\n (window as any).webkitAudioContext)();\n\n // Create oscillator for the click sound\n const oscillator = audioContext.createOscillator();\n const gainNode = audioContext.createGain();\n\n // Connect nodes\n oscillator.connect(gainNode);\n gainNode.connect(audioContext.destination);\n\n // Configure the sound - a short, crisp click\n oscillator.frequency.setValueAtTime(800, audioContext.currentTime); // High frequency for crisp sound\n oscillator.frequency.exponentialRampToValueAtTime(\n 400,\n audioContext.currentTime + 0.1,\n );\n\n // Set volume envelope for a quick click\n gainNode.gain.setValueAtTime(0, audioContext.currentTime);\n gainNode.gain.linearRampToValueAtTime(0.3, audioContext.currentTime + 0.01); // Quick attack\n gainNode.gain.exponentialRampToValueAtTime(\n 0.01,\n audioContext.currentTime + 0.1,\n ); // Quick decay\n\n // Play the sound\n oscillator.start(audioContext.currentTime);\n oscillator.stop(audioContext.currentTime + 0.1);\n } catch (error) {\n console.error('Audio not supported or failed:', error);\n }\n}\n\n/**\n * Interface for createRealImage function parameters\n */\nexport interface CreateRealParams {\n avatar: AvataaarsModel;\n avatarUrl?: string;\n cardInfo?: {\n notes?: string;\n };\n sendRequestCommand: {\n execute: (input: {\n url: string;\n method: string;\n requestBody: string;\n headers?: Record;\n }) => Promise<{\n response: Response;\n }>;\n };\n}\n\n/**\n * Interface for createRealImage result\n */\nexport interface CreateRealResult {\n success: boolean;\n imageUrl?: string;\n error?: string;\n}\n\n/**\n * Builds AI interpretation cues based on avatar configuration\n */\nexport function buildAICues(avatarModel: AvataaarsModel): string {\n const cuesList = [];\n\n // Check mouth type\n if (avatarModel.mouthType === 'Grimace') {\n cuesList.push('- Grimace should show teeth with a stretched mouth');\n }\n if (avatarModel.mouthType === 'Vomit') {\n cuesList.push(\n '- Vomit should be pretending to vomit, as if seeing something revolting',\n );\n }\n\n // Check hair/top type\n if (avatarModel.topType === 'WinterHat1') {\n cuesList.push('- Winter Hat 1 has sides that covers ears and cheeks');\n }\n if (avatarModel.topType === 'WinterHat2') {\n cuesList.push('- Winter Hat 2 is knit');\n }\n if (avatarModel.topType === 'WinterHat3') {\n cuesList.push('- Winter Hat 3 is a beanie');\n }\n if (avatarModel.topType === 'WinterHat4') {\n cuesList.push('- Winter Hat 4 is a Christmas hat');\n }\n if (avatarModel.topType === 'NoHair') {\n cuesList.push('- nohair is bald');\n }\n if (avatarModel.topType === 'ShortHairSides') {\n cuesList.push(\n '- ShortHairSides person should be 90% bald with male pattern baldness',\n );\n }\n\n // Check eye type\n if (avatarModel.eyeType === 'Hearts') {\n cuesList.push(\n \"- hearts eye: don't draw hearts, just make their eyes big and doe-y with affection and attraction\",\n );\n }\n if (avatarModel.eyeType === 'Dizzy') {\n cuesList.push('- dizzy eye should be an overall emotion');\n }\n\n return cuesList.length > 0\n ? '\\n\\nAI Interpretation Cues:\\n' + cuesList.join('\\n')\n : '';\n}\n" - }, - { - "filename": "fields/components/avatar.gts", - "contents": "import { fn } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport { eq, gt } from '@cardstack/boxel-ui/helpers';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { TrackedMap } from 'tracked-built-ins';\nimport { task } from 'ember-concurrency';\nimport {\n FilterList,\n BoxelButton,\n BoxelInput,\n} from '@cardstack/boxel-ui/components';\n\nimport {\n AvataaarsModel,\n DEFAULT_AVATAR_VALUES,\n CATEGORY_MAP,\n PRESET_AVATAR_SETS,\n getAvataarsUrl,\n generateRandomAvatarModel,\n getCategoryOptions,\n getOptionPreviewUrl,\n getCurrentSelectionForCategory,\n updateAvatarModelForCategory,\n playClickSound,\n} from '../../utils/external/avataar';\n\nimport { SuggestAvatar } from '../../commands/suggest-avatar';\n\ninterface AvatarCreatorArgs {\n model: AvataaarsModel;\n context?: any;\n onUpdate?: (model: AvataaarsModel) => void;\n}\n\nexport default class AvatarComponent extends Component {\n @tracked selectedCategory = 'hair';\n @tracked activeFilter: any = null;\n @tracked copySuccess = false;\n @tracked currentMode: 'presets' | 'customized' = 'presets';\n\n // Store filter objects to maintain reference equality\n private presetFilter = {\n displayName: 'Presets',\n mode: 'presets' as const,\n };\n\n private customizedFilter = {\n displayName: 'Customized',\n filters: [] as any[], // Will be populated in getter\n isExpanded: false,\n };\n\n constructor(owner: any, args: AvatarCreatorArgs) {\n super(owner, args);\n // Set initial active filter to Presets\n this.activeFilter = this.presetFilter;\n }\n\n // Generate categories from CATEGORY_MAP to keep things DRY\n get categories() {\n const categoryLabels: Record = {\n hair: 'Hair Style',\n hairColor: 'Hair Color',\n eyes: 'Eyes',\n eyebrows: 'Eyebrows',\n mouth: 'Mouth',\n skinTone: 'Skin Tone',\n clothes: 'Clothes',\n };\n\n return Object.keys(CATEGORY_MAP).map((key) => ({\n key,\n label: categoryLabels[key] || key,\n }));\n }\n\n // Store category filter objects to maintain reference equality\n private _categoryFilters = new Map();\n\n private getCategoryFilter(category: { key: string; label: string }) {\n if (!this._categoryFilters.has(category.key)) {\n this._categoryFilters.set(category.key, {\n displayName: category.label,\n categoryKey: category.key,\n mode: 'customized' as const,\n });\n }\n return this._categoryFilters.get(category.key);\n }\n\n // Transform into FilterList format with Presets and Customized\n get avatarFilters() {\n // Custom category filters with stable references\n const categoryFilters = this.categories.map((category) =>\n this.getCategoryFilter(category),\n );\n\n // Update the customized filter with current state\n this.customizedFilter.filters = categoryFilters;\n this.customizedFilter.isExpanded = this.currentMode === 'customized';\n\n return [this.presetFilter, this.customizedFilter];\n }\n\n // Internal mutable avatar state using TrackedMap\n @tracked currentModel = new TrackedMap([\n ['topType', this.args.model?.topType || DEFAULT_AVATAR_VALUES.topType],\n [\n 'accessoriesType',\n this.args.model?.accessoriesType || DEFAULT_AVATAR_VALUES.accessoriesType,\n ],\n [\n 'hairColor',\n this.args.model?.hairColor || DEFAULT_AVATAR_VALUES.hairColor,\n ],\n [\n 'facialHairType',\n this.args.model?.facialHairType || DEFAULT_AVATAR_VALUES.facialHairType,\n ],\n [\n 'clotheType',\n this.args.model?.clotheType || DEFAULT_AVATAR_VALUES.clotheType,\n ],\n ['eyeType', this.args.model?.eyeType || DEFAULT_AVATAR_VALUES.eyeType],\n [\n 'eyebrowType',\n this.args.model?.eyebrowType || DEFAULT_AVATAR_VALUES.eyebrowType,\n ],\n [\n 'mouthType',\n this.args.model?.mouthType || DEFAULT_AVATAR_VALUES.mouthType,\n ],\n [\n 'skinColor',\n this.args.model?.skinColor || DEFAULT_AVATAR_VALUES.skinColor,\n ],\n ]);\n\n // Get Avataaars URL for the image\n get avataaarsUrl() {\n // Convert TrackedMap to object for getAvataarsUrl function\n const modelObj = Object.fromEntries(this.currentModel.entries());\n return getAvataarsUrl(modelObj as AvataaarsModel);\n }\n\n get currentCategoryOptions() {\n return getCategoryOptions(this.selectedCategory);\n }\n\n // Get preset avatar sets with their URLs for display\n get presetAvatarOptions() {\n return PRESET_AVATAR_SETS.map((avatarSet) => ({\n name: avatarSet.name,\n url: getAvataarsUrl(avatarSet.model),\n model: avatarSet.model,\n }));\n }\n\n onFilterChanged = (filter: any) => {\n // Handle presets selection\n if (filter.mode === 'presets') {\n this.currentMode = 'presets';\n this.activeFilter = this.presetFilter;\n }\n // Handle customized category filter selection\n else if (filter.mode === 'customized' && filter.categoryKey) {\n this.currentMode = 'customized';\n this.selectedCategory = filter.categoryKey;\n this.activeFilter = filter; // This should now be from our cached objects\n }\n // Handle customized parent selection (expand/collapse)\n else if (filter.displayName === 'Customized') {\n // Don't change activeFilter for the parent, just toggle expansion\n this.currentMode = 'customized';\n }\n };\n\n generateRandomAvatar = () => {\n // Play click sound\n playClickSound();\n\n // Generate random avatar using the utility function\n const randomAvatar = generateRandomAvatarModel();\n\n // Apply random selections to internal state - reassign entire TrackedMap\n this.currentModel = new TrackedMap(Object.entries(randomAvatar));\n\n // Notify parent component of the change\n this.args.onUpdate?.(randomAvatar);\n };\n\n selectAvataaarsOption = (option: { value: string; label: string }) => {\n // Get current model as object\n const currentModelObj = Object.fromEntries(\n this.currentModel.entries(),\n ) as AvataaarsModel;\n\n // Update using the utility function\n const updatedModel = updateAvatarModelForCategory(\n currentModelObj,\n this.selectedCategory,\n option.value,\n );\n\n // Update internal state\n this.currentModel = new TrackedMap(Object.entries(updatedModel));\n\n // Notify parent component of the change\n this.args.onUpdate?.(updatedModel);\n };\n\n copyAvataaarsUrl = () => {\n try {\n // Play click sound\n playClickSound();\n navigator.clipboard.writeText(this.avataaarsUrl);\n this.copySuccess = true;\n // Reset success state after 2 seconds\n setTimeout(() => {\n this.copySuccess = false;\n }, 2000);\n } catch (error) {\n console.error('Failed to copy URL:', error);\n }\n };\n\n // Generate preview URL for each option\n getOptionPreviewUrl = (option: { value: string; label: string }) => {\n const currentModelObj = Object.fromEntries(\n this.currentModel.entries(),\n ) as AvataaarsModel;\n return getOptionPreviewUrl(\n currentModelObj,\n this.selectedCategory,\n option.value,\n );\n };\n\n // Getters for template use - these properly track TrackedMap changes\n get topType() {\n return this.currentModel.get('topType');\n }\n\n get hairColor() {\n return this.currentModel.get('hairColor');\n }\n\n get mouthType() {\n return this.currentModel.get('mouthType');\n }\n\n get skinColor() {\n return this.currentModel.get('skinColor');\n }\n\n get eyeType() {\n return this.currentModel.get('eyeType');\n }\n\n get eyebrowType() {\n return this.currentModel.get('eyebrowType');\n }\n\n get clotheType() {\n return this.currentModel.get('clotheType');\n }\n\n get currentSelection() {\n try {\n const currentModelObj = Object.fromEntries(\n this.currentModel.entries(),\n ) as AvataaarsModel;\n return getCurrentSelectionForCategory(\n currentModelObj,\n this.selectedCategory,\n );\n } catch (error) {\n console.warn('Error getting current selection:', error);\n return null;\n }\n }\n\n _suggestAvatar = task(async () => {\n try {\n let commandContext = this.args.context?.commandContext;\n if (!commandContext) {\n throw new Error(\n 'Command context does not exist. Please switch to Interact Mode',\n );\n }\n\n let suggestCommand = new SuggestAvatar(commandContext);\n await suggestCommand.execute({\n name: 'Avatar',\n });\n } catch (error) {\n console.error('Error suggesting avatar:', error);\n alert('There was an error getting avatar suggestions. Please try again.');\n }\n });\n\n suggestAvatar = () => {\n this._suggestAvatar.perform();\n };\n\n isOptionSelected = (option: { value: string; label: string }) => {\n return this.currentSelection === option.value;\n };\n\n selectPresetAvatar = (avatarOption: any) => {\n playClickSound();\n // Apply the selected preset avatar\n this.currentModel = new TrackedMap(Object.entries(avatarOption.model));\n this.args.onUpdate?.(avatarOption.model);\n };\n\n // Check if a preset avatar is currently selected\n isPresetSelected = (avatarOption: any) => {\n const currentModelObj = Object.fromEntries(this.currentModel.entries());\n\n // Compare all avatar properties to see if this preset matches current state\n return (\n currentModelObj.topType === avatarOption.model.topType &&\n currentModelObj.accessoriesType === avatarOption.model.accessoriesType &&\n currentModelObj.hairColor === avatarOption.model.hairColor &&\n currentModelObj.facialHairType === avatarOption.model.facialHairType &&\n currentModelObj.clotheType === avatarOption.model.clotheType &&\n currentModelObj.eyeType === avatarOption.model.eyeType &&\n currentModelObj.eyebrowType === avatarOption.model.eyebrowType &&\n currentModelObj.mouthType === avatarOption.model.mouthType &&\n currentModelObj.skinColor === avatarOption.model.skinColor\n );\n };\n\n \n}\n" - }, - { - "filename": "commands/suggest-avatar.gts", - "contents": "import { CardDef, field, contains } from 'https://cardstack.com/base/card-api';\nimport StringField from 'https://cardstack.com/base/string';\nimport UseAiAssistantCommand from '@cardstack/boxel-host/commands/ai-assistant';\nimport SetActiveLLMCommand from '@cardstack/boxel-host/commands/set-active-llm';\nimport { Command, DEFAULT_CODING_LLM } from '@cardstack/runtime-common';\n\nclass SuggestAvatarInput extends CardDef {\n @field name = contains(StringField, {\n description: 'Name to use for the avatar suggestion room',\n });\n}\n\nexport class SuggestAvatar extends Command<\n typeof SuggestAvatarInput,\n undefined\n> {\n static actionVerb = 'Generate';\n static displayName = 'Suggest Avatar';\n\n async getInputType() {\n return SuggestAvatarInput;\n }\n\n protected async run(input: SuggestAvatarInput): Promise {\n let { name } = input;\n\n let skillCardId = new URL('../Skill/avatar-suggestion', import.meta.url)\n .href;\n\n try {\n let useAiAssistantCommand = new UseAiAssistantCommand(\n this.commandContext,\n );\n let result = await useAiAssistantCommand.execute({\n roomName: `Avatar Suggestions: ${name || 'Unnamed Avatar'}`,\n openRoom: true,\n prompt: `Please suggest two example avatar prompts: one describing a visual style and one referencing a celebrity's look.`,\n skillCardIds: [skillCardId],\n llmModel: DEFAULT_CODING_LLM,\n });\n\n if (result.roomId) {\n let setActiveLLMCommand = new SetActiveLLMCommand(this.commandContext);\n await setActiveLLMCommand.execute({\n roomId: result.roomId,\n mode: 'ask',\n });\n }\n } catch (error: any) {\n throw new Error(`❌ Failed to suggest avatar: ${error.message}`);\n }\n }\n}\n" - }, - { - "filename": "avatar-creator/components/avatar-creator.gts", - "contents": "import { fn } from '@ember/helper';\nimport { on } from '@ember/modifier';\nimport { eq, gt } from '@cardstack/boxel-ui/helpers';\nimport Component from '@glimmer/component';\nimport { tracked } from '@glimmer/tracking';\nimport { TrackedMap } from 'tracked-built-ins';\nimport { task } from 'ember-concurrency';\nimport { BoxelButton, BoxelInput } from '@cardstack/boxel-ui/components';\n\nimport {\n AvataaarsModel,\n DEFAULT_AVATAR_VALUES,\n getAvataarsUrl,\n generateRandomAvatarModel,\n getCategoryOptions,\n getOptionPreviewUrl,\n getCurrentSelectionForCategory,\n updateAvatarModelForCategory,\n playClickSound,\n} from '../../utils/external/avataar';\n\nimport { SuggestAvatar } from '../../commands/suggest-avatar';\n\ninterface AvatarCreatorArgs {\n model: AvataaarsModel;\n context?: any;\n onUpdate?: (model: AvataaarsModel) => void;\n isImageGenerating?: boolean;\n generatedImage?: string;\n errorImageGenerating?: string;\n onCreateRealImage?: () => void;\n}\n\nexport default class AvatarCreatorComponent extends Component {\n @tracked selectedCategory = 'hair';\n @tracked copySuccess = false;\n\n // Internal mutable avatar state using TrackedMap\n @tracked currentModel = new TrackedMap([\n ['topType', this.args.model?.topType || DEFAULT_AVATAR_VALUES.topType],\n [\n 'accessoriesType',\n this.args.model?.accessoriesType || DEFAULT_AVATAR_VALUES.accessoriesType,\n ],\n [\n 'hairColor',\n this.args.model?.hairColor || DEFAULT_AVATAR_VALUES.hairColor,\n ],\n [\n 'facialHairType',\n this.args.model?.facialHairType || DEFAULT_AVATAR_VALUES.facialHairType,\n ],\n [\n 'clotheType',\n this.args.model?.clotheType || DEFAULT_AVATAR_VALUES.clotheType,\n ],\n ['eyeType', this.args.model?.eyeType || DEFAULT_AVATAR_VALUES.eyeType],\n [\n 'eyebrowType',\n this.args.model?.eyebrowType || DEFAULT_AVATAR_VALUES.eyebrowType,\n ],\n [\n 'mouthType',\n this.args.model?.mouthType || DEFAULT_AVATAR_VALUES.mouthType,\n ],\n [\n 'skinColor',\n this.args.model?.skinColor || DEFAULT_AVATAR_VALUES.skinColor,\n ],\n ]);\n\n // Get Avataaars URL for the image\n get avataaarsUrl() {\n // Convert TrackedMap to object for getAvataarsUrl function\n const modelObj = Object.fromEntries(this.currentModel.entries());\n return getAvataarsUrl(modelObj as AvataaarsModel);\n }\n\n get currentCategoryOptions() {\n return getCategoryOptions(this.selectedCategory);\n }\n\n selectCategory = (category: string) => {\n this.selectedCategory = category;\n };\n\n generateRandomAvatar = () => {\n // Play click sound\n playClickSound();\n\n // Generate random avatar using the utility function\n const randomAvatar = generateRandomAvatarModel();\n\n // Apply random selections to internal state - reassign entire TrackedMap\n this.currentModel = new TrackedMap(Object.entries(randomAvatar));\n\n // Notify parent component of the change\n this.args.onUpdate?.(randomAvatar);\n };\n\n selectAvataaarsOption = (option: { value: string; label: string }) => {\n // Get current model as object\n const currentModelObj = Object.fromEntries(\n this.currentModel.entries(),\n ) as AvataaarsModel;\n\n // Update using the utility function\n const updatedModel = updateAvatarModelForCategory(\n currentModelObj,\n this.selectedCategory,\n option.value,\n );\n\n // Update internal state\n this.currentModel = new TrackedMap(Object.entries(updatedModel));\n\n // Notify parent component of the change\n this.args.onUpdate?.(updatedModel);\n };\n\n copyAvataaarsUrl = () => {\n try {\n // Play click sound\n playClickSound();\n navigator.clipboard.writeText(this.avataaarsUrl);\n this.copySuccess = true;\n // Reset success state after 2 seconds\n setTimeout(() => {\n this.copySuccess = false;\n }, 2000);\n } catch (error) {\n console.error('Failed to copy URL:', error);\n }\n };\n\n // Generate preview URL for each option\n getOptionPreviewUrl = (option: { value: string; label: string }) => {\n const currentModelObj = Object.fromEntries(\n this.currentModel.entries(),\n ) as AvataaarsModel;\n return getOptionPreviewUrl(\n currentModelObj,\n this.selectedCategory,\n option.value,\n );\n };\n\n // Getters for template use - these properly track TrackedMap changes\n get topType() {\n return this.currentModel.get('topType');\n }\n\n get hairColor() {\n return this.currentModel.get('hairColor');\n }\n\n get mouthType() {\n return this.currentModel.get('mouthType');\n }\n\n get skinColor() {\n return this.currentModel.get('skinColor');\n }\n\n get eyeType() {\n return this.currentModel.get('eyeType');\n }\n\n get eyebrowType() {\n return this.currentModel.get('eyebrowType');\n }\n\n get clotheType() {\n return this.currentModel.get('clotheType');\n }\n\n get currentSelection() {\n try {\n const currentModelObj = Object.fromEntries(\n this.currentModel.entries(),\n ) as AvataaarsModel;\n return getCurrentSelectionForCategory(\n currentModelObj,\n this.selectedCategory,\n );\n } catch (error) {\n console.warn('Error getting current selection:', error);\n return null;\n }\n }\n\n _suggestAvatar = task(async () => {\n try {\n let commandContext = this.args.context?.commandContext;\n if (!commandContext) {\n throw new Error(\n 'Command context does not exist. Please switch to Interact Mode',\n );\n }\n\n let suggestCommand = new SuggestAvatar(commandContext);\n await suggestCommand.execute({\n name: 'Avatar',\n });\n } catch (error) {\n console.error('Error suggesting avatar:', error);\n alert('There was an error getting avatar suggestions. Please try again.');\n }\n });\n\n suggestAvatar = () => {\n this._suggestAvatar.perform();\n };\n\n isOptionSelected = (option: { value: string; label: string }) => {\n return this.currentSelection === option.value;\n };\n\n \n}\n" - }, - { - "filename": "commands/create-real-image.gts", - "contents": "import { CardDef, field, contains } from 'https://cardstack.com/base/card-api';\nimport StringField from 'https://cardstack.com/base/string';\n\nimport { Command } from '@cardstack/runtime-common';\nimport SendRequestViaProxyCommand from '@cardstack/boxel-host/commands/send-request-via-proxy';\n\nimport { buildAICues } from '../utils/external/avataar';\nimport Avatar from '../fields/avatar';\n\nclass CreateRealImageInput extends CardDef {\n @field avatar = contains(Avatar, {\n description: 'Avatar model configuration',\n });\n\n @field avatarUrl = contains(StringField, {\n description: 'URL of the avatar image to use as reference',\n });\n\n @field notes = contains(StringField, {\n description: 'Optional notes string used for schema/context',\n });\n}\n\nexport class CreateRealImage extends Command<\n typeof CreateRealImageInput,\n undefined\n> {\n static actionVerb = 'Generate';\n static displayName = 'Create Real Image';\n\n result?: { success: boolean; imageUrl?: string; error?: string };\n\n async getInputType() {\n return CreateRealImageInput;\n }\n\n protected async run(input: CreateRealImageInput): Promise {\n const { avatar, avatarUrl, notes } = input;\n\n if (!avatarUrl) {\n throw new Error('No avatar URL available');\n }\n\n const aiCues = buildAICues(avatar);\n const configSchema = notes || '';\n\n const prompt = `Imagine this avatar as a beloved main character in a modern, live-action, TV-14 series that appeals to both kids and adults. Render a realistic, high-quality headshot portrait (1:1 aspect ratio, facing forward) as if photographed with a Sony A74 and professionally retouched for a poster.\n\n - Guess and visually express: age, sex, location, time of year, religion, race/ethnicity, mood (be authentic—even unusual emotions are welcome), and current situation, based on the avatar's features. Ensure broad and inclusive representation.\n - Exaggerate emotions as a talented actor would.\n - Highlight subtle details as a skilled makeup artist would: e.g., eyes open or closed, tongue, hair color, etc.\n - Any special effects (SFX) should be photorealistic and seamlessly composited.\n - Use a natural, unobtrusive background. No borders or text.\n\n ${aiCues}\n\n IMPORTANT: Only use valid avatar configuration values. Reference this schema for accurate interpretations:\n\n ${configSchema}\n\n ${avatarUrl}`;\n\n // Send the request via host proxy\n const sendRequestCommand = new SendRequestViaProxyCommand(\n this.commandContext,\n );\n const result = await sendRequestCommand.execute({\n url: 'https://openrouter.ai/api/v1/chat/completions',\n method: 'POST',\n requestBody: JSON.stringify({\n model: 'google/gemini-2.5-flash-image-preview',\n messages: [\n {\n role: 'user',\n content: prompt,\n },\n ],\n }),\n });\n\n if (!result.response.ok) {\n this.result = {\n success: false,\n error: `Failed to make request: ${result.response.statusText}`,\n };\n return;\n }\n\n try {\n const responseData = await result.response.json();\n if (responseData.error) {\n const errorMsg = responseData.error.message || responseData.error;\n this.result = { success: false, error: `API Error: ${errorMsg}` };\n return;\n }\n\n const messageContent = responseData.choices?.[0]?.message;\n const images = messageContent?.images;\n if (!Array.isArray(images) || images.length === 0) {\n this.result = { success: false, error: 'No images found in response' };\n return;\n }\n\n const firstValidImage = images.find(\n (img: any) =>\n img?.image_url?.url && img.image_url.url.startsWith('data:image/'),\n );\n\n if (firstValidImage?.image_url?.url) {\n this.result = {\n success: true,\n imageUrl: firstValidImage.image_url.url,\n };\n return;\n }\n\n this.result = {\n success: false,\n error: 'No valid images generated in response',\n };\n } catch (e: any) {\n this.result = {\n success: false,\n error: e?.message || 'Unknown error occurred',\n };\n }\n }\n}\n" - }, - { - "filename": "Spec/1b023c94-c534-43bf-8a09-3db1f6f70967.json", - "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../avatar-creator/avatar-creator\",\n \"name\": \"AvatarCreator\"\n },\n \"specType\": \"card\",\n \"containedExamples\": [],\n \"cardTitle\": \"AvatarCreator\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" - }, - { - "filename": "Spec/94c53473-bfca-493d-b1f6-f70967d4714d.json", - "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../fields/avatar\",\n \"name\": \"default\"\n },\n \"specType\": \"field\",\n \"containedExamples\": [],\n \"cardTitle\": \"default\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" - }, - { - "filename": "Spec/bfca093d-b1f6-4709-a7d4-714d86bdb90e.json", - "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../utils/external/avataar\",\n \"name\": \"getAvataarsUrl\"\n },\n \"specType\": null,\n \"containedExamples\": [],\n \"cardTitle\": \"getAvataarsUrl\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" - }, - { - "filename": "Spec/ca093db1-f6f7-4967-9471-4d86bdb90e9a.json", - "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../utils/external/avataar\",\n \"name\": \"generateRandomAvatarModel\"\n },\n \"specType\": null,\n \"containedExamples\": [],\n \"cardTitle\": \"generateRandomAvatarModel\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" - }, - { - "filename": "Spec/73bfca09-3db1-46f7-8967-d4714d86bdb9.json", - "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../utils/external/avataar\",\n \"name\": \"getCategoryOptions\"\n },\n \"specType\": null,\n \"containedExamples\": [],\n \"cardTitle\": \"getCategoryOptions\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" - }, - { - "filename": "Spec/093db1f6-f709-47d4-b14d-86bdb90e9a95.json", - "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../utils/external/avataar\",\n \"name\": \"getOptionPreviewUrl\"\n },\n \"specType\": null,\n \"containedExamples\": [],\n \"cardTitle\": \"getOptionPreviewUrl\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" - }, - { - "filename": "Spec/b1f6f709-67d4-414d-86bd-b90e9a95eb3e.json", - "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../utils/external/avataar\",\n \"name\": \"getCurrentSelectionForCategory\"\n },\n \"specType\": null,\n \"containedExamples\": [],\n \"cardTitle\": \"getCurrentSelectionForCategory\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" - }, - { - "filename": "Spec/3db1f6f7-0967-4471-8d86-bdb90e9a95eb.json", - "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../utils/external/avataar\",\n \"name\": \"updateAvatarModelForCategory\"\n },\n \"specType\": null,\n \"containedExamples\": [],\n \"cardTitle\": \"updateAvatarModelForCategory\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" - }, - { - "filename": "Spec/f6f70967-d471-4d86-bdb9-0e9a95eb3e97.json", - "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../utils/external/avataar\",\n \"name\": \"playClickSound\"\n },\n \"specType\": null,\n \"containedExamples\": [],\n \"cardTitle\": \"playClickSound\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" - }, - { - "filename": "Spec/938e0b02-5259-4ae3-956f-a568ea6adf2f.json", - "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../utils/external/avataar\",\n \"name\": \"buildAICues\"\n },\n \"specType\": null,\n \"containedExamples\": [],\n \"cardTitle\": \"buildAICues\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" - }, - { - "filename": "Spec/67d4714d-86bd-490e-9a95-eb3e97e8960c.json", - "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../fields/components/avatar\",\n \"name\": \"default\"\n },\n \"specType\": \"component\",\n \"containedExamples\": [],\n \"cardTitle\": \"default\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" - }, - { - "filename": "Spec/4d86bdb9-0e9a-45eb-be97-e8960cfb907e.json", - "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../commands/suggest-avatar\",\n \"name\": \"SuggestAvatar\"\n },\n \"specType\": \"command\",\n \"containedExamples\": [],\n \"cardTitle\": \"SuggestAvatar\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" - }, - { - "filename": "Spec/b90e9a95-eb3e-47e8-960c-fb907eed22d7.json", - "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../avatar-creator/components/avatar-creator\",\n \"name\": \"default\"\n },\n \"specType\": \"component\",\n \"containedExamples\": [],\n \"cardTitle\": \"default\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" - }, - { - "filename": "Spec/0252595a-e315-4fa5-a8ea-6adf2fda9ca6.json", - "contents": "{\n \"data\": {\n \"type\": \"card\",\n \"attributes\": {\n \"readMe\": null,\n \"ref\": {\n \"module\": \"../commands/create-real-image\",\n \"name\": \"CreateRealImage\"\n },\n \"specType\": \"command\",\n \"containedExamples\": [],\n \"cardTitle\": \"CreateRealImage\",\n \"cardDescription\": null,\n \"cardInfo\": {\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null,\n \"notes\": null\n }\n },\n \"relationships\": {\n \"linkedExamples\": {\n \"links\": {\n \"self\": null\n }\n }\n },\n \"meta\": {\n \"adoptsFrom\": {\n \"module\": \"https://cardstack.com/base/spec\",\n \"name\": \"Spec\"\n }\n }\n }\n}\n" - }, - { - "filename": "avatar-creator/AvatarCreator/luna-starweaver.json", - "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"AvatarCreator\",\n \"module\": \"../avatar-creator\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"avatar\": {\n \"eyeType\": \"Surprised\",\n \"topType\": \"Hat\",\n \"hairColor\": \"SilverGray\",\n \"mouthType\": \"ScreamOpen\",\n \"skinColor\": \"Brown\",\n \"clotheType\": \"ShirtVNeck\",\n \"eyebrowType\": \"FlatNatural\",\n \"facialHairType\": \"Blank\",\n \"accessoriesType\": \"Blank\"\n },\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null\n }\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" - }, - { - "filename": "avatar-creator/AvatarCreator/magnus-stormbeard.json", - "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"AvatarCreator\",\n \"module\": \"../avatar-creator\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"avatar\": {\n \"eyeType\": \"Happy\",\n \"topType\": \"WinterHat1\",\n \"hairColor\": \"Auburn\",\n \"mouthType\": \"Serious\",\n \"skinColor\": \"Brown\",\n \"clotheType\": \"Hoodie\",\n \"eyebrowType\": \"RaisedExcitedNatural\",\n \"facialHairType\": \"Blank\",\n \"accessoriesType\": \"Blank\"\n },\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null\n }\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" - }, - { - "filename": "avatar-creator/AvatarCreator/pichu.json", - "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"AvatarCreator\",\n \"module\": \"../avatar-creator\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"avatar\": {\n \"eyeType\": \"Default\",\n \"topType\": \"WinterHat2\",\n \"hairColor\": \"BrownDark\",\n \"mouthType\": \"Smile\",\n \"skinColor\": \"Light\",\n \"clotheType\": \"BlazerShirt\",\n \"eyebrowType\": \"Default\",\n \"facialHairType\": \"Blank\",\n \"accessoriesType\": \"Blank\"\n },\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null\n }\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" - }, - { - "filename": "avatar-creator/AvatarCreator/zara-nightshade.json", - "contents": "{\n \"data\": {\n \"meta\": {\n \"adoptsFrom\": {\n \"name\": \"AvatarCreator\",\n \"module\": \"../avatar-creator\"\n }\n },\n \"type\": \"card\",\n \"attributes\": {\n \"avatar\": {\n \"eyeType\": \"Default\",\n \"topType\": \"LongHairShavedSides\",\n \"hairColor\": \"BrownDark\",\n \"mouthType\": \"Smile\",\n \"skinColor\": \"Pale\",\n \"clotheType\": \"Overall\",\n \"eyebrowType\": \"Default\",\n \"facialHairType\": \"Blank\",\n \"accessoriesType\": \"Blank\"\n },\n \"cardInfo\": {\n \"notes\": null,\n \"name\": null,\n \"summary\": null,\n \"cardThumbnailURL\": null\n }\n },\n \"relationships\": {\n \"cardInfo.theme\": {\n \"links\": {\n \"self\": null\n }\n }\n }\n }\n}\n" - } - ] - }, - "relationships": { - "listing": { - "links": { - "self": "../CardListing/f0c0ad91-0194-46b9-a971-9e60d637a51a" - } - }, - "cardInfo.theme": { - "links": { - "self": null - } - } - } - } -} \ No newline at end of file diff --git a/packages/catalog-realm/submission-card/components/card/fitted-template.gts b/packages/catalog-realm/submission-card/components/card/fitted-template.gts index 7cc051c6bca..76c6674fe40 100644 --- a/packages/catalog-realm/submission-card/components/card/fitted-template.gts +++ b/packages/catalog-realm/submission-card/components/card/fitted-template.gts @@ -3,22 +3,13 @@ import { on } from '@ember/modifier'; import { Component, realmURL } from 'https://cardstack.com/base/card-api'; import type { Query } from '@cardstack/runtime-common'; -import { eq } from '@cardstack/boxel-ui/helpers'; import { BoxelButton } from '@cardstack/boxel-ui/components'; -import CheckCircleIcon from '@cardstack/boxel-icons/circle-check'; -import ClockIcon from '@cardstack/boxel-icons/clock'; import GitBranchIcon from '@cardstack/boxel-icons/git-branch'; import GitPullRequestIcon from '@cardstack/boxel-icons/git-pull-request'; import MessageIcon from '@cardstack/boxel-icons/message'; -import XCircleIcon from '@cardstack/boxel-icons/circle-x'; - -import { - buildRealmHrefs, - buildLatestReviewByReviewer, - computeLatestReviewState, - searchEventQuery, -} from '../../../pr-card/utils'; + +import { buildRealmHrefs } from '../../../pr-card/utils'; import type { PrCard } from '../../../pr-card/pr-card'; import type { SubmissionCard } from '../../submission-card'; @@ -27,18 +18,6 @@ export class FittedTemplate extends Component { return this.args.model.listing?.name ?? this.args.model.listing?.cardTitle; } - get title() { - return this.args.model.cardTitle; - } - - get branchName() { - return this.args.model.branchName; - } - - get roomId() { - return this.args.model.roomId; - } - get listingImage() { return this.args.model.listing?.images?.[0]; } @@ -80,39 +59,6 @@ export class FittedTemplate extends Component { return (this.prCardData?.instances?.[0] as PrCard) ?? null; } - get githubEventCardRef() { - return { - module: new URL('../../../github-event/github-event', import.meta.url) - .href, - name: 'GithubEventCard' as const, - }; - } - - get prReviewEventQuery(): Query | undefined { - const prNumber = this.prCardInstance?.prNumber; - if (!prNumber) return undefined; - return searchEventQuery( - this.githubEventCardRef, - prNumber, - 'pull_request_review', - ); - } - - prReviewEventData = this.args.context?.getCards( - this, - () => this.prReviewEventQuery, - () => this.realmHrefs, - { isLive: true }, - ); - - get reviewState() { - if (!this.prCardInstance) return null; - const reviews = buildLatestReviewByReviewer( - this.prReviewEventData?.instances ?? [], - ); - return computeLatestReviewState(reviews); - } - openPrCard = (e: Event) => { e.stopPropagation(); if (this.prCardInstance) { @@ -139,27 +85,6 @@ export class FittedTemplate extends Component { {{else}} <@model.constructor.icon class='card-icon' /> {{/if}} - {{#if this.reviewState}} - - {{#if (eq this.reviewState 'approved')}} - - {{else if (eq this.reviewState 'changes_requested')}} - - {{else}} - - {{/if}} - - {{/if}} {{#if @model.listing}}
{ aria-label='View submission details' {{on 'click' this.openSubmission}} > - {{this.title}} - {{#if this.branchName}} + {{@model.cardTitle}} + {{#if @model.branchName}} Branch - {{this.branchName}} + {{@model.branchName}} {{/if}} - {{#if this.roomId}} + {{#if @model.roomId}} Room - {{this.roomId}} + {{@model.roomId}} {{/if}} @@ -393,35 +318,6 @@ export class FittedTemplate extends Component { --boxel-button-border: 1px solid var(--border, #d0d7de); } - .review-corner-badge { - position: absolute; - top: var(--boxel-sp-4xs); - right: var(--boxel-sp-4xs); - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - border-radius: 50%; - z-index: 1; - box-shadow: 0 2px 6px rgba(0, 0, 0, 0.45), 0 0 0 1.5px rgba(255, 255, 255, 0.9); - } - - .review-corner-badge--approved { - background: #d4edda; - color: #1a7f37; - } - - .review-corner-badge--changes { - background: #fde8ea; - color: #d73a49; - } - - .review-corner-badge--pending { - background: #fff3cd; - color: #9a6700; - } - @container fitted-card (aspect-ratio <= 1.0) { .submission-fitted { flex-direction: column; From 73bbab83ccbb7f097370866a7a6631397651fbaa Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 11 Mar 2026 15:43:44 +0800 Subject: [PATCH 14/19] update display message and loadingindicator pos --- .../pr-card/components/isolated/review-section.gts | 2 +- packages/catalog-realm/pr-card/utils.ts | 5 ----- .../submission-card/submission-card-portal.gts | 13 ++++++++++++- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/catalog-realm/pr-card/components/isolated/review-section.gts b/packages/catalog-realm/pr-card/components/isolated/review-section.gts index 0a79ea5c52c..978538e68e0 100644 --- a/packages/catalog-realm/pr-card/components/isolated/review-section.gts +++ b/packages/catalog-realm/pr-card/components/isolated/review-section.gts @@ -142,7 +142,7 @@ export class ReviewSection extends GlimmerComponent { - Pending Review + -
{{/if}} diff --git a/packages/catalog-realm/pr-card/utils.ts b/packages/catalog-realm/pr-card/utils.ts index dd9ac25adee..a5f23dd11a8 100644 --- a/packages/catalog-realm/pr-card/utils.ts +++ b/packages/catalog-realm/pr-card/utils.ts @@ -24,9 +24,6 @@ export type CiGroup = { export type ReviewState = | 'changes_requested' | 'approved' - | 'commented' - | 'dismissed' - | 'pending' | 'unknown'; // ── PR State Helpers ───────────────────────────────────────────────────── @@ -269,8 +266,6 @@ export function normalizeReviewState( return 'changes_requested'; case 'approved': return 'approved'; - case 'commented': - return 'commented'; default: return 'unknown'; } diff --git a/packages/catalog-realm/submission-card/submission-card-portal.gts b/packages/catalog-realm/submission-card/submission-card-portal.gts index aa90638470d..ecbac0e0b0a 100644 --- a/packages/catalog-realm/submission-card/submission-card-portal.gts +++ b/packages/catalog-realm/submission-card/submission-card-portal.gts @@ -199,7 +199,9 @@ class Isolated extends Component { @context={{@context}} /> {{else}} - +
+ +
{{/if}} @@ -270,6 +272,15 @@ class Isolated extends Component { --strip-view-min-width: 100%; --strip-view-height: 120px; } + + .portal-content .loading-screen { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + min-height: 300px; + } } From afa3185bc1afc5b0edde500c9736089a51fc59ba Mon Sep 17 00:00:00 2001 From: Lucas Date: Wed, 11 Mar 2026 21:21:50 +0800 Subject: [PATCH 15/19] revert linked to prcard code --- .../b535d5fb-8eef-44a6-8114-4bce6929b95a.json | 4 +- .../components/isolated/mergeable-section.gts | 105 ++++++++++++++++++ packages/catalog-realm/pr-card/pr-card.gts | 85 ++++++++++++++ 3 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 packages/catalog-realm/pr-card/components/isolated/mergeable-section.gts diff --git a/packages/catalog-realm/SubmissionCardPortal/b535d5fb-8eef-44a6-8114-4bce6929b95a.json b/packages/catalog-realm/SubmissionCardPortal/b535d5fb-8eef-44a6-8114-4bce6929b95a.json index b7dae599f01..8cbe6944bef 100644 --- a/packages/catalog-realm/SubmissionCardPortal/b535d5fb-8eef-44a6-8114-4bce6929b95a.json +++ b/packages/catalog-realm/SubmissionCardPortal/b535d5fb-8eef-44a6-8114-4bce6929b95a.json @@ -10,12 +10,12 @@ "attributes": { "title": "Submission Card Portal", "cardInfo": { - "name": null, + "name": "Submission Card Portal", "notes": null, "summary": null, "cardThumbnailURL": null }, - "description": null + "description": "" }, "relationships": { "cardInfo.theme": { diff --git a/packages/catalog-realm/pr-card/components/isolated/mergeable-section.gts b/packages/catalog-realm/pr-card/components/isolated/mergeable-section.gts new file mode 100644 index 00000000000..ece6e19d279 --- /dev/null +++ b/packages/catalog-realm/pr-card/components/isolated/mergeable-section.gts @@ -0,0 +1,105 @@ +import GlimmerComponent from '@glimmer/component'; +import TriangleAlertIcon from '@cardstack/boxel-icons/triangle-alert'; +import CircleCheckIcon from '@cardstack/boxel-icons/circle-check'; + +interface MergeableSectionSignature { + Args: { + isMergeable: boolean; + isClosedOrMerged: boolean; + blockReasons: string[]; + }; +} + +export class MergeableSection extends GlimmerComponent { + +} diff --git a/packages/catalog-realm/pr-card/pr-card.gts b/packages/catalog-realm/pr-card/pr-card.gts index f118809c856..3f590b4ea38 100644 --- a/packages/catalog-realm/pr-card/pr-card.gts +++ b/packages/catalog-realm/pr-card/pr-card.gts @@ -19,6 +19,7 @@ import { HeaderSection } from './components/isolated/header-section'; import { CiSection } from './components/isolated/ci-section'; import { ReviewSection } from './components/isolated/review-section'; import { SummarySection } from './components/isolated/summary-section'; +import { MergeableSection } from './components/isolated/mergeable-section'; import { renderPrActionLabel, @@ -196,6 +197,44 @@ class IsolatedTemplate extends Component { return !!this.latestPrReviewCommentEventInstance; } + // ── Mergeability ── + get isClosed() { + let label = this.latestPrActionLabel; + return label === 'Closed' || label === 'Merged'; + } + + get isDraft() { + return this.latestPrActionLabel === 'Draft'; + } + + get mergeBlockReasons(): string[] { + if (this.isClosed) return []; + let reasons: string[] = []; + if (this.isDraft) { + reasons.push('This pull request is still a work in progress'); + } + let { ciItems } = this; + if (ciItems.some((i) => i.state === 'failure')) { + reasons.push('Some checks were not successful'); + } else if (ciItems.some((i) => i.state === 'in_progress')) { + reasons.push('Some checks are still in progress'); + } + let reviewState = this.latestReviewState; + if (reviewState === 'changes_requested') { + reasons.push('Changes were requested by a reviewer'); + } else if (reviewState !== 'approved') { + reasons.push( + 'At least 1 approving review is required by reviewers with write access', + ); + } + return reasons; + } + + get isMergeable() { + if (this.isClosed) return false; + return this.mergeBlockReasons.length === 0; + } +