@ripple-ui/svelte 0.8.2 → 0.9.0-next.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/discover/CreatorAvatar.svelte +37 -0
  2. package/dist/discover/CreatorAvatar.svelte.d.ts +10 -0
  3. package/dist/discover/CreatorLine.svelte +18 -0
  4. package/dist/discover/CreatorLine.svelte.d.ts +8 -0
  5. package/dist/discover/DiscoverChips.svelte +15 -2
  6. package/dist/discover/DiscoverFilters.svelte +56 -41
  7. package/dist/discover/DiscoverHeader.svelte +33 -10
  8. package/dist/discover/DiscoverHeader.svelte.d.ts +3 -0
  9. package/dist/discover/DiscoverRow.svelte +36 -10
  10. package/dist/discover/DiscoverRow.svelte.d.ts +7 -0
  11. package/dist/discover/DiscoverSection.svelte +18 -10
  12. package/dist/discover/DiscoverSkeleton.svelte +5 -3
  13. package/dist/discover/DiscoverTile.svelte +52 -17
  14. package/dist/discover/DiscoverTile.svelte.d.ts +7 -0
  15. package/dist/discover/ItemActions.svelte +46 -17
  16. package/dist/discover/ItemActions.svelte.d.ts +6 -0
  17. package/dist/discover/ItemDetail.svelte +280 -0
  18. package/dist/discover/ItemDetail.svelte.d.ts +32 -0
  19. package/dist/discover/ItemMenu.svelte +61 -0
  20. package/dist/discover/ItemMenu.svelte.d.ts +13 -0
  21. package/dist/discover/LiveFrame.svelte +162 -0
  22. package/dist/discover/LiveFrame.svelte.d.ts +21 -0
  23. package/dist/discover/WhatsAppQrCard.svelte +302 -0
  24. package/dist/discover/WhatsAppQrCard.svelte.d.ts +18 -0
  25. package/dist/discover/frame.d.ts +7 -0
  26. package/dist/discover/frame.js +27 -0
  27. package/dist/discover/href.d.ts +7 -1
  28. package/dist/discover/href.js +16 -6
  29. package/dist/discover/index.d.ts +7 -2
  30. package/dist/discover/index.js +15 -7
  31. package/dist/discover/item.d.ts +14 -0
  32. package/dist/discover/item.js +41 -2
  33. package/dist/discover/layout.d.ts +1 -1
  34. package/dist/discover/layout.js +1 -1
  35. package/dist/discover/sections.d.ts +39 -0
  36. package/dist/discover/sections.js +84 -0
  37. package/dist/discover/types.d.ts +17 -0
  38. package/dist/discover/types.js +5 -2
  39. package/dist/discover/whatsapp-card-export.d.ts +9 -0
  40. package/dist/discover/whatsapp-card-export.js +72 -0
  41. package/dist/discover/whatsapp-card.d.ts +106 -0
  42. package/dist/discover/whatsapp-card.js +226 -0
  43. package/dist/discover/whatsapp-qr.d.ts +7 -0
  44. package/dist/discover/whatsapp-qr.js +20 -0
  45. package/dist/figures/bike-gears/figure.js +2 -6
  46. package/dist/figures/fission-reactor/figure.js +2 -6
  47. package/dist/figures/heart/figure.js +2 -6
  48. package/dist/figures/ion-thruster/figure.js +2 -6
  49. package/dist/figures/particle-collider/figure.js +2 -6
  50. package/dist/figures/rocket-engine/figure.js +2 -6
  51. package/dist/figures/style.d.ts +2 -0
  52. package/dist/figures/style.js +18 -0
  53. package/dist/figures/tokamak/figure.js +2 -6
  54. package/dist/manifest/entries/chart.js +1 -1
  55. package/dist/manifest/entries/markdown.js +2 -2
  56. package/dist/manifest.json +5 -5
  57. package/dist/styles.css +11 -10
  58. package/dist/utils/markdown-ext.d.ts +6 -0
  59. package/dist/utils/markdown-ext.js +68 -0
  60. package/dist/utils/mermaid.d.ts +94 -0
  61. package/dist/utils/mermaid.js +144 -0
  62. package/dist/utils/sanitize-html.d.ts +6 -0
  63. package/dist/utils/sanitize-html.js +15 -0
  64. package/dist/version.d.ts +2 -2
  65. package/dist/version.js +2 -2
  66. package/dist/widgets/data/Chart.svelte +34 -6
  67. package/dist/widgets/data/chart-layout.d.ts +4 -4
  68. package/dist/widgets/data/chart-layout.js +9 -8
  69. package/dist/widgets/display/Markdown.svelte +147 -3
  70. package/dist/widgets/display/Markdown.svelte.d.ts +1 -1
  71. package/package.json +4 -3
