your personal website on atproto - mirror blento.app

commandbar changes

+1210 -266
+2
src/app.css
··· 3 @plugin '@tailwindcss/forms'; 4 @plugin '@tailwindcss/typography'; 5 6 @source '../node_modules/@foxui'; 7 8 @custom-variant dark (&:where(.dark, .dark *):not(:where(.light, .light *)));
··· 3 @plugin '@tailwindcss/forms'; 4 @plugin '@tailwindcss/typography'; 5 6 + @plugin 'tailwindcss-animate'; 7 + 8 @source '../node_modules/@foxui'; 9 10 @custom-variant dark (&:where(.dark, .dark *):not(:where(.light, .light *)));
+19 -5
src/lib/cards/ATProtoCollectionsCard/ATProtoCollectionsCard.svelte
··· 39 <Badge size="md" class="accent:text-accent-950">{collections.length}</Badge> 40 {/if} 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> 47 </div>
··· 39 <Badge size="md" class="accent:text-accent-950">{collections.length}</Badge> 40 {/if} 41 </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} 61 </div>
+6 -1
src/lib/cards/ATProtoCollectionsCard/index.ts
··· 19 item.w = 4; 20 item.mobileW = 8; 21 }, 22 - sidebarButtonText: 'Atmosphere Collections' 23 } as CardDefinition & { type: 'atprotocollections' };
··· 19 item.w = 4; 20 item.mobileW = 8; 21 }, 22 + sidebarButtonText: 'Atmosphere Collections', 23 + 24 + name: 'ATProto Collections', 25 + 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 } as CardDefinition & { type: 'atprotocollections' };
+4 -1
src/lib/cards/BigSocialCard/index.ts
··· 51 return item; 52 }, 53 urlHandlerPriority: 1, 54 - canHaveLabel: true 55 } as CardDefinition & { type: 'bigsocial' }; 56 57 import {
··· 51 return item; 52 }, 53 urlHandlerPriority: 1, 54 + canHaveLabel: true, 55 + 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 } as CardDefinition & { type: 'bigsocial' }; 59 60 import {
+6 -1
src/lib/cards/BlueskyMediaCard/index.ts
··· 8 createNew: () => {}, 9 creationModalComponent: CreateBlueskyMediaCardModal, 10 sidebarButtonText: 'Bluesky Media', 11 - canHaveLabel: true 12 } as CardDefinition & { type: 'blueskyMedia' };
··· 8 createNew: () => {}, 9 creationModalComponent: CreateBlueskyMediaCardModal, 10 sidebarButtonText: 'Bluesky Media', 11 + canHaveLabel: true, 12 + 13 + groups: ['Media'], 14 + 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 } as CardDefinition & { type: 'blueskyMedia' };
+4 -1
src/lib/cards/BlueskyPostCard/index.ts
··· 63 return postsMap; 64 }, 65 minW: 4, 66 - name: 'Bluesky Post' 67 } as CardDefinition & { type: 'blueskyPost' };
··· 63 return postsMap; 64 }, 65 minW: 4, 66 + name: 'Bluesky Post', 67 + 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 } as CardDefinition & { type: 'blueskyPost' };
+5 -1
src/lib/cards/ButtonCard/index.ts
··· 27 minW: 2, 28 minH: 1, 29 maxW: 8, 30 - maxH: 4 31 };
··· 27 minW: 2, 28 minH: 1, 29 maxW: 8, 30 + maxH: 4, 31 + 32 + groups: ['Utilities'], 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 };
+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' };
+4 -1
src/lib/cards/DrawCard/index.ts
··· 23 strokeWidth: 1, 24 locked: true 25 }; 26 - } 27 } as CardDefinition & { type: 'draw' };
··· 23 strokeWidth: 1, 24 locked: true 25 }; 26 + }, 27 + 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 } as CardDefinition & { type: 'draw' };
+3 -1
src/lib/cards/EmbedCard/index.ts
··· 19 // change: (item) => { 20 // return item; 21 // }, 22 - name: 'Embed Card' 23 } as CardDefinition & { type: 'embed' };
··· 19 // change: (item) => { 20 // return item; 21 // }, 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 } as CardDefinition & { type: 'embed' };
+4 -1
src/lib/cards/EventCard/index.ts
··· 112 113 urlHandlerPriority: 5, 114 115 - name: 'Event Card' 116 } as CardDefinition & { type: 'event' };
··· 112 113 urlHandlerPriority: 5, 114 115 + name: 'Event', 116 + 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 } as CardDefinition & { type: 'event' };
+5 -1
src/lib/cards/FluidTextCard/index.ts
··· 23 sidebarButtonText: 'Fluid Text', 24 defaultColor: 'transparent', 25 allowSetColor: true, 26 - minW: 2 27 } as CardDefinition & { type: 'fluid-text' };
··· 23 sidebarButtonText: 'Fluid Text', 24 defaultColor: 'transparent', 25 allowSetColor: true, 26 + minW: 2, 27 + 28 + groups: ['Visual'], 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 } as CardDefinition & { type: 'fluid-text' };
+4 -1
src/lib/cards/GIFCard/index.ts
··· 45 return null; 46 }, 47 urlHandlerPriority: 5, 48 - name: 'GIF' 49 } as CardDefinition & { type: 'gif' };
··· 45 return null; 46 }, 47 urlHandlerPriority: 5, 48 + name: 'GIF', 49 + 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 } as CardDefinition & { type: 'gif' };
+5 -1
src/lib/cards/GameCards/DinoGameCard/index.ts
··· 14 card.mobileH = 6; 15 card.cardData = {}; 16 }, 17 - canHaveLabel: true 18 } as CardDefinition & { type: 'dino-game' };
··· 14 card.mobileH = 6; 15 card.cardData = {}; 16 }, 17 + canHaveLabel: true, 18 + 19 + groups: ['Games'], 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 } as CardDefinition & { type: 'dino-game' };
+6 -1
src/lib/cards/GameCards/TetrisCard/index.ts
··· 19 card.cardData = {}; 20 }, 21 maxH: 10, 22 - canHaveLabel: true 23 } as CardDefinition & { type: 'tetris' };
··· 19 card.cardData = {}; 20 }, 21 maxH: 10, 22 + canHaveLabel: true, 23 + 24 + groups: ['Games'], 25 + 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 } 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>
+6 -1
src/lib/cards/GitHubProfileCard/index.ts
··· 1 import type { CardDefinition } from '../types'; 2 import type GithubContributionsGraph from './GithubContributionsGraph.svelte'; 3 import GitHubProfileCard from './GitHubProfileCard.svelte'; 4 import type { GitHubContributionsData } from './types'; ··· 8 export const GithubProfileCardDefitition = { 9 type: 'githubProfile', 10 contentComponent: GitHubProfileCard, 11 12 loadData: async (items) => { 13 const githubData: Record<string, GithubContributionsGraph> = {}; ··· 50 51 return item; 52 }, 53 - name: 'Github Profile' 54 } as CardDefinition & { type: 'githubProfile' }; 55 56 function getGitHubUsername(url: string | undefined): string | undefined {
··· 1 import type { CardDefinition } from '../types'; 2 + import CreateGitHubProfileCardModal from './CreateGitHubProfileCardModal.svelte'; 3 import type GithubContributionsGraph from './GithubContributionsGraph.svelte'; 4 import GitHubProfileCard from './GitHubProfileCard.svelte'; 5 import type { GitHubContributionsData } from './types'; ··· 9 export const GithubProfileCardDefitition = { 10 type: 'githubProfile', 11 contentComponent: GitHubProfileCard, 12 + creationModalComponent: CreateGitHubProfileCardModal, 13 14 loadData: async (items) => { 15 const githubData: Record<string, GithubContributionsGraph> = {}; ··· 52 53 return item; 54 }, 55 + name: 'Github Profile', 56 + 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 } as CardDefinition & { type: 'githubProfile' }; 60 61 function getGitHubUsername(url: string | undefined): string | undefined {
+3 -1
src/lib/cards/GuestbookCard/index.ts
··· 60 61 return results; 62 }, 63 - name: 'Guestbook' 64 } as CardDefinition & { type: 'guestbook' };
··· 60 61 return results; 62 }, 63 + name: 'Guestbook', 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 } as CardDefinition & { type: 'guestbook' };
+19 -2
src/lib/cards/ImageCard/index.ts
··· 42 }, 43 urlHandlerPriority: 3, 44 45 - name: 'Image Card', 46 47 - canHaveLabel: true 48 } as CardDefinition & { type: 'image' };
··· 42 }, 43 urlHandlerPriority: 3, 44 45 + name: 'Image', 46 + 47 + canHaveLabel: true, 48 + 49 + groups: ['Core'], 50 51 + icon: `<svg 52 + xmlns="http://www.w3.org/2000/svg" 53 + fill="none" 54 + viewBox="0 0 24 24" 55 + stroke-width="2" 56 + stroke="currentColor" 57 + class="size-4" 58 + > 59 + <path 60 + stroke-linecap="round" 61 + stroke-linejoin="round" 62 + d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" 63 + /> 64 + </svg>` 65 } as CardDefinition & { type: 'image' };
+6 -1
src/lib/cards/LatestBlueskyPostCard/index.ts
··· 18 19 return JSON.parse(JSON.stringify(authorFeed)); 20 }, 21 - minW: 4 22 } as CardDefinition & { type: 'latestPost' };
··· 18 19 return JSON.parse(JSON.stringify(authorFeed)); 20 }, 21 + minW: 4, 22 + 23 + name: 'Latest Bluesky Post', 24 + 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 } as CardDefinition & { type: 'latestPost' };
+44
src/lib/cards/LinkCard/CreateLinkCardModal.svelte
···
··· 1 + <script lang="ts"> 2 + import { Button, Input, Modal, Subheading } from '@foxui/core'; 3 + import type { CreationModalComponentProps } from '../types'; 4 + import { validateLink } from '$lib/helper'; 5 + 6 + let { item = $bindable(), oncreate, oncancel }: CreationModalComponentProps = $props(); 7 + 8 + let isFetchingLocation = $state(false); 9 + 10 + let errorMessage = $state(''); 11 + </script> 12 + 13 + <Modal open={true} closeButton={false}> 14 + <form 15 + onsubmit={() => { 16 + if (!item.cardData.href.trim()) return; 17 + 18 + let link = validateLink(item.cardData.href); 19 + if (!link) { 20 + errorMessage = 'Invalid link'; 21 + return; 22 + } 23 + 24 + item.cardData.href = link; 25 + item.cardData.domain = new URL(link).hostname; 26 + item.cardData.hasFetched = false; 27 + 28 + oncreate?.(); 29 + }} 30 + class="flex flex-col gap-2" 31 + > 32 + <Subheading>Enter a link</Subheading> 33 + <Input bind:value={item.cardData.href} class="mt-4" /> 34 + 35 + {#if errorMessage} 36 + <p class="mt-2 text-sm text-red-600">{errorMessage}</p> 37 + {/if} 38 + 39 + <div class="mt-4 flex justify-end gap-2"> 40 + <Button onclick={oncancel} variant="ghost">Cancel</Button> 41 + <Button type="submit" disabled={isFetchingLocation}>Create</Button> 42 + </div> 43 + </form> 44 + </Modal>
+22 -2
src/lib/cards/LinkCard/index.ts
··· 1 import { checkAndUploadImage, validateLink } from '$lib/helper'; 2 import type { CardDefinition } from '../types'; 3 import EditingLinkCard from './EditingLinkCard.svelte'; 4 import LinkCard from './LinkCard.svelte'; 5 import LinkCardSettings from './LinkCardSettings.svelte'; ··· 13 }, 14 settingsComponent: LinkCardSettings, 15 16 - name: 'Link Card', 17 canChange: (item) => Boolean(validateLink(item.cardData?.href)), 18 change: (item) => { 19 const href = validateLink(item.cardData?.href); ··· 36 await checkAndUploadImage(item.cardData, 'favicon'); 37 return item; 38 }, 39 - urlHandlerPriority: 0 40 } as CardDefinition & { type: 'link' };
··· 1 import { checkAndUploadImage, validateLink } from '$lib/helper'; 2 import type { CardDefinition } from '../types'; 3 + import CreateLinkCardModal from './CreateLinkCardModal.svelte'; 4 import EditingLinkCard from './EditingLinkCard.svelte'; 5 import LinkCard from './LinkCard.svelte'; 6 import LinkCardSettings from './LinkCardSettings.svelte'; ··· 14 }, 15 settingsComponent: LinkCardSettings, 16 17 + creationModalComponent: CreateLinkCardModal, 18 + 19 + name: 'Link', 20 canChange: (item) => Boolean(validateLink(item.cardData?.href)), 21 change: (item) => { 22 const href = validateLink(item.cardData?.href); ··· 39 await checkAndUploadImage(item.cardData, 'favicon'); 40 return item; 41 }, 42 + urlHandlerPriority: 0, 43 + 44 + groups: ['Core'], 45 + 46 + icon: `<svg 47 + xmlns="http://www.w3.org/2000/svg" 48 + fill="none" 49 + viewBox="-2 -2 28 28" 50 + stroke-width="2" 51 + stroke="currentColor" 52 + class="size-4" 53 + > 54 + <path 55 + stroke-linecap="round" 56 + stroke-linejoin="round" 57 + d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 58 + /> 59 + </svg>` 60 } as CardDefinition & { type: 'link' };
+3 -1
src/lib/cards/LivestreamCard/index.ts
··· 81 82 urlHandlerPriority: 5, 83 84 - name: 'stream.place Card' 85 } as CardDefinition & { type: 'latestLivestream' }; 86 87 export const LivestreamEmbedCardDefitition = {
··· 81 82 urlHandlerPriority: 5, 83 84 + name: 'Latest Livestream (stream.place)', 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 } as CardDefinition & { type: 'latestLivestream' }; 88 89 export const LivestreamEmbedCardDefitition = {
+10 -1
src/lib/cards/MapCard/index.ts
··· 17 creationModalComponent: CreateMapCardModal, 18 allowSetColor: false, 19 canHaveLabel: true, 20 - settingsComponent: MapCardSettings 21 } as CardDefinition & { type: 'mapLocation' }; 22 23 export function getZoomLevel(type: string | undefined): number {
··· 17 creationModalComponent: CreateMapCardModal, 18 allowSetColor: false, 19 canHaveLabel: true, 20 + settingsComponent: MapCardSettings, 21 + 22 + groups: ['Core'], 23 + 24 + name: 'Map', 25 + 26 + icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"> 27 + <path stroke-linecap="round" stroke-linejoin="round" d="M9 6.75V15m6-6v8.25m.503 3.498 4.875-2.437c.381-.19.622-.58.622-1.006V4.82c0-.836-.88-1.38-1.628-1.006l-3.869 1.934c-.317.159-.69.159-1.006 0L9.503 3.252a1.125 1.125 0 0 0-1.006 0L3.622 5.689C3.24 5.88 3 6.27 3 6.695V19.18c0 .836.88 1.38 1.628 1.006l3.869-1.934c.317-.159.69-.159 1.006 0l4.994 2.497c.317.158.69.158 1.006 0Z" /> 28 + </svg> 29 + ` 30 } as CardDefinition & { type: 'mapLocation' }; 31 32 export function getZoomLevel(type: string | undefined): number {
+37 -23
src/lib/cards/PopfeedReviews/PopfeedReviewsCard.svelte
··· 30 </script> 31 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" 43 > 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 <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> 53 54 - <Rating class="z-10 text-lg" rating={review.value.rating} /> 55 - </div> 56 - </a> 57 - {/if} 58 - {/each} 59 </div>
··· 30 </script> 31 32 <div class="z-10 flex h-full gap-4 overflow-x-scroll p-4"> 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}" 41 > 42 <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 + /> 50 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} 73 </div>
+5 -1
src/lib/cards/PopfeedReviews/index.ts
··· 18 }, 19 minH: 3, 20 sidebarButtonText: 'Popfeed Reviews', 21 - canHaveLabel: true 22 } as CardDefinition & { type: 'recentPopfeedReviews' };
··· 18 }, 19 minH: 3, 20 sidebarButtonText: 'Popfeed Reviews', 21 + canHaveLabel: true, 22 + 23 + groups: ['Media'], 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 } as CardDefinition & { type: 'recentPopfeedReviews' };
+16 -1
src/lib/cards/SectionCard/index.ts
··· 26 defaultColor: 'transparent', 27 maxH: 1, 28 canResize: false, 29 - settingsComponent: SectionCardSettings 30 } as CardDefinition & { type: 'section' }; 31 32 export const textAlignClasses: Record<string, string> = {
··· 26 defaultColor: 'transparent', 27 maxH: 1, 28 canResize: false, 29 + settingsComponent: SectionCardSettings, 30 + 31 + name: 'Heading', 32 + groups: ['Core'], 33 + 34 + icon: `<svg 35 + xmlns="http://www.w3.org/2000/svg" 36 + viewBox="0 0 24 24" 37 + fill="none" 38 + stroke="currentColor" 39 + stroke-width="2" 40 + stroke-linecap="round" 41 + stroke-linejoin="round" 42 + class="size-4" 43 + ><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></svg 44 + >` 45 } as CardDefinition & { type: 'section' }; 46 47 export const textAlignClasses: Record<string, string> = {
+4 -1
src/lib/cards/SpotifyCard/index.ts
··· 40 name: 'Spotify Embed', 41 canResize: true, 42 minW: 4, 43 - minH: 5 44 } as CardDefinition & { type: typeof cardType }; 45 46 // Match Spotify album and playlist URLs
··· 40 name: 'Spotify Embed', 41 canResize: true, 42 minW: 4, 43 + minH: 5, 44 + 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 } as CardDefinition & { type: typeof cardType }; 48 49 // Match Spotify album and playlist URLs
+34 -8
src/lib/cards/StandardSiteDocumentListCard/StandardSiteDocumentListCard.svelte
··· 27 </script> 28 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} 38 </div>
··· 27 </script> 28 29 <div class="flex h-full flex-col gap-10 overflow-y-scroll p-8"> 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} 64 </div>
+6 -1
src/lib/cards/StandardSiteDocumentListCard/index.ts
··· 42 return records; 43 }, 44 45 - sidebarButtonText: 'site.standard.document list' 46 } as CardDefinition & { type: 'site.standard.document list' };
··· 42 return records; 43 }, 44 45 + sidebarButtonText: 'site.standard.document list', 46 + 47 + name: 'Blog Posts', 48 + 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 } as CardDefinition & { type: 'site.standard.document list' };
+5 -1
src/lib/cards/StatusphereCard/index.ts
··· 47 item.cardData.label = item.cardData.title; 48 } 49 }, 50 - canHaveLabel: true 51 } as CardDefinition & { type: 'statusphere' }; 52 53 export function emojiToNotoAnimatedWebp(emoji: string | undefined): string | undefined {
··· 47 item.cardData.label = item.cardData.title; 48 } 49 }, 50 + canHaveLabel: true, 51 + 52 + name: 'Emoji', 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 } as CardDefinition & { type: 'statusphere' }; 56 57 export function emojiToNotoAnimatedWebp(emoji: string | undefined): string | undefined {
+22 -8
src/lib/cards/TealFMPlaysCard/TealFMPlaysCard.svelte
··· 85 {/snippet} 86 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"> 91 {@render musicItem(play)} 92 - </a> 93 - {:else} 94 - {@render musicItem(play)} 95 - {/if} 96 - {/each} 97 </div>
··· 85 {/snippet} 86 87 <div class="z-10 flex h-full w-full flex-col gap-4 overflow-y-scroll p-4"> 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} 95 {@render musicItem(play)} 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} 111 </div>
+6 -1
src/lib/cards/TealFMPlaysCard/index.ts
··· 22 }, 23 minW: 4, 24 sidebarButtonText: 'teal.fm Plays', 25 - canHaveLabel: true 26 } as CardDefinition & { type: 'recentTealFMPlays' };
··· 22 }, 23 minW: 4, 24 sidebarButtonText: 'teal.fm Plays', 25 + canHaveLabel: true, 26 + 27 + name: 'Teal.fm Plays', 28 + 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 } as CardDefinition & { type: 'recentTealFMPlays' };
+16 -1
src/lib/cards/TextCard/index.ts
··· 14 }; 15 }, 16 17 - settingsComponent: TextCardSettings 18 } as CardDefinition & { type: 'text' }; 19 20 export const textAlignClasses: Record<string, string> = {
··· 14 }; 15 }, 16 17 + settingsComponent: TextCardSettings, 18 + 19 + name: 'Text', 20 + 21 + groups: ['Core'], 22 + 23 + icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-4" 24 + ><path 25 + fill="none" 26 + stroke="currentColor" 27 + stroke-linecap="round" 28 + stroke-linejoin="round" 29 + stroke-width="2" 30 + d="m15 16l2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16m-6.303-2h5.606M2 16l4.039-9.69a.5.5 0 0 1 .923 0L11 16m-7.696-3h6.392" 31 + /></svg 32 + >` 33 } as CardDefinition & { type: 'text' }; 34 35 export const textAlignClasses: Record<string, string> = {
+15 -3
src/lib/cards/TimerCard/index.ts
··· 17 type: 'timer', 18 contentComponent: TimerCard, 19 settingsComponent: TimerCardSettings, 20 - sidebarButtonText: 'Timer', 21 22 createNew: (card) => { 23 card.w = 4; ··· 31 }, 32 33 allowSetColor: true, 34 - name: 'Timer Card', 35 minW: 4, 36 - canHaveLabel: true 37 } as CardDefinition & { type: 'timer' };
··· 17 type: 'timer', 18 contentComponent: TimerCard, 19 settingsComponent: TimerCardSettings, 20 21 createNew: (card) => { 22 card.w = 4; ··· 30 }, 31 32 allowSetColor: true, 33 minW: 4, 34 + canHaveLabel: true, 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 } as CardDefinition & { type: 'timer' };
+3 -1
src/lib/cards/VCardCard/index.ts
··· 122 123 sidebarButtonText: 'vCard', 124 allowSetColor: true, 125 - name: 'vCard Card' 126 } as CardDefinition & { type: 'vcard' };
··· 122 123 sidebarButtonText: 'vCard', 124 allowSetColor: true, 125 + name: 'vCard Card', 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 } as CardDefinition & { type: 'vcard' };
+3 -1
src/lib/cards/VideoCard/index.ts
··· 59 }, 60 settingsComponent: VideoCardSettings, 61 62 - name: 'Video Card' 63 } as CardDefinition & { type: 'video' };
··· 59 }, 60 settingsComponent: VideoCardSettings, 61 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>` 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>
+12 -1
src/lib/cards/YoutubeVideoCard/index.ts
··· 1 import type { CardDefinition } from '../types'; 2 import YoutubeCard from './YoutubeCard.svelte'; 3 import YoutubeCardSettings from './YoutubeCardSettings.svelte'; 4 ··· 6 type: 'youtubeVideo', 7 contentComponent: YoutubeCard, 8 settingsComponent: YoutubeCardSettings, 9 createNew: (card) => { 10 card.cardType = 'youtubeVideo'; 11 card.cardData = {}; ··· 51 52 return item; 53 }, 54 - name: 'Youtube Video' 55 } as CardDefinition & { type: 'youtubeVideo' }; 56 57 // Thanks to eleventy-plugin-youtube-embed
··· 1 import type { CardDefinition } from '../types'; 2 + import CreateYoutubeCardModal from './CreateYoutubeCardModal.svelte'; 3 import YoutubeCard from './YoutubeCard.svelte'; 4 import YoutubeCardSettings from './YoutubeCardSettings.svelte'; 5 ··· 7 type: 'youtubeVideo', 8 contentComponent: YoutubeCard, 9 settingsComponent: YoutubeCardSettings, 10 + creationModalComponent: CreateYoutubeCardModal, 11 createNew: (card) => { 12 card.cardType = 'youtubeVideo'; 13 card.cardData = {}; ··· 53 54 return item; 55 }, 56 + name: 'Youtube Video', 57 + 58 + groups: ['Media'], 59 + 60 + icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-3" viewBox="0 0 256 180" 61 + ><path 62 + fill="currentColor" 63 + d="M250.346 28.075A32.18 32.18 0 0 0 227.69 5.418C207.824 0 127.87 0 127.87 0S47.912.164 28.046 5.582A32.18 32.18 0 0 0 5.39 28.24c-6.009 35.298-8.34 89.084.165 122.97a32.18 32.18 0 0 0 22.656 22.657c19.866 5.418 99.822 5.418 99.822 5.418s79.955 0 99.82-5.418a32.18 32.18 0 0 0 22.657-22.657c6.338-35.348 8.291-89.1-.164-123.134" 64 + /><path fill="currentColor" class="invert" d="m102.421 128.06l66.328-38.418l-66.328-38.418z" /></svg 65 + >` 66 } as CardDefinition & { type: 'youtubeVideo' }; 67 68 // Thanks to eleventy-plugin-youtube-embed
+4
src/lib/cards/index.ts
··· 30 import { VCardCardDefinition } from './VCardCard'; 31 import { DrawCardDefinition } from './DrawCard'; 32 import { TimerCardDefinition } from './TimerCard'; 33 import { SpotifyCardDefinition } from './SpotifyCard'; 34 import { ButtonCardDefinition } from './ButtonCard'; 35 import { GuestbookCardDefinition } from './GuestbookCard'; ··· 69 VCardCardDefinition, 70 DrawCardDefinition, 71 TimerCardDefinition, 72 SpotifyCardDefinition 73 // Model3DCardDefinition 74 ] as const;
··· 30 import { VCardCardDefinition } from './VCardCard'; 31 import { DrawCardDefinition } from './DrawCard'; 32 import { TimerCardDefinition } from './TimerCard'; 33 + import { ClockCardDefinition } from './ClockCard'; 34 + import { CountdownCardDefinition } from './CountdownCard'; 35 import { SpotifyCardDefinition } from './SpotifyCard'; 36 import { ButtonCardDefinition } from './ButtonCard'; 37 import { GuestbookCardDefinition } from './GuestbookCard'; ··· 71 VCardCardDefinition, 72 DrawCardDefinition, 73 TimerCardDefinition, 74 + ClockCardDefinition, 75 + CountdownCardDefinition, 76 SpotifyCardDefinition 77 // Model3DCardDefinition 78 ] as const;
+6
src/lib/cards/types.ts
··· 73 canHaveLabel?: boolean; 74 75 migrate?: (item: Item) => void; 76 };
··· 73 canHaveLabel?: boolean; 74 75 migrate?: (item: Item) => void; 76 + 77 + groups?: string[]; 78 + 79 + keywords?: string[]; 80 + 81 + icon?: string; 82 };
+192
src/lib/components/card-command/CardCommand.svelte
···
··· 1 + <script lang="ts"> 2 + import { AllCardDefinitions } from '$lib/cards'; 3 + import type { CardDefinition } from '$lib/cards/types'; 4 + import { Command, Dialog } from 'bits-ui'; 5 + import { isTyping } from '$lib/helper'; 6 + 7 + const CardDefGroups = [ 8 + 'Core', 9 + ...Array.from( 10 + new Set( 11 + AllCardDefinitions.map((cardDef) => cardDef.groups) 12 + .flat() 13 + .filter((g) => g) 14 + ) 15 + ) 16 + .sort() 17 + .filter((g) => g !== 'Core') 18 + ]; 19 + 20 + let { 21 + open = $bindable(false), 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 + } 74 + 75 + function handleKeydown(e: KeyboardEvent) { 76 + if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { 77 + e.preventDefault(); 78 + open = true; 79 + } 80 + if (e.key === '+' && !isTyping()) { 81 + e.preventDefault(); 82 + open = true; 83 + } 84 + } 85 + </script> 86 + 87 + <svelte:document onkeydown={handleKeydown} /> 88 + 89 + <Dialog.Root bind:open> 90 + <Dialog.Portal> 91 + <Dialog.Overlay 92 + class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80" 93 + /> 94 + <Dialog.Content 95 + class="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-36 left-[50%] z-50 w-full max-w-[94%] translate-x-[-50%] outline-hidden sm:max-w-lg md:w-full" 96 + > 97 + <Dialog.Title class="sr-only">Command Menu</Dialog.Title> 98 + <Dialog.Description class="sr-only"> 99 + This is the command menu. Use the arrow keys to navigate and press ⌘K to open the search 100 + bar. 101 + </Dialog.Description> 102 + <Command.Root 103 + filter={commandFilter} 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" 105 + > 106 + <Command.Input 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" 108 + placeholder="Search for a card or paste a link..." 109 + oninput={(e) => { 110 + searchValue = e.currentTarget.value; 111 + }} 112 + /> 113 + 114 + <Command.List 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" 116 + > 117 + <Command.Viewport> 118 + <Command.Empty 119 + class="text-base-900 dark:text-base-100 flex w-full items-center justify-center pt-8 pb-6 text-sm" 120 + > 121 + No results found. 122 + </Command.Empty> 123 + 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)} 154 + {#if group && AllCardDefinitions.some((cardDef) => cardDef.groups?.includes(group))} 155 + <Command.Group> 156 + <Command.GroupHeading 157 + class="text-base-600 dark:text-base-400 px-3 pt-4 pb-2 text-xs" 158 + > 159 + {group} 160 + </Command.GroupHeading> 161 + <Command.GroupItems> 162 + {#each AllCardDefinitions.filter( (cardDef) => cardDef.groups?.includes(group) ) as cardDef (cardDef.type)} 163 + <Command.Item 164 + onSelect={() => { 165 + open = false; 166 + searchValue = ''; 167 + onselect(cardDef); 168 + }} 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" 170 + keywords={[group, cardDef.type, ...(cardDef.keywords || [])]} 171 + > 172 + {#if cardDef.icon} 173 + <div class="text-base-700 dark:text-base-300"> 174 + {@html cardDef.icon} 175 + </div> 176 + {/if} 177 + {cardDef.name} 178 + </Command.Item> 179 + {/each} 180 + </Command.GroupItems> 181 + </Command.Group> 182 + {#if index < CardDefGroups.length - 1} 183 + <Command.Separator class="bg-base-900/5 dark:bg-base-50/5 my-1 h-px w-full" /> 184 + {/if} 185 + {/if} 186 + {/each} 187 + </Command.Viewport> 188 + </Command.List> 189 + </Command.Root> 190 + </Dialog.Content> 191 + </Dialog.Portal> 192 + </Dialog.Root>
+4 -175
src/lib/website/EditBar.svelte
··· 2 import { dev } from '$app/environment'; 3 import { user } from '$lib/atproto'; 4 import type { WebsiteData } from '$lib/types'; 5 - import { Button, Input, Navbar, Popover, Toggle, toast } from '@foxui/core'; 6 7 let { 8 data, 9 - linkValue = $bindable(), 10 - newCard, 11 - addLink, 12 13 showingMobileView = $bindable(), 14 isSaving = $bindable(), ··· 16 17 save, 18 19 - handleImageInputChange, 20 - handleVideoInputChange 21 }: { 22 data: WebsiteData; 23 - linkValue: string; 24 - newCard: (type: string) => void; 25 - addLink: (url: string) => void; 26 27 showingMobileView: boolean; 28 ··· 31 32 save: () => Promise<void>; 33 34 - handleImageInputChange: (evt: Event) => void; 35 - handleVideoInputChange: (evt: Event) => void; 36 } = $props(); 37 - 38 - let linkPopoverOpen = $state(false); 39 - 40 - let imageInputRef: HTMLInputElement | undefined = $state(); 41 - let videoInputRef: HTMLInputElement | undefined = $state(); 42 43 function getShareUrl() { 44 const base = typeof window !== 'undefined' ? window.location.origin : ''; ··· 54 } 55 </script> 56 57 - <input 58 - type="file" 59 - accept="image/*" 60 - onchange={handleImageInputChange} 61 - class="hidden" 62 - multiple 63 - bind:this={imageInputRef} 64 - /> 65 - 66 - <input 67 - type="file" 68 - accept="video/*" 69 - onchange={handleVideoInputChange} 70 - class="hidden" 71 - multiple 72 - bind:this={videoInputRef} 73 - /> 74 - 75 {#if dev || (user.isLoggedIn && user.profile?.did === data.did)} 76 <Navbar 77 class={[ ··· 80 ]} 81 > 82 <div class="flex items-center gap-2"> 83 - <Button 84 - size="iconLg" 85 - variant="ghost" 86 - class="backdrop-blur-none" 87 - onclick={() => { 88 - newCard('section'); 89 - }} 90 - > 91 - <svg 92 - xmlns="http://www.w3.org/2000/svg" 93 - viewBox="0 0 24 24" 94 - fill="none" 95 - stroke="currentColor" 96 - stroke-width="2" 97 - stroke-linecap="round" 98 - stroke-linejoin="round" 99 - ><path d="M6 12h12" /><path d="M6 20V4" /><path d="M18 20V4" /></svg 100 - > 101 - </Button> 102 - 103 - <Button 104 - size="iconLg" 105 - variant="ghost" 106 - class="backdrop-blur-none" 107 - onclick={() => { 108 - newCard('text'); 109 - }} 110 - > 111 - <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" 112 - ><path 113 - fill="none" 114 - stroke="currentColor" 115 - stroke-linecap="round" 116 - stroke-linejoin="round" 117 - stroke-width="2" 118 - d="m15 16l2.536-7.328a1.02 1.02 1 0 1 1.928 0L22 16m-6.303-2h5.606M2 16l4.039-9.69a.5.5 0 0 1 .923 0L11 16m-7.696-3h6.392" 119 - /></svg 120 - > 121 - </Button> 122 - 123 - <Popover sideOffset={16} bind:open={linkPopoverOpen} class="bg-base-100 dark:bg-base-900"> 124 - {#snippet child({ props })} 125 - <Button 126 - size="iconLg" 127 - variant="ghost" 128 - class="backdrop-blur-none" 129 - onclick={() => { 130 - newCard('link'); 131 - }} 132 - {...props} 133 - > 134 - <svg 135 - xmlns="http://www.w3.org/2000/svg" 136 - fill="none" 137 - viewBox="-2 -2 28 28" 138 - stroke-width="2" 139 - stroke="currentColor" 140 - > 141 - <path 142 - stroke-linecap="round" 143 - stroke-linejoin="round" 144 - d="M13.19 8.688a4.5 4.5 0 0 1 1.242 7.244l-4.5 4.5a4.5 4.5 0 0 1-6.364-6.364l1.757-1.757m13.35-.622 1.757-1.757a4.5 4.5 0 0 0-6.364-6.364l-4.5 4.5a4.5 4.5 0 0 0 1.242 7.244" 145 - /> 146 - </svg> 147 - </Button> 148 - {/snippet} 149 - <Input 150 - spellcheck={false} 151 - type="url" 152 - bind:value={linkValue} 153 - onkeydown={(event) => { 154 - if (event.code === 'Enter') { 155 - addLink(linkValue); 156 - event.preventDefault(); 157 - } 158 - }} 159 - placeholder="Enter link" 160 - /> 161 - <Button onclick={() => addLink(linkValue)} size="icon" 162 - ><svg 163 - xmlns="http://www.w3.org/2000/svg" 164 - fill="none" 165 - viewBox="0 0 24 24" 166 - stroke-width="2" 167 - stroke="currentColor" 168 - class="size-6" 169 - > 170 - <path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /> 171 - </svg> 172 - </Button> 173 - </Popover> 174 - 175 - <Button 176 - size="iconLg" 177 - variant="ghost" 178 - class="backdrop-blur-none" 179 - onclick={() => { 180 - imageInputRef?.click(); 181 - }} 182 - > 183 - <svg 184 - xmlns="http://www.w3.org/2000/svg" 185 - fill="none" 186 - viewBox="0 0 24 24" 187 - stroke-width="2" 188 - stroke="currentColor" 189 - > 190 - <path 191 - stroke-linecap="round" 192 - stroke-linejoin="round" 193 - d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" 194 - /> 195 - </svg> 196 - </Button> 197 - 198 - {#if dev} 199 - <Button 200 - size="iconLg" 201 - variant="ghost" 202 - class="backdrop-blur-none" 203 - onclick={() => { 204 - videoInputRef?.click(); 205 - }} 206 - > 207 - <svg 208 - xmlns="http://www.w3.org/2000/svg" 209 - fill="none" 210 - viewBox="0 0 24 24" 211 - stroke-width="1.5" 212 - stroke="currentColor" 213 - > 214 - <path 215 - stroke-linecap="round" 216 - stroke-linejoin="round" 217 - 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" 218 - /> 219 - </svg> 220 - </Button> 221 - {/if} 222 - 223 - <Button size="iconLg" variant="ghost" class="backdrop-blur-none" popovertarget="mobile-menu"> 224 <svg 225 xmlns="http://www.w3.org/2000/svg" 226 fill="none"
··· 2 import { dev } from '$app/environment'; 3 import { user } from '$lib/atproto'; 4 import type { WebsiteData } from '$lib/types'; 5 + import { Button, Navbar, Toggle, toast } from '@foxui/core'; 6 7 let { 8 data, 9 10 showingMobileView = $bindable(), 11 isSaving = $bindable(), ··· 13 14 save, 15 16 + showCardCommand 17 }: { 18 data: WebsiteData; 19 20 showingMobileView: boolean; 21 ··· 24 25 save: () => Promise<void>; 26 27 + showCardCommand: () => void; 28 } = $props(); 29 30 function getShareUrl() { 31 const base = typeof window !== 'undefined' ? window.location.origin : ''; ··· 41 } 42 </script> 43 44 {#if dev || (user.isLoggedIn && user.profile?.did === data.did)} 45 <Navbar 46 class={[ ··· 49 ]} 50 > 51 <div class="flex items-center gap-2"> 52 + <Button size="iconLg" variant="ghost" class="backdrop-blur-none" onclick={showCardCommand}> 53 <svg 54 xmlns="http://www.w3.org/2000/svg" 55 fill="none"
+58 -13
src/lib/website/EditableWebsite.svelte
··· 24 import EditingCard from '../cards/Card/EditingCard.svelte'; 25 import { AllCardDefinitions, CardDefinitionsByType } from '../cards'; 26 import { tick, type Component } from 'svelte'; 27 - import type { CreationModalComponentProps } from '../cards/types'; 28 import { dev } from '$app/environment'; 29 import { setIsMobile } from './context'; 30 import BaseEditingCard from '../cards/BaseCard/BaseEditingCard.svelte'; ··· 38 import { user } from '$lib/atproto'; 39 import { launchConfetti } from '@foxui/visual'; 40 import Controls from './Controls.svelte'; 41 42 let { 43 data ··· 362 return { x: gridX, y: gridY, swapWithId, placement }; 363 } 364 365 - let linkValue = $state(''); 366 - 367 - function addLink(url: string) { 368 let link = validateLink(url); 369 if (!link) { 370 toast.error('invalid link'); ··· 372 } 373 let item = createEmptyCard(data.page); 374 375 for (const cardDef of AllCardDefinitions.toSorted( 376 (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0) 377 )) { ··· 384 break; 385 } 386 } 387 - 388 - if (linkValue === url) { 389 - linkValue = ''; 390 - } 391 } 392 393 function getImageDimensions(src: string): Promise<{ width: number; height: number }> { ··· 645 } 646 647 // $inspect(items); 648 </script> 649 650 <svelte:body ··· 684 </div> 685 {/if} 686 687 <Controls bind:data /> 688 689 {#if showingMobileView} ··· 900 </div> 901 </Sidebar> 902 903 <EditBar 904 {data} 905 - bind:linkValue 906 bind:isSaving 907 bind:showingMobileView 908 {hasUnsavedChanges} 909 - {newCard} 910 - {addLink} 911 {save} 912 - {handleImageInputChange} 913 - {handleVideoInputChange} 914 /> 915 916 <Toaster />
··· 24 import EditingCard from '../cards/Card/EditingCard.svelte'; 25 import { AllCardDefinitions, CardDefinitionsByType } from '../cards'; 26 import { tick, type Component } from 'svelte'; 27 + import type { CardDefinition, CreationModalComponentProps } from '../cards/types'; 28 import { dev } from '$app/environment'; 29 import { setIsMobile } from './context'; 30 import BaseEditingCard from '../cards/BaseCard/BaseEditingCard.svelte'; ··· 38 import { user } from '$lib/atproto'; 39 import { launchConfetti } from '@foxui/visual'; 40 import Controls from './Controls.svelte'; 41 + import CardCommand from '$lib/components/card-command/CardCommand.svelte'; 42 43 let { 44 data ··· 363 return { x: gridX, y: gridY, swapWithId, placement }; 364 } 365 366 + function addLink(url: string, specificCardDef?: CardDefinition) { 367 let link = validateLink(url); 368 if (!link) { 369 toast.error('invalid link'); ··· 371 } 372 let item = createEmptyCard(data.page); 373 374 + if (specificCardDef?.onUrlHandler?.(link, item)) { 375 + item.cardType = specificCardDef.type; 376 + newItem.item = item; 377 + saveNewItem(); 378 + toast(specificCardDef.name + ' added!'); 379 + return; 380 + } 381 + 382 for (const cardDef of AllCardDefinitions.toSorted( 383 (a, b) => (b.urlHandlerPriority ?? 0) - (a.urlHandlerPriority ?? 0) 384 )) { ··· 391 break; 392 } 393 } 394 } 395 396 function getImageDimensions(src: string): Promise<{ width: number; height: number }> { ··· 648 } 649 650 // $inspect(items); 651 + 652 + let showCardCommand = $state(true); 653 </script> 654 655 <svelte:body ··· 689 </div> 690 {/if} 691 692 + <CardCommand 693 + bind:open={showCardCommand} 694 + onselect={(cardDef: CardDefinition) => { 695 + if (cardDef.type === 'image') { 696 + const input = document.getElementById('image-input') as HTMLInputElement; 697 + if (input) { 698 + input.click(); 699 + return; 700 + } 701 + } else if (cardDef.type === 'video') { 702 + const input = document.getElementById('video-input') as HTMLInputElement; 703 + if (input) { 704 + input.click(); 705 + return; 706 + } 707 + } else { 708 + newCard(cardDef.type); 709 + } 710 + }} 711 + onlink={(url, cardDef) => { 712 + addLink(url, cardDef); 713 + }} 714 + /> 715 + 716 <Controls bind:data /> 717 718 {#if showingMobileView} ··· 929 </div> 930 </Sidebar> 931 932 + <input 933 + type="file" 934 + accept="image/*" 935 + onchange={handleImageInputChange} 936 + class="hidden" 937 + id="image-input" 938 + multiple 939 + /> 940 + 941 + <input 942 + type="file" 943 + accept="video/*" 944 + onchange={handleVideoInputChange} 945 + class="hidden" 946 + id="video-input" 947 + multiple 948 + /> 949 + 950 <EditBar 951 {data} 952 bind:isSaving 953 bind:showingMobileView 954 {hasUnsavedChanges} 955 {save} 956 + showCardCommand={() => { 957 + showCardCommand = true; 958 + }} 959 /> 960 961 <Toaster />