your personal website on atproto - mirror blento.app
26
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #115 from flo-bit/card-command-bar-v2

Card command bar v2

authored by

Florian and committed by
GitHub
0ac11b47 1b021a9c

+939 -121
+1 -3
src/app.css
··· 3 3 @plugin '@tailwindcss/forms'; 4 4 @plugin '@tailwindcss/typography'; 5 5 6 - 7 - @plugin "tailwindcss-animate"; 8 - 6 + @plugin 'tailwindcss-animate'; 9 7 10 8 @source '../node_modules/@foxui'; 11 9
+19 -5
src/lib/cards/ATProtoCollectionsCard/ATProtoCollectionsCard.svelte
··· 39 39 <Badge size="md" class="accent:text-accent-950">{collections.length}</Badge> 40 40 {/if} 41 41 </div> 42 - <div class="flex w-full flex-wrap gap-2 overflow-x-hidden overflow-y-scroll px-4"> 43 - {#each collections ?? [] as collection (collection)} 44 - <Button target="_blank" href={getLink(collection)} size="sm">{collection}</Button> 45 - {/each} 46 - </div> 42 + {#if collections && collections.length > 0} 43 + <div class="flex w-full flex-wrap gap-2 overflow-x-hidden overflow-y-scroll px-4"> 44 + {#each collections as collection (collection)} 45 + <Button target="_blank" href={getLink(collection)} size="sm">{collection}</Button> 46 + {/each} 47 + </div> 48 + {:else if collections} 49 + <div 50 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 51 + > 52 + No collections found. 53 + </div> 54 + {:else} 55 + <div 56 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 57 + > 58 + Loading collections... 59 + </div> 60 + {/if} 47 61 </div>
+3 -1
src/lib/cards/ATProtoCollectionsCard/index.ts
··· 23 23 24 24 name: 'ATProto Collections', 25 25 26 - groups: ['Social'] 26 + groups: ['Social'], 27 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" /></svg>` 28 + 27 29 } as CardDefinition & { type: 'atprotocollections' };
+3 -1
src/lib/cards/BigSocialCard/index.ts
··· 53 53 urlHandlerPriority: 1, 54 54 canHaveLabel: true, 55 55 56 - groups: ['Social'] 56 + groups: ['Social'], 57 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 1 0 0 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186 9.566-5.314m-9.566 7.5 9.566 5.314m0 0a2.25 2.25 0 1 0 3.935 2.186 2.25 2.25 0 0 0-3.935-2.186Zm0-12.814a2.25 2.25 0 1 0 3.933-2.185 2.25 2.25 0 0 0-3.933 2.185Z" /></svg>` 58 + 57 59 } as CardDefinition & { type: 'bigsocial' }; 58 60 59 61 import {
+3 -1
src/lib/cards/BlueskyMediaCard/index.ts
··· 12 12 13 13 groups: ['Media'], 14 14 15 - name: 'Video/Image from Bluesky' 15 + name: 'Video/Image from Bluesky', 16 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h1.5C5.496 19.5 6 18.996 6 18.375m-3.75 0V5.625m0 12.75v-1.5c0-.621.504-1.125 1.125-1.125m18.375 2.625V5.625m0 12.75c0 .621-.504 1.125-1.125 1.125m1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125m0 3.75h-1.5A1.125 1.125 0 0 1 18 18.375M20.625 4.5H3.375m17.25 0c.621 0 1.125.504 1.125 1.125M20.625 4.5h-1.5C18.504 4.5 18 5.004 18 5.625m3.75 0v1.5c0 .621-.504 1.125-1.125 1.125M3.375 4.5c-.621 0-1.125.504-1.125 1.125M3.375 4.5h1.5C5.496 4.5 6 5.004 6 5.625m-3.75 0v1.5c0 .621.504 1.125 1.125 1.125m0 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125m1.5-3.75C5.496 8.25 6 7.746 6 7.125v-1.5M4.875 8.25C5.496 8.25 6 8.754 6 9.375v1.5m0-5.25v5.25m0-5.25C6 5.004 6.504 4.5 7.125 4.5h9.75c.621 0 1.125.504 1.125 1.125m1.125 2.625h1.5m-1.5 0A1.125 1.125 0 0 1 18 7.125v-1.5m1.125 2.625c-.621 0-1.125.504-1.125 1.125v1.5m2.625-2.625c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125M18 5.625v5.25M7.125 12h9.75m-9.75 0A1.125 1.125 0 0 1 6 10.875M7.125 12C6.504 12 6 12.504 6 13.125m0-2.25C6 11.496 5.496 12 4.875 12M18 10.875c0 .621-.504 1.125-1.125 1.125M18 10.875c0 .621.504 1.125 1.125 1.125m-2.25 0c.621 0 1.125.504 1.125 1.125m-12 5.25v-5.25m0 5.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125m-12 0v-1.5c0-.621-.504-1.125-1.125-1.125M18 18.375v-5.25m0 5.25v-1.5c0-.621.504-1.125 1.125-1.125M18 13.125v1.5c0 .621.504 1.125 1.125 1.125M18 13.125c0-.621.504-1.125 1.125-1.125M6 13.125v1.5c0 .621-.504 1.125-1.125 1.125M6 13.125C6 12.504 5.496 12 4.875 12m-1.5 0h1.5m-1.5 0c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125M19.125 12h1.5m0 0c.621 0 1.125.504 1.125 1.125v1.5c0 .621-.504 1.125-1.125 1.125m-17.25 0h1.5m14.25 0h1.5" /></svg>` 17 + 16 18 } as CardDefinition & { type: 'blueskyMedia' };
+3 -1
src/lib/cards/BlueskyPostCard/index.ts
··· 65 65 minW: 4, 66 66 name: 'Bluesky Post', 67 67 68 - groups: ['Social'] 68 + groups: ['Social'], 69 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.129.166 2.27.293 3.423.379.35.026.67.21.865.501L12 21l2.755-4.133a1.14 1.14 0 0 1 .865-.501 48.172 48.172 0 0 0 3.423-.379c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z" /></svg>` 70 + 69 71 } as CardDefinition & { type: 'blueskyPost' };
+3 -1
src/lib/cards/ButtonCard/index.ts
··· 30 30 maxH: 4, 31 31 32 32 groups: ['Utilities'], 33 - name: 'Button' 33 + name: 'Button', 34 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M15.042 21.672 13.684 16.6m0 0-2.51 2.225.569-9.47 5.227 7.917-3.286-.672ZM12 2.25V4.5m5.834.166-1.591 1.591M20.25 10.5H18M7.757 14.743l-1.59 1.59M6 10.5H3.75m4.007-4.243-1.59-1.59" /></svg>` 35 + 34 36 };
+87
src/lib/cards/ClockCard/ClockCard.svelte
··· 1 + <script lang="ts"> 2 + import NumberFlow, { NumberFlowGroup } from '@number-flow/svelte'; 3 + import type { ContentComponentProps } from '../types'; 4 + import type { ClockCardData } from './index'; 5 + import { onMount } from 'svelte'; 6 + 7 + let { item }: ContentComponentProps = $props(); 8 + 9 + let cardData = $derived(item.cardData as ClockCardData); 10 + 11 + let now = $state(new Date()); 12 + 13 + onMount(() => { 14 + const interval = setInterval(() => { 15 + now = new Date(); 16 + }, 1000); 17 + return () => clearInterval(interval); 18 + }); 19 + 20 + let clockParts = $derived.by(() => { 21 + try { 22 + return new Intl.DateTimeFormat('en-US', { 23 + timeZone: cardData.timezone || 'UTC', 24 + hour: '2-digit', 25 + minute: '2-digit', 26 + second: '2-digit', 27 + hour12: false 28 + }).formatToParts(now); 29 + } catch { 30 + return null; 31 + } 32 + }); 33 + 34 + let clockHours = $derived( 35 + clockParts ? parseInt(clockParts.find((p) => p.type === 'hour')?.value || '0') : 0 36 + ); 37 + let clockMinutes = $derived( 38 + clockParts ? parseInt(clockParts.find((p) => p.type === 'minute')?.value || '0') : 0 39 + ); 40 + let clockSeconds = $derived( 41 + clockParts ? parseInt(clockParts.find((p) => p.type === 'second')?.value || '0') : 0 42 + ); 43 + 44 + let timezoneDisplay = $derived.by(() => { 45 + if (!cardData.timezone) return ''; 46 + try { 47 + const formatter = new Intl.DateTimeFormat('en-US', { 48 + timeZone: cardData.timezone, 49 + timeZoneName: 'short' 50 + }); 51 + const parts = formatter.formatToParts(now); 52 + return parts.find((p) => p.type === 'timeZoneName')?.value || cardData.timezone; 53 + } catch { 54 + return cardData.timezone; 55 + } 56 + }); 57 + </script> 58 + 59 + <div class="@container flex h-full w-full flex-col items-center justify-center p-4"> 60 + <NumberFlowGroup> 61 + <div 62 + class="text-base-900 dark:text-base-100 accent:text-base-900 flex items-center text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 63 + style="font-variant-numeric: tabular-nums;" 64 + > 65 + <NumberFlow value={clockHours} format={{ minimumIntegerDigits: 2 }} /> 66 + <span class="text-base-400 dark:text-base-500 accent:text-accent-950 mx-0.5">:</span> 67 + <NumberFlow 68 + value={clockMinutes} 69 + format={{ minimumIntegerDigits: 2 }} 70 + digits={{ 1: { max: 5 } }} 71 + trend={1} 72 + /> 73 + <span class="text-base-400 dark:text-base-500 accent:text-accent-950 mx-0.5">:</span> 74 + <NumberFlow 75 + value={clockSeconds} 76 + format={{ minimumIntegerDigits: 2 }} 77 + digits={{ 1: { max: 5 } }} 78 + trend={1} 79 + /> 80 + </div> 81 + </NumberFlowGroup> 82 + {#if timezoneDisplay} 83 + <div class="text-base-500 dark:text-base-400 accent:text-base-600 mt-1 text-xs @sm:text-sm"> 84 + {timezoneDisplay} 85 + </div> 86 + {/if} 87 + </div>
+74
src/lib/cards/ClockCard/ClockCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { Button, Label } from '@foxui/core'; 4 + import type { ClockCardData } from './index'; 5 + import { onMount } from 'svelte'; 6 + 7 + let { item }: { item: Item; onclose: () => void } = $props(); 8 + 9 + let cardData = $derived(item.cardData as ClockCardData); 10 + 11 + const timezoneOptions = [ 12 + { value: 'Pacific/Midway', label: 'UTC-11 (Midway)' }, 13 + { value: 'Pacific/Honolulu', label: 'UTC-10 (Honolulu)' }, 14 + { value: 'America/Anchorage', label: 'UTC-9 (Anchorage)' }, 15 + { value: 'America/Los_Angeles', label: 'UTC-8 (Los Angeles)' }, 16 + { value: 'America/Denver', label: 'UTC-7 (Denver)' }, 17 + { value: 'America/Chicago', label: 'UTC-6 (Chicago)' }, 18 + { value: 'America/New_York', label: 'UTC-5 (New York)' }, 19 + { value: 'America/Halifax', label: 'UTC-4 (Halifax)' }, 20 + { value: 'America/Sao_Paulo', label: 'UTC-3 (São Paulo)' }, 21 + { value: 'Atlantic/South_Georgia', label: 'UTC-2 (South Georgia)' }, 22 + { value: 'Atlantic/Azores', label: 'UTC-1 (Azores)' }, 23 + { value: 'UTC', label: 'UTC+0 (London)' }, 24 + { value: 'Europe/Paris', label: 'UTC+1 (Paris)' }, 25 + { value: 'Europe/Helsinki', label: 'UTC+2 (Helsinki)' }, 26 + { value: 'Europe/Moscow', label: 'UTC+3 (Moscow)' }, 27 + { value: 'Asia/Dubai', label: 'UTC+4 (Dubai)' }, 28 + { value: 'Asia/Karachi', label: 'UTC+5 (Karachi)' }, 29 + { value: 'Asia/Kolkata', label: 'UTC+5:30 (Mumbai)' }, 30 + { value: 'Asia/Dhaka', label: 'UTC+6 (Dhaka)' }, 31 + { value: 'Asia/Bangkok', label: 'UTC+7 (Bangkok)' }, 32 + { value: 'Asia/Shanghai', label: 'UTC+8 (Shanghai)' }, 33 + { value: 'Asia/Tokyo', label: 'UTC+9 (Tokyo)' }, 34 + { value: 'Australia/Sydney', label: 'UTC+10 (Sydney)' }, 35 + { value: 'Pacific/Noumea', label: 'UTC+11 (Noumea)' }, 36 + { value: 'Pacific/Auckland', label: 'UTC+12 (Auckland)' } 37 + ]; 38 + 39 + onMount(() => { 40 + if (!cardData.timezone) { 41 + try { 42 + item.cardData.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 43 + } catch { 44 + item.cardData.timezone = 'UTC'; 45 + } 46 + } 47 + }); 48 + 49 + function useLocalTimezone() { 50 + try { 51 + item.cardData.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; 52 + } catch { 53 + item.cardData.timezone = 'UTC'; 54 + } 55 + } 56 + </script> 57 + 58 + <div class="flex flex-col gap-4"> 59 + <div class="flex flex-col gap-2"> 60 + <Label>Timezone</Label> 61 + <div class="flex gap-2"> 62 + <select 63 + value={cardData.timezone || 'UTC'} 64 + onchange={(e) => (item.cardData.timezone = e.currentTarget.value)} 65 + class="bg-base-100 dark:bg-base-800 border-base-300 dark:border-base-700 text-base-900 dark:text-base-100 flex-1 rounded-xl border px-3 py-2" 66 + > 67 + {#each timezoneOptions as tz (tz.value)} 68 + <option value={tz.value}>{tz.label}</option> 69 + {/each} 70 + </select> 71 + <Button size="sm" variant="ghost" onclick={useLocalTimezone}>Local</Button> 72 + </div> 73 + </div> 74 + </div>
+31
src/lib/cards/ClockCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import ClockCard from './ClockCard.svelte'; 3 + import ClockCardSettings from './ClockCardSettings.svelte'; 4 + 5 + export type ClockCardData = { 6 + timezone?: string; 7 + }; 8 + 9 + export const ClockCardDefinition = { 10 + type: 'clock', 11 + contentComponent: ClockCard, 12 + settingsComponent: ClockCardSettings, 13 + 14 + createNew: (card) => { 15 + card.w = 4; 16 + card.h = 2; 17 + card.mobileW = 8; 18 + card.mobileH = 3; 19 + card.cardData = { 20 + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone 21 + } as ClockCardData; 22 + }, 23 + 24 + allowSetColor: true, 25 + name: 'Clock', 26 + minW: 4, 27 + canHaveLabel: true, 28 + groups: ['Utilities'], 29 + keywords: ['time', 'timezone', 'watch'], 30 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" /></svg>` 31 + } as CardDefinition & { type: 'clock' };
+185
src/lib/cards/CountdownCard/CountdownCard.svelte
··· 1 + <script lang="ts"> 2 + import NumberFlow, { NumberFlowGroup } from '@number-flow/svelte'; 3 + import type { ContentComponentProps } from '../types'; 4 + import type { CountdownCardData } from './index'; 5 + import { onMount } from 'svelte'; 6 + 7 + let { item }: ContentComponentProps = $props(); 8 + 9 + let cardData = $derived(item.cardData as CountdownCardData); 10 + 11 + let now = $state(new Date()); 12 + 13 + onMount(() => { 14 + const interval = setInterval(() => { 15 + now = new Date(); 16 + }, 1000); 17 + return () => clearInterval(interval); 18 + }); 19 + 20 + // Countdown to target date 21 + let eventDiff = $derived.by(() => { 22 + if (!cardData.targetDate) return null; 23 + const target = new Date(cardData.targetDate); 24 + return Math.max(0, target.getTime() - now.getTime()); 25 + }); 26 + 27 + let eventDays = $derived(eventDiff !== null ? Math.floor(eventDiff / (1000 * 60 * 60 * 24)) : 0); 28 + let eventHours = $derived( 29 + eventDiff !== null ? Math.floor((eventDiff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) : 0 30 + ); 31 + let eventMinutes = $derived( 32 + eventDiff !== null ? Math.floor((eventDiff % (1000 * 60 * 60)) / (1000 * 60)) : 0 33 + ); 34 + let eventSeconds = $derived( 35 + eventDiff !== null ? Math.floor((eventDiff % (1000 * 60)) / 1000) : 0 36 + ); 37 + 38 + // Check if event is in the past (elapsed mode) 39 + let isEventPast = $derived.by(() => { 40 + if (!cardData.targetDate) return false; 41 + return now.getTime() > new Date(cardData.targetDate).getTime(); 42 + }); 43 + 44 + // Elapsed time since past event 45 + let elapsedDiff = $derived.by(() => { 46 + if (!isEventPast || !cardData.targetDate) return null; 47 + return now.getTime() - new Date(cardData.targetDate).getTime(); 48 + }); 49 + 50 + let elapsedYears = $derived( 51 + elapsedDiff !== null ? Math.floor(elapsedDiff / (1000 * 60 * 60 * 24 * 365)) : 0 52 + ); 53 + let elapsedDays = $derived( 54 + elapsedDiff !== null 55 + ? Math.floor((elapsedDiff % (1000 * 60 * 60 * 24 * 365)) / (1000 * 60 * 60 * 24)) 56 + : 0 57 + ); 58 + let elapsedHours = $derived( 59 + elapsedDiff !== null ? Math.floor((elapsedDiff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) : 0 60 + ); 61 + let elapsedMinutes = $derived( 62 + elapsedDiff !== null ? Math.floor((elapsedDiff % (1000 * 60 * 60)) / (1000 * 60)) : 0 63 + ); 64 + let elapsedSeconds = $derived( 65 + elapsedDiff !== null ? Math.floor((elapsedDiff % (1000 * 60)) / 1000) : 0 66 + ); 67 + </script> 68 + 69 + <div class="@container flex h-full w-full flex-col items-center justify-center p-4"> 70 + {#if isEventPast && elapsedDiff !== null} 71 + <!-- Elapsed time since past event --> 72 + <NumberFlowGroup> 73 + <div 74 + class="text-base-900 dark:text-base-100 accent:text-base-900 flex items-baseline gap-4 text-center @sm:gap-6 @md:gap-8" 75 + style="font-variant-numeric: tabular-nums;" 76 + > 77 + {#if elapsedYears > 0} 78 + <div class="flex flex-col items-center"> 79 + <NumberFlow 80 + value={elapsedYears} 81 + trend={1} 82 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 83 + /> 84 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs" 85 + >{elapsedYears === 1 ? 'year' : 'years'}</span 86 + > 87 + </div> 88 + {/if} 89 + {#if elapsedYears > 0 || elapsedDays > 0} 90 + <div class="flex flex-col items-center"> 91 + <NumberFlow 92 + value={elapsedDays} 93 + trend={1} 94 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 95 + /> 96 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs" 97 + >{elapsedDays === 1 ? 'day' : 'days'}</span 98 + > 99 + </div> 100 + {/if} 101 + <div class="flex flex-col items-center"> 102 + <NumberFlow 103 + value={elapsedHours} 104 + trend={1} 105 + format={{ minimumIntegerDigits: 2 }} 106 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 107 + /> 108 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">hrs</span> 109 + </div> 110 + <div class="flex flex-col items-center"> 111 + <NumberFlow 112 + value={elapsedMinutes} 113 + trend={1} 114 + format={{ minimumIntegerDigits: 2 }} 115 + digits={{ 1: { max: 5 } }} 116 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 117 + /> 118 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">min</span> 119 + </div> 120 + <div class="flex flex-col items-center"> 121 + <NumberFlow 122 + value={elapsedSeconds} 123 + trend={1} 124 + format={{ minimumIntegerDigits: 2 }} 125 + digits={{ 1: { max: 5 } }} 126 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 127 + /> 128 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">sec</span> 129 + </div> 130 + </div> 131 + </NumberFlowGroup> 132 + {:else if eventDiff !== null} 133 + <!-- Countdown to future event --> 134 + <NumberFlowGroup> 135 + <div 136 + class="text-base-900 dark:text-base-100 accent:text-base-900 flex items-baseline gap-4 text-center @sm:gap-6 @md:gap-8" 137 + style="font-variant-numeric: tabular-nums;" 138 + > 139 + {#if eventDays > 0} 140 + <div class="flex flex-col items-center"> 141 + <NumberFlow 142 + value={eventDays} 143 + trend={-1} 144 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 145 + /> 146 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs" 147 + >{eventDays === 1 ? 'day' : 'days'}</span 148 + > 149 + </div> 150 + {/if} 151 + <div class="flex flex-col items-center"> 152 + <NumberFlow 153 + value={eventHours} 154 + trend={-1} 155 + format={{ minimumIntegerDigits: 2 }} 156 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 157 + /> 158 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">hrs</span> 159 + </div> 160 + <div class="flex flex-col items-center"> 161 + <NumberFlow 162 + value={eventMinutes} 163 + trend={-1} 164 + format={{ minimumIntegerDigits: 2 }} 165 + digits={{ 1: { max: 5 } }} 166 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 167 + /> 168 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">min</span> 169 + </div> 170 + <div class="flex flex-col items-center"> 171 + <NumberFlow 172 + value={eventSeconds} 173 + trend={-1} 174 + format={{ minimumIntegerDigits: 2 }} 175 + digits={{ 1: { max: 5 } }} 176 + class="text-3xl font-bold @xs:text-4xl @sm:text-5xl @md:text-6xl @lg:text-7xl" 177 + /> 178 + <span class="text-base-500 dark:text-base-400 accent:text-accent-950 text-xs">sec</span> 179 + </div> 180 + </div> 181 + </NumberFlowGroup> 182 + {:else} 183 + <div class="text-base-500 text-sm">Set a target date in settings</div> 184 + {/if} 185 + </div>
+44
src/lib/cards/CountdownCard/CountdownCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import { Input, Label } from '@foxui/core'; 4 + import type { CountdownCardData } from './index'; 5 + 6 + let { item }: { item: Item; onclose: () => void } = $props(); 7 + 8 + let cardData = $derived(item.cardData as CountdownCardData); 9 + 10 + let targetDateValue = $derived.by(() => { 11 + if (!cardData.targetDate) return ''; 12 + return new Date(cardData.targetDate).toISOString().split('T')[0]; 13 + }); 14 + 15 + let targetTimeValue = $derived.by(() => { 16 + if (!cardData.targetDate) return '12:00'; 17 + return new Date(cardData.targetDate).toTimeString().slice(0, 5); 18 + }); 19 + 20 + function updateTargetDate(dateStr: string, timeStr: string) { 21 + if (!dateStr) return; 22 + item.cardData.targetDate = new Date(`${dateStr}T${timeStr}`).toISOString(); 23 + } 24 + </script> 25 + 26 + <div class="flex flex-col gap-4"> 27 + <div class="flex flex-col gap-2"> 28 + <Label>Target Date & Time</Label> 29 + <div class="flex gap-2"> 30 + <Input 31 + type="date" 32 + value={targetDateValue} 33 + onchange={(e) => updateTargetDate(e.currentTarget.value, targetTimeValue)} 34 + class="flex-1" 35 + /> 36 + <Input 37 + type="time" 38 + value={targetTimeValue} 39 + onchange={(e) => updateTargetDate(targetDateValue, e.currentTarget.value)} 40 + class="w-28" 41 + /> 42 + </div> 43 + </div> 44 + </div>
+29
src/lib/cards/CountdownCard/index.ts
··· 1 + import type { CardDefinition } from '../types'; 2 + import CountdownCard from './CountdownCard.svelte'; 3 + import CountdownCardSettings from './CountdownCardSettings.svelte'; 4 + 5 + export type CountdownCardData = { 6 + targetDate?: string; 7 + }; 8 + 9 + export const CountdownCardDefinition = { 10 + type: 'countdown', 11 + contentComponent: CountdownCard, 12 + settingsComponent: CountdownCardSettings, 13 + 14 + createNew: (card) => { 15 + card.w = 4; 16 + card.h = 2; 17 + card.mobileW = 8; 18 + card.mobileH = 3; 19 + card.cardData = {} as CountdownCardData; 20 + }, 21 + 22 + allowSetColor: true, 23 + name: 'Countdown', 24 + minW: 4, 25 + canHaveLabel: true, 26 + groups: ['Utilities'], 27 + keywords: ['timer', 'event', 'date', 'countdown'], 28 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z M19.5 4.5l-1.5 1.5M4.5 4.5l1.5 1.5M12 2.25V3.75M9 2.25h6" /></svg>` 29 + } as CardDefinition & { type: 'countdown' };
+3 -1
src/lib/cards/DrawCard/index.ts
··· 25 25 }; 26 26 }, 27 27 28 - groups: ['Visual'] 28 + groups: ['Visual'], 29 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125" /></svg>` 30 + 29 31 } as CardDefinition & { type: 'draw' };
+4 -1
src/lib/cards/EmbedCard/index.ts
··· 19 19 // change: (item) => { 20 20 // return item; 21 21 // }, 22 - name: 'Embed' 22 + name: 'Embed', 23 + groups: ['Media'], 24 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" /></svg>` 25 + 23 26 } as CardDefinition & { type: 'embed' };
+3 -1
src/lib/cards/EventCard/index.ts
··· 114 114 115 115 name: 'Event', 116 116 117 - groups: ['Social'] 117 + groups: ['Social'], 118 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" /></svg>` 119 + 118 120 } as CardDefinition & { type: 'event' };
+3 -1
src/lib/cards/FluidTextCard/index.ts
··· 26 26 minW: 2, 27 27 28 28 groups: ['Visual'], 29 - name: 'Fluid Text' 29 + name: 'Fluid Text', 30 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25H12" /></svg>` 31 + 30 32 } as CardDefinition & { type: 'fluid-text' };
+3 -1
src/lib/cards/GIFCard/index.ts
··· 47 47 urlHandlerPriority: 5, 48 48 name: 'GIF', 49 49 50 - groups: ['Media'] 50 + groups: ['Media'], 51 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12.75 8.25v7.5m-6-3.75h3v3.75m-3-7.5h3M6 20.25h12A2.25 2.25 0 0 0 20.25 18V6A2.25 2.25 0 0 0 18 3.75H6A2.25 2.25 0 0 0 3.75 6v12A2.25 2.25 0 0 0 6 20.25Z" /></svg>` 52 + 51 53 } as CardDefinition & { type: 'gif' };
+3 -1
src/lib/cards/GameCards/DinoGameCard/index.ts
··· 17 17 canHaveLabel: true, 18 18 19 19 groups: ['Games'], 20 - name: 'Dino Game' 20 + name: 'Dino Game', 21 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 0 1-.657.643 48.491 48.491 0 0 1-4.163-.3c-1.228-.158-2.33.895-2.33 2.134v0c0 1.26 1.09 2.22 2.34 2.14a48.089 48.089 0 0 1 3.27-.108c.43 0 .78.348.78.78v0c0 .22-.09.422-.234.577a8.398 8.398 0 0 0-2.07 4.238c-.19 1.14.513 2.163 1.578 2.428a2.07 2.07 0 0 0 2.478-1.41c.203-.636.37-1.294.524-1.947.128-.537.612-.898 1.16-.84 1.378.15 2.782.18 4.17.076 1.156-.087 2.03-1.09 1.883-2.24a8.52 8.52 0 0 0-1.568-3.7A2.01 2.01 0 0 1 18 8.053v0c0-1.064.82-1.98 1.88-2.08A48.678 48.678 0 0 0 24 5.328v0" /></svg>` 22 + 21 23 } as CardDefinition & { type: 'dino-game' };
+3 -1
src/lib/cards/GameCards/TetrisCard/index.ts
··· 23 23 24 24 groups: ['Games'], 25 25 26 - name: 'Tetris' 26 + name: 'Tetris', 27 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M14 4h-4v4H6v4h4v4h4v-4h4V8h-4V4Z" /></svg>` 28 + 27 29 } as CardDefinition & { type: 'tetris' };
+70
src/lib/cards/GitHubProfileCard/CreateGitHubProfileCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + 5 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 6 + 7 + let errorMessage = $state(''); 8 + </script> 9 + 10 + <Modal open={true} closeButton={false}> 11 + <form 12 + onsubmit={() => { 13 + let input = item.cardData.href?.trim(); 14 + if (!input) return; 15 + 16 + let username: string | undefined; 17 + 18 + // Try parsing as URL first 19 + try { 20 + const parsed = new URL(input); 21 + if (/^(www\.)?github\.com$/.test(parsed.hostname)) { 22 + const segments = parsed.pathname.split('/').filter(Boolean); 23 + if ( 24 + segments.length === 1 && 25 + /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(segments[0]) 26 + ) { 27 + username = segments[0]; 28 + } 29 + } 30 + } catch { 31 + // Not a URL, try as plain username 32 + if (/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(input)) { 33 + username = input; 34 + } 35 + } 36 + 37 + if (!username) { 38 + errorMessage = 'Please enter a valid GitHub username or profile URL'; 39 + return; 40 + } 41 + 42 + item.cardData.user = username; 43 + item.cardData.href = `https://github.com/${username}`; 44 + 45 + item.w = 6; 46 + item.mobileW = 8; 47 + item.h = 3; 48 + item.mobileH = 6; 49 + 50 + oncreate?.(); 51 + }} 52 + class="flex flex-col gap-2" 53 + > 54 + <Subheading>Enter a GitHub username or profile URL</Subheading> 55 + <Input 56 + bind:value={item.cardData.href} 57 + placeholder="username or https://github.com/username" 58 + class="mt-4" 59 + /> 60 + 61 + {#if errorMessage} 62 + <p class="mt-2 text-sm text-red-600">{errorMessage}</p> 63 + {/if} 64 + 65 + <div class="mt-4 flex justify-end gap-2"> 66 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 67 + <Button type="submit">Create</Button> 68 + </div> 69 + </form> 70 + </Modal>
+5 -1
src/lib/cards/GitHubProfileCard/index.ts
··· 1 1 import type { CardDefinition } from '../types'; 2 + import CreateGitHubProfileCardModal from './CreateGitHubProfileCardModal.svelte'; 2 3 import type GithubContributionsGraph from './GithubContributionsGraph.svelte'; 3 4 import GitHubProfileCard from './GitHubProfileCard.svelte'; 4 5 import type { GitHubContributionsData } from './types'; ··· 8 9 export const GithubProfileCardDefitition = { 9 10 type: 'githubProfile', 10 11 contentComponent: GitHubProfileCard, 12 + creationModalComponent: CreateGitHubProfileCardModal, 11 13 12 14 loadData: async (items) => { 13 15 const githubData: Record<string, GithubContributionsGraph> = {}; ··· 52 54 }, 53 55 name: 'Github Profile', 54 56 55 - groups: ['Social'] 57 + groups: ['Social'], 58 + icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" /></svg>` 59 + 56 60 } as CardDefinition & { type: 'githubProfile' }; 57 61 58 62 function getGitHubUsername(url: string | undefined): string | undefined {
+3 -1
src/lib/cards/GuestbookCard/index.ts
··· 61 61 return results; 62 62 }, 63 63 name: 'Guestbook', 64 - groups: ['Social'] 64 + groups: ['Social'], 65 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 0 1 6 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 0 1 6-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0 0 18 18a8.967 8.967 0 0 0-6 2.292m0-14.25v14.25" /></svg>` 66 + 65 67 } as CardDefinition & { type: 'guestbook' };
+3 -1
src/lib/cards/LatestBlueskyPostCard/index.ts
··· 22 22 23 23 name: 'Latest Bluesky Post', 24 24 25 - groups: ['Social'] 25 + groups: ['Social'], 26 + icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M6.335 3.836a47.2 47.2 0 0 1 5.354 4.94c.088.093.165.18.232.26a18 18 0 0 1 .232-.26 47.2 47.2 0 0 1 5.355-4.94C18.882 2.687 21.46 1.37 22.553 2.483c.986 1.003.616 4.264.305 5.857-.567 2.902-2.018 4.274-3.703 4.542 2.348.386 4.678 1.96 3.13 5.602-1.97 4.636-7.065 1.763-9.795-.418a3 3 0 0 1-.18-.15 3 3 0 0 1-.18.15c-2.73 2.18-7.825 5.054-9.795.418-1.548-3.643.782-5.216 3.13-5.602C3.98 12.631 2.529 11.26 1.962 8.357c-.311-1.593-.681-4.854.305-5.857C3.361 1.37 5.94 2.687 6.335 3.836Z" /></svg>` 27 + 26 28 } as CardDefinition & { type: 'latestPost' };
+3 -1
src/lib/cards/LivestreamCard/index.ts
··· 82 82 urlHandlerPriority: 5, 83 83 84 84 name: 'Latest Livestream (stream.place)', 85 - groups: ['Media'] 85 + groups: ['Media'], 86 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>` 87 + 86 88 } as CardDefinition & { type: 'latestLivestream' }; 87 89 88 90 export const LivestreamEmbedCardDefitition = {
+37 -23
src/lib/cards/PopfeedReviews/PopfeedReviewsCard.svelte
··· 30 30 </script> 31 31 32 32 <div class="z-10 flex h-full gap-4 overflow-x-scroll p-4"> 33 - {#each feed ?? [] as review (review.uri)} 34 - {#if review.value.rating !== undefined && review.value.posterUrl} 35 - <a 36 - rel="noopener noreferrer" 37 - target="_blank" 38 - class="flex" 39 - href="https://popfeed.social/review/{review.uri}" 40 - > 41 - <div 42 - class="relative flex aspect-[2/3] h-full flex-col items-center justify-end overflow-hidden rounded-xl p-1" 33 + {#if feed && feed.length > 0} 34 + {#each feed as review (review.uri)} 35 + {#if review.value.rating !== undefined && review.value.posterUrl} 36 + <a 37 + rel="noopener noreferrer" 38 + target="_blank" 39 + class="flex" 40 + href="https://popfeed.social/review/{review.uri}" 43 41 > 44 - <img 45 - src={review.value.posterUrl} 46 - alt="" 47 - class="bg-base-200 absolute inset-0 -z-10 h-full w-full object-cover" 48 - /> 49 - 50 42 <div 51 - class="from-base-900/80 absolute right-0 bottom-0 left-0 h-1/3 bg-gradient-to-t via-transparent" 52 - ></div> 43 + class="relative flex aspect-[2/3] h-full flex-col items-center justify-end overflow-hidden rounded-xl p-1" 44 + > 45 + <img 46 + src={review.value.posterUrl} 47 + alt="" 48 + class="bg-base-200 absolute inset-0 -z-10 h-full w-full object-cover" 49 + /> 53 50 54 - <Rating class="z-10 text-lg" rating={review.value.rating} /> 55 - </div> 56 - </a> 57 - {/if} 58 - {/each} 51 + <div 52 + class="from-base-900/80 absolute right-0 bottom-0 left-0 h-1/3 bg-gradient-to-t via-transparent" 53 + ></div> 54 + 55 + <Rating class="z-10 text-lg" rating={review.value.rating} /> 56 + </div> 57 + </a> 58 + {/if} 59 + {/each} 60 + {:else if feed} 61 + <div 62 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full w-full items-center justify-center text-center text-sm" 63 + > 64 + No reviews yet. 65 + </div> 66 + {:else} 67 + <div 68 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full w-full items-center justify-center text-center text-sm" 69 + > 70 + Loading reviews... 71 + </div> 72 + {/if} 59 73 </div>
+3 -1
src/lib/cards/PopfeedReviews/index.ts
··· 21 21 canHaveLabel: true, 22 22 23 23 groups: ['Media'], 24 - name: 'Movie and TV Reviews' 24 + name: 'Movie and TV Reviews', 25 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" /></svg>` 26 + 25 27 } as CardDefinition & { type: 'recentPopfeedReviews' };
+1
src/lib/cards/SectionCard/index.ts
··· 24 24 }, 25 25 26 26 defaultColor: 'transparent', 27 + minW: COLUMNS, 27 28 maxH: 1, 28 29 canResize: false, 29 30 settingsComponent: SectionCardSettings,
+3 -1
src/lib/cards/SpotifyCard/index.ts
··· 42 42 minW: 4, 43 43 minH: 5, 44 44 45 - groups: ['Media'] 45 + groups: ['Media'], 46 + icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4"><path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" /></svg>` 47 + 46 48 } as CardDefinition & { type: typeof cardType }; 47 49 48 50 // Match Spotify album and playlist URLs
+34 -8
src/lib/cards/StandardSiteDocumentListCard/StandardSiteDocumentListCard.svelte
··· 27 27 </script> 28 28 29 29 <div class="flex h-full flex-col gap-10 overflow-y-scroll p-8"> 30 - {#each feed ?? [] as document (document.uri)} 31 - <BlogEntry 32 - title={document.value.title} 33 - description={document.value.description} 34 - date={document.value.publishedAt} 35 - href={document.value.href} 36 - /> 37 - {/each} 30 + {#if feed && feed.length > 0} 31 + {#each feed as document (document.uri)} 32 + <BlogEntry 33 + title={document.value.title} 34 + description={document.value.description} 35 + date={document.value.publishedAt} 36 + href={document.value.href} 37 + /> 38 + {/each} 39 + {:else if feed} 40 + <div 41 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full flex-col items-center justify-center gap-2 text-center text-sm" 42 + > 43 + <span>No blog posts found.</span> 44 + <span> 45 + Create some on <a 46 + href="https://leaflet.pub" 47 + target="_blank" 48 + rel="noopener noreferrer" 49 + class="underline">Leaflet</a 50 + > 51 + or 52 + <a href="https://pckt.pub" target="_blank" rel="noopener noreferrer" class="underline" 53 + >Pckt</a 54 + > 55 + </span> 56 + </div> 57 + {:else} 58 + <div 59 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 60 + > 61 + Loading blog posts... 62 + </div> 63 + {/if} 38 64 </div>
+3 -1
src/lib/cards/StandardSiteDocumentListCard/index.ts
··· 46 46 47 47 name: 'Blog Posts', 48 48 49 - groups: ['Content'] 49 + groups: ['Content'], 50 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" /></svg>` 51 + 50 52 } as CardDefinition & { type: 'site.standard.document list' };
+3 -1
src/lib/cards/StatusphereCard/index.ts
··· 50 50 canHaveLabel: true, 51 51 52 52 name: 'Emoji', 53 - groups: ['Media'] 53 + groups: ['Media'], 54 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M15.182 15.182a4.5 4.5 0 0 1-6.364 0M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM9.75 9.75c0 .414-.168.75-.375.75S9 10.164 9 9.75 9.168 9 9.375 9s.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Zm5.625 0c0 .414-.168.75-.375.75s-.375-.336-.375-.75.168-.75.375-.75.375.336.375.75Zm-.375 0h.008v.015h-.008V9.75Z" /></svg>` 55 + 54 56 } as CardDefinition & { type: 'statusphere' }; 55 57 56 58 export function emojiToNotoAnimatedWebp(emoji: string | undefined): string | undefined {
+22 -8
src/lib/cards/TealFMPlaysCard/TealFMPlaysCard.svelte
··· 85 85 {/snippet} 86 86 87 87 <div class="z-10 flex h-full w-full flex-col gap-4 overflow-y-scroll p-4"> 88 - {#each feed ?? [] as play (play.uri)} 89 - {#if play.value.originUrl} 90 - <a href={play.value.originUrl} target="_blank" rel="noopener noreferrer" class="w-full"> 88 + {#if feed && feed.length > 0} 89 + {#each feed as play (play.uri)} 90 + {#if play.value.originUrl} 91 + <a href={play.value.originUrl} target="_blank" rel="noopener noreferrer" class="w-full"> 92 + {@render musicItem(play)} 93 + </a> 94 + {:else} 91 95 {@render musicItem(play)} 92 - </a> 93 - {:else} 94 - {@render musicItem(play)} 95 - {/if} 96 - {/each} 96 + {/if} 97 + {/each} 98 + {:else if feed} 99 + <div 100 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 101 + > 102 + No recent plays found. 103 + </div> 104 + {:else} 105 + <div 106 + class="text-base-500 dark:text-base-400 accent:text-white/60 flex h-full items-center justify-center text-center text-sm" 107 + > 108 + Loading plays... 109 + </div> 110 + {/if} 97 111 </div>
+3 -1
src/lib/cards/TealFMPlaysCard/index.ts
··· 26 26 27 27 name: 'Teal.fm Plays', 28 28 29 - groups: ['Media'] 29 + groups: ['Media'], 30 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m9 9 10.5-3m0 6.553v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 1 1-.99-3.467l2.31-.66a2.25 2.25 0 0 0 1.632-2.163Zm0 0V2.25L9 5.25v10.303m0 0v3.75a2.25 2.25 0 0 1-1.632 2.163l-1.32.377a1.803 1.803 0 0 1-.99-3.467l2.31-.66A2.25 2.25 0 0 0 9 15.553Z" /></svg>` 31 + 30 32 } as CardDefinition & { type: 'recentTealFMPlays' };
+15 -3
src/lib/cards/TimerCard/index.ts
··· 17 17 type: 'timer', 18 18 contentComponent: TimerCard, 19 19 settingsComponent: TimerCardSettings, 20 - sidebarButtonText: 'Timer', 21 20 22 21 createNew: (card) => { 23 22 card.w = 4; ··· 31 30 }, 32 31 33 32 allowSetColor: true, 34 - name: 'Timer Card', 35 33 minW: 4, 36 34 canHaveLabel: true, 37 - groups: ['Utilities'] 35 + 36 + migrate: (item) => { 37 + const data = item.cardData as TimerCardData; 38 + if (data.mode === 'event') { 39 + item.cardType = 'countdown'; 40 + item.cardData = { targetDate: data.targetDate }; 41 + } else { 42 + item.cardType = 'clock'; 43 + item.cardData = { timezone: data.timezone }; 44 + } 45 + if (data.label) { 46 + item.cardData.label = data.label; 47 + } 48 + } 49 + 38 50 } as CardDefinition & { type: 'timer' };
+3 -1
src/lib/cards/VCardCard/index.ts
··· 123 123 sidebarButtonText: 'vCard', 124 124 allowSetColor: true, 125 125 name: 'vCard Card', 126 - groups: ['Social'] 126 + groups: ['Social'], 127 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M15 9h3.75M15 12h3.75M15 15h3.75M4.5 19.5h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Zm6-10.125a1.875 1.875 0 1 1-3.75 0 1.875 1.875 0 0 1 3.75 0Zm1.294 6.336a6.721 6.721 0 0 1-3.17.789 6.721 6.721 0 0 1-3.168-.789 3.376 3.376 0 0 1 6.338 0Z" /></svg>` 128 + 127 129 } as CardDefinition & { type: 'vcard' };
+3 -1
src/lib/cards/VideoCard/index.ts
··· 59 59 }, 60 60 settingsComponent: VideoCardSettings, 61 61 62 - name: 'Video Card' 62 + name: 'Video', 63 + groups: ['Media'], 64 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" /></svg>` 63 65 } as CardDefinition & { type: 'video' };
+52
src/lib/cards/YoutubeVideoCard/CreateYoutubeCardModal.svelte
··· 1 + <script lang="ts"> 2 + import { Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + import { matcher } from './index'; 5 + 6 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 7 + 8 + let errorMessage = $state(''); 9 + </script> 10 + 11 + <Modal open={true} closeButton={false}> 12 + <form 13 + onsubmit={() => { 14 + const url = item.cardData.href?.trim(); 15 + if (!url) return; 16 + 17 + const id = matcher(url); 18 + if (!id) { 19 + errorMessage = 'Please enter a valid YouTube URL'; 20 + return; 21 + } 22 + 23 + item.cardData.youtubeId = id; 24 + item.cardData.poster = `https://i.ytimg.com/vi/${id}/hqdefault.jpg`; 25 + item.cardData.showInline = true; 26 + 27 + item.w = 4; 28 + item.mobileW = 8; 29 + item.h = 3; 30 + item.mobileH = 5; 31 + 32 + oncreate?.(); 33 + }} 34 + class="flex flex-col gap-2" 35 + > 36 + <Subheading>Enter a YouTube URL</Subheading> 37 + <Input 38 + bind:value={item.cardData.href} 39 + placeholder="https://youtube.com/watch?v=..." 40 + class="mt-4" 41 + /> 42 + 43 + {#if errorMessage} 44 + <p class="mt-2 text-sm text-red-600">{errorMessage}</p> 45 + {/if} 46 + 47 + <div class="mt-4 flex justify-end gap-2"> 48 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 49 + <Button type="submit">Create</Button> 50 + </div> 51 + </form> 52 + </Modal>
+2
src/lib/cards/YoutubeVideoCard/index.ts
··· 1 1 import type { CardDefinition } from '../types'; 2 + import CreateYoutubeCardModal from './CreateYoutubeCardModal.svelte'; 2 3 import YoutubeCard from './YoutubeCard.svelte'; 3 4 import YoutubeCardSettings from './YoutubeCardSettings.svelte'; 4 5 ··· 6 7 type: 'youtubeVideo', 7 8 contentComponent: YoutubeCard, 8 9 settingsComponent: YoutubeCardSettings, 10 + creationModalComponent: CreateYoutubeCardModal, 9 11 createNew: (card) => { 10 12 card.cardType = 'youtubeVideo'; 11 13 card.cardData = {};
+4
src/lib/cards/index.ts
··· 30 30 import { VCardCardDefinition } from './VCardCard'; 31 31 import { DrawCardDefinition } from './DrawCard'; 32 32 import { TimerCardDefinition } from './TimerCard'; 33 + import { ClockCardDefinition } from './ClockCard'; 34 + import { CountdownCardDefinition } from './CountdownCard'; 33 35 import { SpotifyCardDefinition } from './SpotifyCard'; 34 36 import { ButtonCardDefinition } from './ButtonCard'; 35 37 import { GuestbookCardDefinition } from './GuestbookCard'; ··· 69 71 VCardCardDefinition, 70 72 DrawCardDefinition, 71 73 TimerCardDefinition, 74 + ClockCardDefinition, 75 + CountdownCardDefinition, 72 76 SpotifyCardDefinition 73 77 // Model3DCardDefinition 74 78 ] as const;
+95 -5
src/lib/components/card-command/CardCommand.svelte
··· 2 2 import { AllCardDefinitions } from '$lib/cards'; 3 3 import type { CardDefinition } from '$lib/cards/types'; 4 4 import { Command, Dialog } from 'bits-ui'; 5 + import { isTyping } from '$lib/helper'; 5 6 6 7 const CardDefGroups = [ 7 8 'Core', ··· 18 19 19 20 let { 20 21 open = $bindable(false), 21 - onselect 22 - }: { open: boolean; onselect: (cardDef: CardDefinition) => void } = $props(); 22 + onselect, 23 + onlink 24 + }: { 25 + open: boolean; 26 + onselect: (cardDef: CardDefinition) => void; 27 + onlink?: (url: string, cardDef: CardDefinition) => void; 28 + } = $props(); 29 + 30 + let searchValue = $state(''); 31 + 32 + let normalizedUrl = $derived.by(() => { 33 + if (!searchValue || searchValue.length < 8) return ''; 34 + try { 35 + const val = searchValue.trim(); 36 + const urlStr = val.startsWith('http') ? val : `https://${val}`; 37 + const url = new URL(urlStr); 38 + if (!url.hostname.includes('.')) return ''; 39 + return urlStr; 40 + } catch { 41 + return ''; 42 + } 43 + }); 44 + 45 + let urlMatchingCards = $derived.by(() => { 46 + if (!normalizedUrl) return []; 47 + return AllCardDefinitions.filter((d) => d.onUrlHandler) 48 + .filter((d) => { 49 + try { 50 + const testItem = { cardData: {} }; 51 + return d.onUrlHandler!(normalizedUrl, testItem as any); 52 + } catch { 53 + return false; 54 + } 55 + }) 56 + .toSorted((a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0)); 57 + }); 58 + 59 + function selectUrl(cardDef: CardDefinition) { 60 + const url = normalizedUrl; 61 + open = false; 62 + searchValue = ''; 63 + onlink?.(url, cardDef); 64 + } 65 + 66 + function commandFilter(value: string, search: string, keywords?: string[]): number { 67 + if (value.startsWith('url:')) return 1; 68 + const s = search.toLowerCase(); 69 + for (const t of [value, ...(keywords ?? [])]) { 70 + if (t.toLowerCase().includes(s)) return 1; 71 + } 72 + return 0; 73 + } 23 74 24 75 function handleKeydown(e: KeyboardEvent) { 25 76 if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { 77 + e.preventDefault(); 78 + open = true; 79 + } 80 + if (e.key === '+' && !isTyping()) { 26 81 e.preventDefault(); 27 82 open = true; 28 83 } ··· 45 100 bar. 46 101 </Dialog.Description> 47 102 <Command.Root 103 + filter={commandFilter} 48 104 class="border-base-200 dark:border-base-800 mx-auto flex h-full w-full max-w-[90vw] flex-col overflow-hidden rounded-2xl border bg-white dark:bg-black" 49 105 > 50 106 <Command.Input 51 107 class="focus-override placeholder:text-base-900/50 dark:placeholder:text-base-50/50 border-base-200 dark:border-base-800 bg-base-100 mx-1 mt-1 inline-flex truncate rounded-2xl rounded-tl-2xl px-4 text-sm transition-colors focus:ring-0 focus:outline-hidden dark:bg-black" 52 - placeholder="Search for a card..." 108 + placeholder="Search for a card or paste a link..." 109 + oninput={(e) => { 110 + searchValue = e.currentTarget.value; 111 + }} 53 112 /> 113 + 54 114 <Command.List 55 115 class="focus:outline-accent-500/50 max-h-[50vh] overflow-x-hidden overflow-y-auto rounded-br-2xl rounded-bl-2xl bg-white px-2 pb-2 focus:border-0 dark:bg-black" 56 116 > ··· 61 121 No results found. 62 122 </Command.Empty> 63 123 64 - {#each CardDefGroups as group, index} 124 + {#if urlMatchingCards.length > 0} 125 + <Command.Group> 126 + <Command.GroupHeading 127 + class="text-base-600 dark:text-base-400 px-3 pt-3 pb-2 text-xs" 128 + > 129 + Add from link 130 + </Command.GroupHeading> 131 + <Command.GroupItems> 132 + {#each urlMatchingCards as cardDef (cardDef.type)} 133 + <Command.Item 134 + value="url:{cardDef.type}" 135 + onSelect={() => { 136 + selectUrl(cardDef); 137 + }} 138 + class="rounded-button data-selected:bg-accent-500/10 flex h-10 cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm outline-hidden select-none" 139 + > 140 + {#if cardDef.icon} 141 + <div class="text-base-700 dark:text-base-300"> 142 + {@html cardDef.icon} 143 + </div> 144 + {/if} 145 + {cardDef.name} 146 + </Command.Item> 147 + {/each} 148 + </Command.GroupItems> 149 + </Command.Group> 150 + <Command.Separator class="bg-base-900/5 dark:bg-base-50/5 my-1 h-px w-full" /> 151 + {/if} 152 + 153 + {#each CardDefGroups as group, index (group)} 65 154 {#if group && AllCardDefinitions.some((cardDef) => cardDef.groups?.includes(group))} 66 155 <Command.Group> 67 156 <Command.GroupHeading ··· 70 159 {group} 71 160 </Command.GroupHeading> 72 161 <Command.GroupItems> 73 - {#each AllCardDefinitions.filter( (cardDef) => cardDef.groups?.includes(group) ) as cardDef} 162 + {#each AllCardDefinitions.filter( (cardDef) => cardDef.groups?.includes(group) ) as cardDef (cardDef.type)} 74 163 <Command.Item 75 164 onSelect={() => { 76 165 open = false; 166 + searchValue = ''; 77 167 onselect(cardDef); 78 168 }} 79 169 class="rounded-button data-selected:bg-accent-500/10 flex h-10 cursor-pointer items-center gap-2 rounded-xl px-3 py-2.5 text-sm outline-hidden select-none"
+9 -7
src/lib/website/EditBar.svelte
··· 9 9 10 10 let { 11 11 data, 12 - linkValue = $bindable(), 13 - newCard, 14 - addLink, 15 12 16 13 showingMobileView = $bindable(), 17 14 isSaving = $bindable(), ··· 22 19 handleImageInputChange, 23 20 handleVideoInputChange, 24 21 22 + newCard, 23 + addLink, 24 + linkValue = $bindable(''), 25 + 25 26 showCardCommand, 26 27 selectedCard = null, 27 28 isMobile = false, ··· 31 32 onsetsize 32 33 }: { 33 34 data: WebsiteData; 34 - linkValue: string; 35 - newCard: (type: string) => void; 36 - addLink: (url: string) => void; 37 35 38 36 showingMobileView: boolean; 39 37 ··· 45 43 handleImageInputChange: (evt: Event) => void; 46 44 handleVideoInputChange: (evt: Event) => void; 47 45 46 + newCard: (type?: string, cardData?: any) => void; 47 + addLink: (url: string) => void; 48 + linkValue: string; 49 + 48 50 showCardCommand: () => void; 49 51 selectedCard?: Item | null; 50 52 isMobile?: boolean; ··· 55 57 } = $props(); 56 58 57 59 let linkPopoverOpen = $state(false); 58 - 59 60 let imageInputRef: HTMLInputElement | undefined = $state(); 60 61 let videoInputRef: HTMLInputElement | undefined = $state(); 61 62 ··· 140 141 accept="video/*" 141 142 onchange={handleVideoInputChange} 142 143 class="hidden" 144 + id="video-input" 143 145 multiple 144 146 bind:this={videoInputRef} 145 147 />
+21 -6
src/lib/website/EditableWebsite.svelte
··· 538 538 539 539 let linkValue = $state(''); 540 540 541 - function addLink(url: string) { 541 + function addLink(url: string, specificCardDef?: CardDefinition) { 542 542 let link = validateLink(url); 543 543 if (!link) { 544 544 toast.error('invalid link'); ··· 546 546 } 547 547 let item = createEmptyCard(data.page); 548 548 549 + if (specificCardDef?.onUrlHandler?.(link, item)) { 550 + item.cardType = specificCardDef.type; 551 + newItem.item = item; 552 + saveNewItem(); 553 + toast(specificCardDef.name + ' added!'); 554 + return; 555 + } 556 + 549 557 for (const cardDef of AllCardDefinitions.toSorted( 550 558 (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0) 551 559 )) { ··· 557 565 toast(cardDef.name + ' added!'); 558 566 break; 559 567 } 560 - } 561 - 562 - if (linkValue === url) { 563 - linkValue = ''; 564 568 } 565 569 } 566 570 ··· 871 875 input.click(); 872 876 return; 873 877 } 878 + } else if (cardDef.type === 'video') { 879 + const input = document.getElementById('video-input') as HTMLInputElement; 880 + if (input) { 881 + input.click(); 882 + return; 883 + } 874 884 } else { 875 885 newCard(cardDef.type); 876 886 } 887 + }} 888 + onlink={(url, cardDef) => { 889 + addLink(url, cardDef); 877 890 }} 878 891 /> 879 892 ··· 1138 1151 <FloatingEditButton {data} /> 1139 1152 1140 1153 {#if dev} 1141 - <div class="bg-base-900/70 text-base-100 fixed top-2 right-2 z-50 rounded px-2 py-1 font-mono text-xs"> 1154 + <div 1155 + class="bg-base-900/70 text-base-100 fixed top-2 right-2 z-50 rounded px-2 py-1 font-mono text-xs" 1156 + > 1142 1157 editedOn: {editedOn} 1143 1158 </div> 1144 1159 {/if}
+35 -30
src/lib/website/layout-mirror.ts
··· 1 1 import { COLUMNS } from '$lib'; 2 2 import { CardDefinitionsByType } from '$lib/cards'; 3 - import { clamp, fixAllCollisions } from '$lib/helper'; 3 + import { clamp, findValidPosition, fixAllCollisions } from '$lib/helper'; 4 4 import type { Item } from '$lib/types'; 5 5 6 6 /** ··· 23 23 */ 24 24 export function mirrorItemSize(item: Item, fromMobile: boolean): void { 25 25 const def = CardDefinitionsByType[item.cardType]; 26 - const minW = def?.minW ?? 2; 27 - const maxW = def?.maxW ?? COLUMNS; 28 - const minH = def?.minH ?? 2; 29 - const maxH = def?.maxH ?? Infinity; 30 26 31 27 if (fromMobile) { 32 - const srcW = item.mobileW; 33 - const srcH = item.mobileH; 34 - // Full-width cards stay full-width 35 - item.w = srcW >= COLUMNS ? COLUMNS : clamp(snapEven(srcW / 2), minW, maxW); 36 - item.h = clamp(snapEven((srcH * item.w) / srcW), minH, maxH); 28 + // Mobile → Desktop: halve both dimensions, then clamp to card def constraints 29 + // (constraints are in desktop units) 30 + item.w = clamp(snapEven(item.mobileW / 2), def?.minW ?? 2, def?.maxW ?? COLUMNS); 31 + item.h = clamp(Math.round(item.mobileH / 2), def?.minH ?? 1, def?.maxH ?? Infinity); 37 32 } else { 38 - const srcW = item.w; 39 - const srcH = item.h; 40 - // Full-width cards stay full-width 41 - if (srcW >= COLUMNS) { 42 - item.mobileW = clamp(COLUMNS, minW, Math.min(maxW, COLUMNS)); 43 - } else { 44 - const scaleFactor = Math.min(2, COLUMNS / srcW); 45 - item.mobileW = clamp(snapEven(srcW * scaleFactor), minW, Math.min(maxW, COLUMNS)); 46 - } 47 - item.mobileH = clamp(snapEven((srcH * item.mobileW) / srcW), minH, maxH); 33 + // Desktop → Mobile: double both dimensions 34 + // (don't apply card def constraints — they're in desktop units) 35 + item.mobileW = Math.min(item.w * 2, COLUMNS); 36 + item.mobileH = Math.max(item.h * 2, 2); 48 37 } 49 38 } 50 39 ··· 54 43 * Mutates items in-place. 55 44 */ 56 45 export function mirrorLayout(items: Item[], fromMobile: boolean): void { 46 + // Mirror sizes first 57 47 for (const item of items) { 58 48 mirrorItemSize(item, fromMobile); 49 + } 59 50 60 - if (fromMobile) { 61 - // Mobile → Desktop positions 62 - item.x = clamp(Math.floor(item.mobileX / 2 / 2) * 2, 0, COLUMNS - item.w); 63 - item.y = Math.max(0, Math.round(item.mobileY / 2)); 64 - } else { 65 - // Desktop → Mobile positions 66 - item.mobileX = clamp(Math.floor((item.x * 2) / 2) * 2, 0, COLUMNS - item.mobileW); 51 + if (fromMobile) { 52 + // Mobile → Desktop: reflow items to use the full grid width. 53 + // Sort by mobile position so items are placed in reading order. 54 + const sorted = items.toSorted( 55 + (a, b) => a.mobileY - b.mobileY || a.mobileX - b.mobileX 56 + ); 57 + 58 + // Place each item into the first available spot on the desktop grid 59 + const placed: Item[] = []; 60 + for (const item of sorted) { 61 + item.x = 0; 62 + item.y = 0; 63 + findValidPosition(item, placed, false); 64 + placed.push(item); 65 + } 66 + } else { 67 + // Desktop → Mobile: proportional positions 68 + for (const item of items) { 69 + item.mobileX = clamp( 70 + Math.floor((item.x * 2) / 2) * 2, 71 + 0, 72 + COLUMNS - item.mobileW 73 + ); 67 74 item.mobileY = Math.max(0, Math.round(item.y * 2)); 68 75 } 76 + fixAllCollisions(items, true); 69 77 } 70 - 71 - // Resolve collisions on the target layout 72 - fixAllCollisions(items, !fromMobile); 73 78 }