@@ -6,9 +6,16 @@ type $$ComponentProps = {
6
6
  /** Passed to both links. Off-site consumers pass target="_blank" rel="noopener". */
7
7
  target?: string;
8
8
  rel?: string;
9
+ /** Claim / Use this recipe and Remix as links (the website, to the app). Wins over `onremix`. */
10
+ remixHref?: string;
9
11
  onremix?: (item: DiscoverItem) => void;
10
12
  onreport?: (item: DiscoverItem) => void;
13
+ /** Opens the item in place. With `href`, a plain click on the art or title calls
14
+ * it instead of following the link; modified clicks and JS-off still navigate.
15
+ * For studio items it also drives the Play / View button. */
11
16
  onopen?: (item: DiscoverItem) => void;
17
+ /** Copy link in the ⋯ menu, shown with `href`. Gets the item and that href. */
18
+ oncopylink?: (item: DiscoverItem, href: string) => void;
12
19
  busy?: boolean;
13
20
  class?: string;
14
21
  };
@@ -1,44 +1,67 @@
1
1
  <!-- discover/ItemActions.svelte — the action cluster a tile and a row share: the
2
- primary pill (a link when the item has a live page, a button otherwise), then
3
- Play / View, Remix and Report as icon buttons. Each button renders only when
4
- its callback is passed, so a static page gets just the links. The live link
5
- points at user-submitted content, so it carries rel="nofollow ugc noopener
6
- noreferrer": no rank, no window handle, no catalogue url in the Referer.
7
- Item-page links (the href prop on the tile and row) carry whatever rel
8
- the consumer passes. -->
2
+ primary pill (a link when the item has a live page, a button otherwise),
3
+ Play / View for studio media, a Remix icon for tools and games, then a ⋯
4
+ overflow holding Remix (tools and games, with `remixHref` or `onremix`),
5
+ Copy link (with a `pageHref` and `oncopylink`) and Report. With
6
+ `remixHref` (the website, sending people to the app), Claim / Use this
7
+ recipe, the Remix icon and the Remix menu item are links to it; it wins
8
+ over `onremix`. In a compact tile (under 15rem, the tile's container
9
+ query) the Remix icon hides, the ⋯ copy covers it, and the primary pill
10
+ narrows.
11
+ Site cards get no Remix icon: their primary, Claim, already copies. Each
12
+ control renders only when its callback is passed, so a static page gets
13
+ just the links. The live link points at user-submitted content, so it
14
+ carries rel="nofollow ugc noopener noreferrer": no rank, no window handle,
15
+ no catalogue url in the Referer. Item-page links (the href prop on the
16
+ tile and row) carry whatever rel the consumer passes. -->
9
17
  <script lang="ts">
10
18
  import { safeUrl } from '@ripple-ui/core';
11
- import Flag from '@lucide/svelte/icons/flag';
12
19
  import GitFork from '@lucide/svelte/icons/git-fork';
13
20
  import Maximize2 from '@lucide/svelte/icons/maximize-2';
14
21
  import Play from '@lucide/svelte/icons/play';
15
22
  import { Button } from '../components/ui/button/index.js';
23
+ import { cn } from '../utils.js';
24
+ import ItemMenu from './ItemMenu.svelte';
16
25
  import { isStudioKind, mediaFor, primaryFor } from './item.js';
17
26
  import type { DiscoverItem } from './types.js';
18
27
 
19
28
  let {
20
29
  item,
30
+ pageHref,
31
+ remixHref,
21
32
  onremix,
22
33
  onreport,
23
34
  onopen,
35
+ oncopylink,
24
36
  busy = false,
37
+ class: className,
25
38
  }: {
26
39
  item: DiscoverItem;
40
+ /** The item's own page; Copy link shows only with this and `oncopylink`. */
41
+ pageHref?: string;
42
+ /** Remix as a link (Claim / Use this recipe, the Remix icon and menu item). Wins over `onremix`. */
43
+ remixHref?: string;
27
44
  onremix?: (item: DiscoverItem) => void;
28
45
  onreport?: (item: DiscoverItem) => void;
29
46
  onopen?: (item: DiscoverItem) => void;
47
+ oncopylink?: (item: DiscoverItem, href: string) => void;
30
48
  busy?: boolean;
49
+ class?: string;
31
50
  } = $props();
