your personal website on atproto - mirror blento.app

flip card

Florian 44bd2f00 d426f2df

+366 -1
+63
src/lib/cards/core/FlipCard/EditingFlipCard.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import type { Editor } from '@tiptap/core'; 4 + import { textAlignClasses, textSizeClasses, verticalAlignClasses } from '../TextCard'; 5 + import type { ContentComponentProps } from '../../types'; 6 + import MarkdownTextEditor from '$lib/components/MarkdownTextEditor.svelte'; 7 + import { cn } from '@foxui/core'; 8 + 9 + let { item = $bindable<Item>() }: ContentComponentProps = $props(); 10 + 11 + let frontEditor: Editor | null = $state(null); 12 + let backEditor: Editor | null = $state(null); 13 + </script> 14 + 15 + <!-- svelte-ignore a11y_no_static_element_interactions --> 16 + <!-- svelte-ignore a11y_click_events_have_key_events --> 17 + <div class="flex h-full flex-col"> 18 + <div class="flex min-h-0 flex-1 flex-col"> 19 + <span class="text-base-500 dark:text-base-400 px-6 pt-2 text-xs font-medium">Front</span> 20 + <div 21 + class={cn( 22 + 'prose dark:prose-invert prose-neutral prose-sm prose-a:no-underline prose-a:text-accent-600 dark:prose-a:text-accent-400 accent:prose-a:text-accent-950 accent:prose-a:underline accent:prose-p:text-base-900 hover:bg-base-700/5 accent:hover:bg-accent-300/20 prose-p:first:mt-0 prose-p:last:mb-0 inline-flex min-h-0 w-full max-w-none flex-1 cursor-text overflow-y-scroll rounded-md px-6 py-4 text-lg transition-colors duration-150', 23 + textAlignClasses[item.cardData.textAlign as string], 24 + verticalAlignClasses[item.cardData.verticalAlign as string], 25 + textSizeClasses[(item.cardData.textSize ?? 0) as number] 26 + )} 27 + onclick={() => { 28 + if (frontEditor?.isFocused) return; 29 + frontEditor?.commands.focus('end'); 30 + }} 31 + > 32 + <MarkdownTextEditor 33 + bind:contentDict={item.cardData} 34 + key="frontText" 35 + bind:editor={frontEditor} 36 + /> 37 + </div> 38 + </div> 39 + 40 + <div class="border-base-200 dark:border-base-700 mx-4 border-t"></div> 41 + 42 + <div class="flex min-h-0 flex-1 flex-col"> 43 + <span class="text-base-500 dark:text-base-400 px-6 pt-2 text-xs font-medium">Back</span> 44 + <div 45 + class={cn( 46 + 'prose dark:prose-invert prose-neutral prose-sm prose-a:no-underline prose-a:text-accent-600 dark:prose-a:text-accent-400 accent:prose-a:text-accent-950 accent:prose-a:underline accent:prose-p:text-base-900 hover:bg-base-700/5 accent:hover:bg-accent-300/20 prose-p:first:mt-0 prose-p:last:mb-0 inline-flex min-h-0 w-full max-w-none flex-1 cursor-text overflow-y-scroll rounded-md px-6 py-4 text-lg transition-colors duration-150', 47 + textAlignClasses[item.cardData.textAlign as string], 48 + verticalAlignClasses[item.cardData.verticalAlign as string], 49 + textSizeClasses[(item.cardData.textSize ?? 0) as number] 50 + )} 51 + onclick={() => { 52 + if (backEditor?.isFocused) return; 53 + backEditor?.commands.focus('end'); 54 + }} 55 + > 56 + <MarkdownTextEditor 57 + bind:contentDict={item.cardData} 58 + key="backText" 59 + bind:editor={backEditor} 60 + /> 61 + </div> 62 + </div> 63 + </div>
+101
src/lib/cards/core/FlipCard/FlipCard.svelte
··· 1 + <script lang="ts"> 2 + import { marked } from 'marked'; 3 + import { sanitize } from '$lib/sanitize'; 4 + import type { ContentComponentProps } from '../../types'; 5 + import { textAlignClasses, textSizeClasses, verticalAlignClasses } from '../TextCard'; 6 + import { cn } from '@foxui/core'; 7 + import { getColor } from '../../index'; 8 + 9 + let { item }: ContentComponentProps = $props(); 10 + 11 + let flipped = $state(false); 12 + 13 + const colors: Record<string, string> = { 14 + base: 'bg-base-200/50 dark:bg-base-950/50', 15 + accent: 'bg-accent-400 dark:bg-accent-500 accent', 16 + transparent: 'bg-base-200/50 dark:bg-base-950/50' 17 + }; 18 + 19 + let color = $derived(getColor(item)); 20 + 21 + let colorClasses = $derived.by(() => { 22 + const bgClasses = colors[color] ?? colors.accent; 23 + const colorName = 24 + color !== 'accent' && color !== 'base' && color !== 'transparent' ? color : ''; 25 + const lightClass = color !== 'base' && color !== 'transparent' ? 'light' : ''; 26 + return cn(bgClasses, colorName, lightClass); 27 + }); 28 + 29 + const renderer = new marked.Renderer(); 30 + renderer.link = ({ href, title, text }) => 31 + `<a target="_blank" href="${href}" title="${title ?? ''}">${text}</a>`; 32 + 33 + const proseClasses = 34 + 'prose dark:prose-invert prose-neutral prose-sm prose-a:no-underline prose-a:text-accent-600 dark:prose-a:text-accent-400 accent:prose-a:text-accent-950 accent:prose-a:underline accent:prose-p:text-base-900 prose-p:first:mt-0 prose-p:last:mb-0 prose-headings:first:mt-0 prose-headings:last:mb-0 inline-flex h-full min-h-full w-full max-w-none overflow-x-hidden overflow-y-scroll rounded-md px-6 py-4 text-lg'; 35 + </script> 36 + 37 + <div 38 + class="h-full w-full cursor-pointer perspective-[1000px]" 39 + role="button" 40 + tabindex="0" 41 + onclick={() => (flipped = !flipped)} 42 + onkeydown={(e) => { 43 + if (e.key === 'Enter' || e.key === ' ') { 44 + e.preventDefault(); 45 + flipped = !flipped; 46 + } 47 + }} 48 + > 49 + <div 50 + class={cn( 51 + 'relative h-full w-full [transition:transform_0.6s] transform-3d', 52 + flipped && 'transform-[rotateY(180deg)]' 53 + )} 54 + > 55 + <!-- Front --> 56 + <div 57 + class={cn( 58 + 'text-base-900 dark:text-base-50 absolute inset-0 rounded-[23px] backface-hidden', 59 + colorClasses 60 + )} 61 + > 62 + <div 63 + class={cn( 64 + proseClasses, 65 + textAlignClasses?.[item.cardData.textAlign as string], 66 + verticalAlignClasses[item.cardData.verticalAlign as string], 67 + textSizeClasses[(item.cardData.textSize ?? 0) as number] 68 + )} 69 + > 70 + <span 71 + >{@html sanitize(marked.parse(item.cardData.frontText ?? '', { renderer }) as string, { 72 + ADD_ATTR: ['target'] 73 + })}</span 74 + > 75 + </div> 76 + </div> 77 + 78 + <!-- Back --> 79 + <div 80 + class={cn( 81 + 'text-base-900 dark:text-base-50 absolute inset-0 transform-[rotateY(180deg)] rounded-[23px] backface-hidden', 82 + colorClasses 83 + )} 84 + > 85 + <div 86 + class={cn( 87 + proseClasses, 88 + textAlignClasses?.[item.cardData.textAlign as string], 89 + verticalAlignClasses[item.cardData.verticalAlign as string], 90 + textSizeClasses[(item.cardData.textSize ?? 0) as number] 91 + )} 92 + > 93 + <span 94 + >{@html sanitize(marked.parse(item.cardData.backText ?? '', { renderer }) as string, { 95 + ADD_ATTR: ['target'] 96 + })}</span 97 + > 98 + </div> 99 + </div> 100 + </div> 101 + </div>
+171
src/lib/cards/core/FlipCard/FlipCardSettings.svelte
··· 1 + <script lang="ts"> 2 + import type { Item } from '$lib/types'; 3 + import type { ContentComponentProps } from '../../types'; 4 + import { ToggleGroup, ToggleGroupItem, Button } from '@foxui/core'; 5 + 6 + let { item = $bindable<Item>() }: ContentComponentProps = $props(); 7 + 8 + const classes = 'size-8 min-w-8 [&_svg]:size-3 cursor-pointer'; 9 + </script> 10 + 11 + <div class="flex flex-col gap-2"> 12 + <ToggleGroup 13 + type="single" 14 + bind:value={ 15 + () => { 16 + return item.cardData.verticalAlign ?? 'top'; 17 + }, 18 + (value) => { 19 + if (!value) return; 20 + item.cardData.verticalAlign = value; 21 + } 22 + } 23 + > 24 + <ToggleGroupItem size="sm" value="top" class={classes} 25 + ><svg 26 + xmlns="http://www.w3.org/2000/svg" 27 + viewBox="0 0 24 24" 28 + fill="none" 29 + stroke="currentColor" 30 + stroke-width="2" 31 + stroke-linecap="round" 32 + stroke-linejoin="round" 33 + ><rect width="6" height="16" x="4" y="6" rx="2" /><rect 34 + width="6" 35 + height="9" 36 + x="14" 37 + y="6" 38 + rx="2" 39 + /><path d="M22 2H2" /></svg 40 + > 41 + </ToggleGroupItem> 42 + <ToggleGroupItem size="sm" value="center" class={classes} 43 + ><svg 44 + xmlns="http://www.w3.org/2000/svg" 45 + viewBox="0 0 24 24" 46 + fill="none" 47 + stroke="currentColor" 48 + stroke-width="2" 49 + stroke-linecap="round" 50 + stroke-linejoin="round" 51 + ><rect width="10" height="6" x="7" y="9" rx="2" /><path d="M22 20H2" /><path 52 + d="M22 4H2" 53 + /></svg 54 + ></ToggleGroupItem 55 + > 56 + <ToggleGroupItem size="sm" value="bottom" class={classes} 57 + ><svg 58 + xmlns="http://www.w3.org/2000/svg" 59 + viewBox="0 0 24 24" 60 + fill="none" 61 + stroke="currentColor" 62 + stroke-width="2" 63 + stroke-linecap="round" 64 + stroke-linejoin="round" 65 + ><rect width="14" height="6" x="5" y="12" rx="2" /><rect 66 + width="10" 67 + height="6" 68 + x="7" 69 + y="2" 70 + rx="2" 71 + /><path d="M2 22h20" /></svg 72 + ></ToggleGroupItem 73 + > 74 + </ToggleGroup> 75 + 76 + <ToggleGroup 77 + type="single" 78 + bind:value={ 79 + () => { 80 + return item.cardData.textAlign ?? 'left'; 81 + }, 82 + (value) => { 83 + if (!value) return; 84 + item.cardData.textAlign = value; 85 + } 86 + } 87 + > 88 + <ToggleGroupItem size="sm" value="left" class={classes} 89 + ><svg 90 + xmlns="http://www.w3.org/2000/svg" 91 + viewBox="0 0 24 24" 92 + fill="none" 93 + stroke="currentColor" 94 + stroke-width="2" 95 + stroke-linecap="round" 96 + stroke-linejoin="round"><path d="M21 5H3" /><path d="M15 12H3" /><path d="M17 19H3" /></svg 97 + ></ToggleGroupItem 98 + > 99 + <ToggleGroupItem size="sm" value="center" class={classes} 100 + ><svg 101 + xmlns="http://www.w3.org/2000/svg" 102 + viewBox="0 0 24 24" 103 + fill="none" 104 + stroke="currentColor" 105 + stroke-width="2" 106 + stroke-linecap="round" 107 + stroke-linejoin="round"><path d="M21 5H3" /><path d="M17 12H7" /><path d="M19 19H5" /></svg 108 + ></ToggleGroupItem 109 + > 110 + <ToggleGroupItem size="sm" value="right" class={classes} 111 + ><svg 112 + xmlns="http://www.w3.org/2000/svg" 113 + viewBox="0 0 24 24" 114 + fill="none" 115 + stroke="currentColor" 116 + stroke-width="2" 117 + stroke-linecap="round" 118 + stroke-linejoin="round"><path d="M21 5H3" /><path d="M21 12H9" /><path d="M21 19H7" /></svg 119 + ></ToggleGroupItem 120 + > 121 + </ToggleGroup> 122 + 123 + <div> 124 + <Button 125 + variant="ghost" 126 + onclick={() => { 127 + item.cardData.textSize = Math.max((item.cardData.textSize ?? 0) - 1, 0); 128 + }} 129 + disabled={(item.cardData.textSize ?? 0) < 1} 130 + > 131 + <svg 132 + xmlns="http://www.w3.org/2000/svg" 133 + width="24" 134 + height="24" 135 + viewBox="0 0 24 24" 136 + fill="none" 137 + stroke="currentColor" 138 + stroke-width="2" 139 + stroke-linecap="round" 140 + stroke-linejoin="round" 141 + class="lucide lucide-aarrow-down-icon lucide-a-arrow-down" 142 + ><path d="m14 12 4 4 4-4" /><path d="M18 16V7" /><path 143 + d="m2 16 4.039-9.69a.5.5 0 0 1 .923 0L11 16" 144 + /><path d="M3.304 13h6.392" /></svg 145 + > 146 + </Button> 147 + <Button 148 + variant="ghost" 149 + onclick={() => { 150 + item.cardData.textSize = Math.min((item.cardData.textSize ?? 0) + 1, 5); 151 + }} 152 + disabled={(item.cardData.textSize ?? 0) > 4} 153 + > 154 + <svg 155 + xmlns="http://www.w3.org/2000/svg" 156 + width="24" 157 + height="24" 158 + viewBox="0 0 24 24" 159 + fill="none" 160 + stroke="currentColor" 161 + stroke-width="2" 162 + stroke-linecap="round" 163 + stroke-linejoin="round" 164 + class="lucide lucide-aarrow-up-icon lucide-a-arrow-up" 165 + ><path d="m14 11 4-4 4 4" /><path d="M18 16V7" /><path 166 + d="m2 16 4.039-9.69a.5.5 0 0 1 .923 0L11 16" 167 + /><path d="M3.304 13h6.392" /></svg 168 + > 169 + </Button> 170 + </div> 171 + </div>
+28
src/lib/cards/core/FlipCard/index.ts
··· 1 + import type { CardDefinition } from '../../types'; 2 + import EditingFlipCard from './EditingFlipCard.svelte'; 3 + import FlipCard from './FlipCard.svelte'; 4 + import FlipCardSettings from './FlipCardSettings.svelte'; 5 + 6 + export const FlipCardDefinition = { 7 + type: 'flipCard', 8 + contentComponent: FlipCard, 9 + editingContentComponent: EditingFlipCard, 10 + createNew: (card) => { 11 + card.cardType = 'flipCard'; 12 + card.cardData = { 13 + frontText: 'Front', 14 + backText: 'Back' 15 + }; 16 + }, 17 + 18 + settingsComponent: FlipCardSettings, 19 + 20 + defaultColor: 'transparent', 21 + 22 + name: 'Flip Card', 23 + 24 + keywords: ['flip', 'flashcard', 'two-sided', 'reveal'], 25 + groups: ['Core'], 26 + 27 + icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-4"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 3H5a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h3M16 3h3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-3M12 20V4m-4 8h8" /></svg>` 28 + } as CardDefinition & { type: 'flipCard' };
+3 -1
src/lib/cards/index.ts
··· 46 46 import { LastFMTopTracksCardDefinition } from './media/LastFMCard/LastFMTopTracksCard'; 47 47 import { LastFMTopAlbumsCardDefinition } from './media/LastFMCard/LastFMTopAlbumsCard'; 48 48 import { LastFMProfileCardDefinition } from './media/LastFMCard/LastFMProfileCard'; 49 + import { FlipCardDefinition } from './core/FlipCard'; 49 50 // import { Model3DCardDefinition } from './visual/Model3DCard'; 50 51 51 52 export const AllCardDefinitions = [ ··· 96 97 LastFMRecentTracksCardDefinition, 97 98 LastFMTopTracksCardDefinition, 98 99 LastFMTopAlbumsCardDefinition, 99 - LastFMProfileCardDefinition 100 + LastFMProfileCardDefinition, 101 + FlipCardDefinition 100 102 ] as const; 101 103 102 104 export const CardDefinitionsByType = AllCardDefinitions.reduce(