32
51
 
33
52
  const primary = $derived(primaryFor(item));
34
53
  const media = $derived(mediaFor(item));
54
+ const remixIcon = $derived(!isStudioKind(item.kind) && item.kind !== 'site');
55
+ const hasRemixLink = $derived(Boolean(remixHref && safeUrl(remixHref)));
35
56
  </script>
36
57
 
37
- <div class="flex shrink-0 items-center gap-0.5">
58
+ <div class={cn('flex shrink-0 items-center gap-0.5', className)}>
38
59
  {#if primary?.href}
39
- <Button size="sm" class="rounded-full px-4" href={safeUrl(primary.href)} target="_blank" rel="nofollow ugc noopener noreferrer" aria-label={`${primary.label} ${item.title} (opens in a new tab)`}>{primary.label}</Button>
60
+ <Button size="sm" class="rounded-full px-4 @max-[15rem]/tile:px-3" href={safeUrl(primary.href)} target="_blank" rel="nofollow ugc noopener noreferrer" aria-label={`${primary.label} ${item.title} (opens in a new tab)`}>{primary.label}</Button>
61
+ {:else if primary && hasRemixLink}
62
+ <Button size="sm" class="rounded-full px-4 @max-[15rem]/tile:px-3" href={safeUrl(remixHref)}>{primary.label}</Button>
40
63
  {:else if primary && onremix}
41
- <Button size="sm" class="rounded-full px-4" disabled={busy} onclick={() => onremix(item)}>{primary.label}</Button>
64
+ <Button size="sm" class="rounded-full px-4 @max-[15rem]/tile:px-3" disabled={busy} onclick={() => onremix(item)}>{primary.label}</Button>
42
65
  {/if}
43
66
  {#if media && onopen}
44
67
  {@const playable = media.kind !== 'image'}
@@ -46,14 +69,20 @@
46
69
  {#if playable}<Play class="size-4" />{:else}<Maximize2 class="size-4" />{/if}
47
70
  </Button>
48
71
  {/if}
49
- {#if !isStudioKind(item.kind) && onremix}
50
- <Button size="icon-sm" variant="ghost" aria-label={`Remix ${item.title}`} title="Remix" disabled={busy} onclick={() => onremix(item)}>
72
+ {#if remixIcon && hasRemixLink}
73
+ <Button size="icon-sm" variant="ghost" class="@max-[15rem]/tile:hidden" aria-label={`Remix ${item.title}`} title="Remix" href={safeUrl(remixHref)}>
51
74
  <GitFork class="size-4" />
52
75
  </Button>
53
- {/if}
54
- {#if onreport}
55
- <Button size="icon-sm" variant="ghost" class="text-muted-foreground" aria-label={`Report ${item.title}`} title="Report" onclick={() => onreport(item)}>
56
- <Flag class="size-3.5" />
76
+ {:else if remixIcon && onremix}
77
+ <Button size="icon-sm" variant="ghost" class="@max-[15rem]/tile:hidden" aria-label={`Remix ${item.title}`} title="Remix" disabled={busy} onclick={() => onremix(item)}>
78
+ <GitFork class="size-4" />
57
79
  </Button>
58
80
  {/if}
81
+ <ItemMenu
82
+ title={item.title}
83
+ remixHref={remixIcon && hasRemixLink ? remixHref : undefined}
84
+ onremix={remixIcon && !hasRemixLink && onremix && !busy ? () => onremix(item) : undefined}
85
+ oncopy={pageHref && oncopylink ? () => oncopylink(item, pageHref) : undefined}
86
+ onreport={onreport ? () => onreport(item) : undefined}
87
+ />
59
88
  </div>
@@ -1,10 +1,16 @@
1
1
  import type { DiscoverItem } from './types.js';
2
2
  type $$ComponentProps = {
3
3
  item: DiscoverItem;
4
+ /** The item's own page; Copy link shows only with this and `oncopylink`. */
5
+ pageHref?: string;
6
+ /** Remix as a link (Claim / Use this recipe, the Remix icon and menu item). Wins over `onremix`. */
7
+ remixHref?: string;
4
8
  onremix?: (item: DiscoverItem) => void;
5
9
  onreport?: (item: DiscoverItem) => void;
6
10
  onopen?: (item: DiscoverItem) => void;
11
+ oncopylink?: (item: DiscoverItem, href: string) => void;
7
12
  busy?: boolean;
13
+ class?: string;
8
14
  };
9
15
  declare const ItemActions: import("svelte").Component<$$ComponentProps, {}, "">;
10
16
  type ItemActions = ReturnType<typeof ItemActions>;
@@ -0,0 +1,280 @@
1
+ <!-- discover/ItemDetail.svelte — one Discover item in full: the renderer picked
2
+ by rendererFor (the studio player, the live page in a LiveFrame, or the
3
+ picture), the title and line, the creator row (avatar, name, "Published
4
+ <date>" and "· Updated <date>" when it changed more than a day later;
5
+ dates are fixed-locale UTC so hydration never rewrites them), the usage
6
+ line (left out at zero), the `madeWith` line, what it was remixed from,
7
+ its tags (links with `tagHref`, plain chips otherwise) and the actions.
8
+
9
+ Two layouts. layout="page" is the item's own route: one bordered card
10
+ (container `item-page`) with the preview large on the left (a site, game
11
+ or tool autoloads in a tall LiveFrame) and a sticky info panel on the
12
+ right (title, actions, then the metadata) from 56rem of container;
13
+ narrower, the panel stacks under the preview. h1. layout="overlay" is the
14
+ body of a consumer's Dialog or Sheet: one column, no side padding, h2, a
15
+ top inset that keeps the media clear of the close button, click-to-load
16
+ frames, actions last. It never draws the Dialog or Sheet itself.
17
+ `compact` is the narrow overlay: a framed item shows its picture instead
18
+ of loading the frame; media still plays. `pageHref` (overlay only) adds
19
+ "See full page", a link to the item's page; `onpage` takes a plain click
20
+ on it so a consumer can close its overlay and navigate (modified clicks
21
+ follow the link).
22
+
23
+ Actions: the primary (Use / Play link to the live page; Claim / Use this
24
+ recipe as `remixHref` link or `onremix` button), a "Remix in Paw OS" for
25
+ tools and games only (a studio or site primary already is the remix), and
26
+ a ⋯ menu with Copy link (`href` or `oncopylink`) and Report (`onreport`).
27
+ Without `oncopylink`, Copy link writes `href` resolved against the page to
28
+ the clipboard. Renders fully on the server. -->
29
+ <script lang="ts">
30
+ import { safeUrl } from '@ripple-ui/core';
31
+ import GitFork from '@lucide/svelte/icons/git-fork';
32
+ import Maximize2 from '@lucide/svelte/icons/maximize-2';
33
+ import Chip from '../widgets/display/Chip.svelte';
34
+ import { Button } from '../components/ui/button/index.js';
35
+ import { cn } from '../utils.js';
36
+ import CreatorAvatar from './CreatorAvatar.svelte';
37
+ import DetailMedia from './DetailMedia.svelte';
38
+ import ItemArt from './ItemArt.svelte';
39
+ import ItemMenu from './ItemMenu.svelte';
40
+ import LiveFrame from './LiveFrame.svelte';
41
+ import { initialFor, tintFor } from './art.js';
42
+ import { isPlainClick, itemHref } from './href.js';
43
+ import { httpUrl, isStudioKind, primaryFor, publishedDate, rendererFor, showUsage, updatedDate, usageLabel } from './item.js';
44
+ import type { DiscoverItem } from './types.js';
45
+
46
+ let {
47
+ item,
48
+ layout,
49
+ compact = false,
50
+ href,
51
+ remixHref,
52
+ onremix,
53
+ onreport,
54
+ oncopylink,
55
+ busy = false,
56
+ headingLevel,
57
+ madeWith,
58
+ tagHref,
59
+ pageHref,
60
+ onpage,
61
+ class: className,
62
+ }: {
63
+ item: DiscoverItem;
64
+ /** 'page': the item's own route, two columns. 'overlay': inside a consumer's Dialog or Sheet. */
65
+ layout: 'page' | 'overlay';
66
+ /** Narrow overlay: framed items show the picture, not the live frame. */
67
+ compact?: boolean;
68
+ /** The item's own page (Copy link; the compact Open when there is no `pageHref`). */
69
+ href?: string;
70
+ /** Remix as a link (the website, to the app). Wins over `onremix`. */
71
+ remixHref?: string;
72
+ /** Remix as a callback (the app). */
73
+ onremix?: (item: DiscoverItem) => void;
74
+ onreport?: (item: DiscoverItem) => void;
75
+ /** Replaces the built-in clipboard copy of `href`. */
76
+ oncopylink?: (item: DiscoverItem) => void;
77
+ busy?: boolean;
78
+ /** Defaults to h1 for a page, h2 for an overlay. */
79
+ headingLevel?: 'h1' | 'h2';
80
+ /** A line naming the tool it was made with, e.g. "Made with Paw Sites". */
81
+ madeWith?: string;
82
+ /** Makes each tag a link to this address (a filtered Discover page). */
83
+ tagHref?: (tag: string) => string;
84
+ /** Overlay only: draws "See full page", a link to the item's own page. */
85
+ pageHref?: string;
86
+ /** Takes a plain click on "See full page" (the link's default is prevented). */
87
+ onpage?: (item: DiscoverItem) => void;
88
+ class?: string;
89
+ } = $props();
90
+
91
+ const renderer = $derived(rendererFor(item));
92
+ const primary = $derived(primaryFor(item));
93
+ const isPage = $derived(layout === 'page');
94
+ const heading = $derived(headingLevel ?? (isPage ? 'h1' : 'h2'));
95
+ const tint = $derived(tintFor(item.id));
96
+ const initial = $derived(initialFor(item.title));
97
+ const liveUrl = $derived(httpUrl(item.liveUrl));
98
+ // A studio or site primary is already the remix; a second button would repeat it.
99
+ const remixButton = $derived(!isStudioKind(item.kind) && item.kind !== 'site' && Boolean(remixHref || onremix));
100
+ const canCopy = $derived(Boolean(href || oncopylink));
101
+ const creator = $derived(item.creator?.name ? item.creator : null);
102
+ const published = $derived(publishedDate(item));
103
+ const updated = $derived(updatedDate(item));
104
+ const pageLink = $derived(!isPage && pageHref ? pageHref : undefined);
105
+ // The compact sheet's Open predates pageHref; with pageHref, See full page leads instead.
106
+ const compactOpen = $derived(compact && renderer === 'frame' && Boolean(href) && !pageLink);
107
+ const leadsWithPage = $derived(compact && Boolean(pageLink));
108
+ const primaryVariant = $derived(compactOpen || leadsWithPage ? 'outline' : 'default');
109
+ // The page panel has room for callout-size metadata; the overlay keeps it small.
110
+ const metaText = $derived(isPage ? 'text-callout' : 'text-footnote');
111
+ let copied = $state(false);
112
+
113
+ async function copyLink() {
114
+ if (oncopylink) return oncopylink(item);
115
+ if (!href) return;
116
+ try {
117
+ await navigator.clipboard.writeText(new URL(href, location.href).href);
118
+ copied = true;
119
+ setTimeout(() => (copied = false), 2000);
120
+ } catch {
121
+ // No clipboard (insecure context, denied): nothing to undo.
122
+ }
123
+ }
124
+
125
+ function openPage(e: MouseEvent) {
126
+ if (!onpage || !isPlainClick(e)) return;
127
+ e.preventDefault();
128
+ onpage(item);
129
+ }
130
+ </script>
131
+
132
+ {#snippet preview()}
133
+ {#if renderer === 'media'}
134
+ <DetailMedia {item} />
135
+ {:else if renderer === 'frame' && liveUrl && !compact}
136
+ <LiveFrame
137
+ url={liveUrl}
138
+ title={item.title}
139
+ kind={item.kind}
140
+ poster={safeUrl(item.imageUrl ?? undefined, { kind: 'resource' })}
141
+ {tint}
142
+ {initial}
143
+ autoload={isPage}
144
+ tall={isPage}
145
+ />
146
+ {:else}
147
+ <ItemArt imageUrl={item.imageUrl} title={item.title} mediaKind={item.mediaKind} {tint} {initial} />
148
+ {/if}
149
+ {/snippet}
150
+
151
+ {#snippet titleBlock()}
152
+ <div class="flex flex-col gap-1.5">
153
+ <svelte:element this={heading} class={cn('m-0 text-foreground', isPage ? 'text-title-1' : 'text-title-2')}>{item.title}</svelte:element>
154
+ {#if item.desc}<p class="m-0 text-body text-muted-foreground">{item.desc}</p>{/if}
155
+ </div>
156
+ {/snippet}
157
+
158
+ {#snippet meta()}
159
+ {#if creator || published}
160
+ <div class="flex min-w-0 items-center gap-3" data-testid="discover-detail-creator">
161
+ {#if creator}<CreatorAvatar name={creator.name} avatarUrl={creator.avatarUrl} size="lg" />{/if}
162
+ <div class="flex min-w-0 flex-col">
163
+ {#if creator}<span class="truncate text-callout font-medium text-foreground">By {creator.name}</span>{/if}
164
+ {#if published}
165
+ <span class={cn(metaText, 'text-muted-foreground tabular-nums')} data-testid="discover-detail-dates"
166
+ >Published {published}{#if updated}{' '}· Updated {updated}{/if}</span
167
+ >
168
+ {/if}
169
+ </div>
170
+ </div>
171
+ {/if}
172
+
173
+ {#if showUsage(item) || item.remixedFrom || madeWith}
174
+ <div class={cn('flex flex-col', isPage ? 'gap-1.5' : 'gap-1')}>
175
+ {#if showUsage(item)}
176
+ <p class={cn('m-0 flex items-center gap-1.5 text-muted-foreground tabular-nums', isPage ? 'text-callout' : 'text-caption-1')}>
177
+ <GitFork class={isPage ? 'size-3.5' : 'size-3'} aria-hidden="true" />{usageLabel(item)}
178
+ </p>
179
+ {/if}
180
+ {#if item.remixedFrom}
181
+ <p class={cn('m-0 text-muted-foreground', metaText)} data-testid="discover-detail-remixed-from">
182
+ Remixed from <a class="text-foreground hover:underline" href={safeUrl(itemHref(item.kind, item.remixedFrom.slug))}>{item.remixedFrom.title}</a>
183
+ </p>
184
+ {/if}
185
+ {#if madeWith}<p class={cn('m-0 text-muted-foreground', metaText)} data-testid="discover-detail-made-with">{madeWith}</p>{/if}
186
+ </div>
187
+ {/if}
188
+
189
+ {#if item.tags?.length}
190
+ <ul class="m-0 flex list-none flex-wrap gap-1.5 p-0" aria-label="Tags">
191
+ {#each item.tags as tag (tag)}
192
+ <li>
193
+ {#if tagHref}
194
+ <a class="rounded-md focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none underline-offset-2 hover:underline" href={safeUrl(tagHref(tag))}><Chip size="sm" label={tag} /></a>
195
+ {:else}
196
+ <Chip size="sm" label={tag} />
197
+ {/if}
198
+ </li>
199
+ {/each}
200
+ </ul>
201
+ {/if}
202
+ {/snippet}
203
+
204
+ {#snippet pageButton(variant: 'default' | 'outline')}
205
+ <Button {variant} class="rounded-full px-5" href={safeUrl(pageLink)} onclick={openPage} data-testid="discover-detail-page-link"
206
+ ><Maximize2 aria-hidden="true" />See full page</Button
207
+ >
208
+ {/snippet}
209
+
210
+ {#snippet actions()}
211
+ <div class="flex flex-wrap items-center gap-2" data-testid="discover-detail-actions">
212
+ {#if compactOpen && href}
213
+ <Button class="rounded-full px-5" href={safeUrl(href)}>Open</Button>
214
+ {/if}
215
+ {#if leadsWithPage}{@render pageButton('default')}{/if}
216
+ {#if primary?.href}
217
+ <Button
218
+ variant={primaryVariant}
219
+ class="rounded-full px-5"
220
+ href={safeUrl(primary.href)}
221
+ target="_blank"
222
+ rel="nofollow ugc noopener noreferrer"
223
+ aria-label={`${primary.label} ${item.title} (opens in a new tab)`}>{primary.label}</Button
224
+ >
225
+ {:else if primary && remixHref}
226
+ <Button variant={primaryVariant} class="rounded-full px-5" href={safeUrl(remixHref)}>{primary.label}</Button>
227
+ {:else if primary && onremix}
228
+ <Button variant={primaryVariant} class="rounded-full px-5" disabled={busy} onclick={() => onremix(item)}>{primary.label}</Button>
229
+ {/if}
230
+ {#if remixButton}
231
+ {#if remixHref}
232
+ <Button variant="outline" class="rounded-full px-5" href={safeUrl(remixHref)}><GitFork aria-hidden="true" />Remix in Paw OS</Button>
233
+ {:else if onremix}
234
+ <Button variant="outline" class="rounded-full px-5" disabled={busy} onclick={() => onremix(item)}><GitFork aria-hidden="true" />Remix in Paw OS</Button>
235
+ {/if}
236
+ {/if}
237
+ {#if pageLink && !leadsWithPage}{@render pageButton('outline')}{/if}
238
+ <ItemMenu title={item.title} oncopy={canCopy ? copyLink : undefined} onreport={onreport ? () => onreport(item) : undefined} />
239
+ <span class="text-caption-1 text-muted-foreground" aria-live="polite">{copied ? 'Link copied' : ''}</span>
240
+ </div>
241
+ {/snippet}
242
+
243
+ {#if isPage}
244
+ <article
245
+ class={cn('@container/item-page mx-auto w-full max-w-7xl min-w-0', className)}
246
+ data-testid={`discover-detail-${item.id}`}
247
+ data-renderer={renderer}
248
+ data-layout="page"
249
+ >
250
+ <div
251
+ class="grid grid-cols-1 items-start rounded-2xl border border-border bg-card @4xl/item-page:grid-cols-[minmax(0,1fr)_minmax(18rem,22rem)]"
252
+ data-testid="discover-page-card"
253
+ >
254
+ <div
255
+ class="min-w-0 rounded-t-2xl border-b border-border bg-muted/40 p-3 @lg/item-page:p-4 @4xl/item-page:rounded-tr-none @4xl/item-page:rounded-bl-2xl @4xl/item-page:border-r @4xl/item-page:border-b-0"
256
+ data-testid="discover-page-preview"
257
+ >
258
+ {@render preview()}
259
+ </div>
260
+ <aside
261
+ class="flex min-w-0 flex-col gap-5 p-5 @lg/item-page:p-6 @4xl/item-page:sticky @4xl/item-page:top-4"
262
+ aria-label="About this item"
263
+ data-testid="discover-page-info"
264
+ >
265
+ {@render titleBlock()}
266
+ {@render actions()}
267
+ <div class="flex flex-col gap-4 border-t border-border pt-5">{@render meta()}</div>
268
+ </aside>
269
+ </div>
270
+ </article>
271
+ {:else}
272
+ <article class={cn('flex min-w-0 flex-col gap-4 pt-8', className)} data-testid={`discover-detail-${item.id}`} data-renderer={renderer} data-layout="overlay">
273
+ {@render preview()}
274
+ <div class="flex flex-col gap-4">
275
+ {@render titleBlock()}
276
+ {@render meta()}
277
+ {@render actions()}
278
+ </div>
279
+ </article>
280
+ {/if}
@@ -0,0 +1,32 @@
1
+ import type { DiscoverItem } from './types.js';
2
+ type $$ComponentProps = {
3
+ item: DiscoverItem;
4
+ /** 'page': the item's own route, two columns. 'overlay': inside a consumer's Dialog or Sheet. */
5
+ layout: 'page' | 'overlay';
6
+ /** Narrow overlay: framed items show the picture, not the live frame. */
7
+ compact?: boolean;
8
+ /** The item's own page (Copy link; the compact Open when there is no `pageHref`). */
9
+ href?: string;
10
+ /** Remix as a link (the website, to the app). Wins over `onremix`. */
11
+ remixHref?: string;
12
+ /** Remix as a callback (the app). */
13
+ onremix?: (item: DiscoverItem) => void;
14
+ onreport?: (item: DiscoverItem) => void;
15
+ /** Replaces the built-in clipboard copy of `href`. */
16
+ oncopylink?: (item: DiscoverItem) => void;
17
+ busy?: boolean;
18
+ /** Defaults to h1 for a page, h2 for an overlay. */
19
+ headingLevel?: 'h1' | 'h2';
20
+ /** A line naming the tool it was made with, e.g. "Made with Paw Sites". */
21
+ madeWith?: string;
22
+ /** Makes each tag a link to this address (a filtered Discover page). */
23
+ tagHref?: (tag: string) => string;
24
+ /** Overlay only: draws "See full page", a link to the item's own page. */
25
+ pageHref?: string;
26
+ /** Takes a plain click on "See full page" (the link's default is prevented). */
27
+ onpage?: (item: DiscoverItem) => void;
28
+ class?: string;
29
+ };
30
+ declare const ItemDetail: import("svelte").Component<$$ComponentProps, {}, "">;
31
+ type ItemDetail = ReturnType<typeof ItemDetail>;
32
+ export default ItemDetail;
@@ -0,0 +1,61 @@
1
+ <!-- discover/ItemMenu.svelte — the ⋯ overflow a card and the detail view share:
2
+ Remix (cards only; a link with `remixHref`, which wins over `onremix`),
3
+ Copy link and Report, each drawn only when its callback is passed;
4
+ with none, no trigger is drawn. Internal to the card set (not exported). The
5
+ menu is ripple's DropdownMenu, whose content mounts only when open, so the
6
+ server markup is just the trigger button. -->
7
+ <script lang="ts">
8
+ import { safeUrl } from '@ripple-ui/core';
9
+ import Ellipsis from '@lucide/svelte/icons/ellipsis';
10
+ import Flag from '@lucide/svelte/icons/flag';
11
+ import GitFork from '@lucide/svelte/icons/git-fork';
12
+ import Link from '@lucide/svelte/icons/link';
13
+ import { Button } from '../components/ui/button/index.js';
14
+ import * as DropdownMenu from '../components/ui/dropdown-menu/index.js';
15
+
16
+ let {
17
+ title,
18
+ remixHref,
19
+ onremix,
20
+ oncopy,
21
+ onreport,
22
+ }: {
23
+ /** The item's title, for the trigger's accessible name. */
24
+ title: string;
25
+ /** Remix as a link. Wins over `onremix`. */
26
+ remixHref?: string;
27
+ /** Remix, for a card whose Remix icon hides on a narrow tile. */
28
+ onremix?: () => void;
29
+ oncopy?: () => void;
30
+ onreport?: () => void;
31
+ } = $props();
32
+ </script>
33
+
34
+ {#if remixHref || onremix || oncopy || onreport}
35
+ <DropdownMenu.Root>
36
+ <DropdownMenu.Trigger>
37
+ {#snippet child({ props })}
38
+ <Button {...props} size="icon-sm" variant="ghost" class="text-muted-foreground" aria-label={`More for ${title}`} title="More">
39
+ <Ellipsis class="size-4" />
40
+ </Button>
41
+ {/snippet}
42
+ </DropdownMenu.Trigger>
43
+ <DropdownMenu.Content align="end" class="w-40">
44
+ {#if remixHref}
45
+ <DropdownMenu.Item>
46
+ {#snippet child({ props })}
47
+ <a {...props} href={safeUrl(remixHref)}><GitFork />Remix</a>
48
+ {/snippet}
49
+ </DropdownMenu.Item>
50
+ {:else if onremix}
51
+ <DropdownMenu.Item onSelect={onremix}><GitFork />Remix</DropdownMenu.Item>
52
+ {/if}
53
+ {#if oncopy}
54
+ <DropdownMenu.Item onSelect={oncopy}><Link />Copy link</DropdownMenu.Item>
55
+ {/if}
56
+ {#if onreport}
57
+ <DropdownMenu.Item onSelect={onreport}><Flag />Report</DropdownMenu.Item>
58
+ {/if}
59
+ </DropdownMenu.Content>
60
+ </DropdownMenu.Root>
61
+ {/if}
@@ -0,0 +1,13 @@
1
+ type $$ComponentProps = {
2
+ /** The item's title, for the trigger's accessible name. */
3
+ title: string;
4
+ /** Remix as a link. Wins over `onremix`. */
5
+ remixHref?: string;
6
+ /** Remix, for a card whose Remix icon hides on a narrow tile. */
7
+ onremix?: () => void;
8
+ oncopy?: () => void;
9
+ onreport?: () => void;
10
+ };
11
+ declare const ItemMenu: import("svelte").Component<$$ComponentProps, {}, "">;
12
+ type ItemMenu = ReturnType<typeof ItemMenu>;
13
+ export default ItemMenu;