learn and share notes on atproto (wip) 🦉 malfestio.stormlightlabs.org/
readability solid axum atproto srs

feat: overhaul Discovery and Feed page UI with new components and animations

+2 -2
web/package.json
··· 12 12 "lint": "eslint ." 13 13 }, 14 14 "dependencies": { 15 - "@fontsource-variable/alegreya": "^5.2.8", 16 - "@fontsource-variable/lora": "^5.2.8", 15 + "@fontsource-variable/figtree": "^5.2.8", 16 + "@fontsource-variable/source-serif-4": "^5.2.8", 17 17 "@solidjs/meta": "^0.29.4", 18 18 "@solidjs/router": "^0.15.4", 19 19 "@tailwindcss/vite": "^4.1.18",
+10 -10
web/pnpm-lock.yaml
··· 11 11 12 12 .: 13 13 dependencies: 14 - '@fontsource-variable/alegreya': 14 + '@fontsource-variable/figtree': 15 15 specifier: ^5.2.8 16 - version: 5.2.8 17 - '@fontsource-variable/lora': 16 + version: 5.2.10 17 + '@fontsource-variable/source-serif-4': 18 18 specifier: ^5.2.8 19 - version: 5.2.8 19 + version: 5.2.9 20 20 '@solidjs/meta': 21 21 specifier: ^0.29.4 22 22 version: 0.29.4(solid-js@1.9.10) ··· 316 316 '@exodus/crypto': 317 317 optional: true 318 318 319 - '@fontsource-variable/alegreya@5.2.8': 320 - resolution: {integrity: sha512-gQcIA7j76KYTOcdkfo1Xee9xLBi5mya4qTkzlgeoHf9SjOL/gJj5GSSOg/7ba/ciUU18K92i7VGxXzFhDsowGg==} 319 + '@fontsource-variable/figtree@5.2.10': 320 + resolution: {integrity: sha512-a5Gumbpy3mdd+Yg31g6Qb7CmjYbrfyutJa3bWfP5q8A4GclIOwX7mI+ZuSHsJnw/mHvW6r9oh1AHJcJTIxK4JA==} 321 321 322 - '@fontsource-variable/lora@5.2.8': 323 - resolution: {integrity: sha512-cxjTJ9BbOWIzusewR4UMBLVePvTSWV6dtNaNsCkF/oKoyA68fJGWfaYCILOOP1BObE4dmjfZ3xo6m9hdHhtYhg==} 322 + '@fontsource-variable/source-serif-4@5.2.9': 323 + resolution: {integrity: sha512-PPcxjLFk/fS0WHg79pDM2YNvz61kC+oYZ5cWZZyCS0DHpJncmuYOuiZAsvj4tDxlWPBEvxxcRLQQNmSaRbPkqw==} 324 324 325 325 '@humanfs/core@0.19.1': 326 326 resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} ··· 2172 2172 2173 2173 '@exodus/bytes@1.6.0': {} 2174 2174 2175 - '@fontsource-variable/alegreya@5.2.8': {} 2175 + '@fontsource-variable/figtree@5.2.10': {} 2176 2176 2177 - '@fontsource-variable/lora@5.2.8': {} 2177 + '@fontsource-variable/source-serif-4@5.2.9': {} 2178 2178 2179 2179 '@humanfs/core@0.19.1': {} 2180 2180
+33
web/src/components/ui/tests/EmptyState.test.tsx
··· 1 + import { cleanup, render, screen } from "@solidjs/testing-library"; 2 + import { afterEach, describe, expect, it } from "vitest"; 3 + import { EmptyState } from "../EmptyState"; 4 + 5 + describe("EmptyState", () => { 6 + afterEach(cleanup); 7 + 8 + it("renders title", () => { 9 + render(() => <EmptyState title="No items" />); 10 + expect(screen.getByText("No items")).toBeInTheDocument(); 11 + }); 12 + 13 + it("renders description when provided", () => { 14 + render(() => <EmptyState title="Empty" description="Create your first item" />); 15 + expect(screen.getByText("Create your first item")).toBeInTheDocument(); 16 + }); 17 + 18 + it("renders custom icon when provided", () => { 19 + render(() => <EmptyState title="Empty" icon={<span data-testid="custom-icon">🎯</span>} />); 20 + expect(screen.getByTestId("custom-icon")).toBeInTheDocument(); 21 + }); 22 + 23 + it("renders action when provided", () => { 24 + render(() => <EmptyState title="Empty" action={<button>Create</button>} />); 25 + expect(screen.getByRole("button", { name: "Create" })).toBeInTheDocument(); 26 + }); 27 + 28 + it("renders default icon when no custom icon provided", () => { 29 + render(() => <EmptyState title="Empty" />); 30 + const svg = document.querySelector("svg"); 31 + expect(svg).toBeInTheDocument(); 32 + }); 33 + });
+2 -2
web/src/fonts.d.ts
··· 1 1 // CSS-only packages without TypeScript declarations 2 - declare module "@fontsource-variable/alegreya"; 3 - declare module "@fontsource-variable/lora"; 2 + declare module "@fontsource-variable/figtree"; 3 + declare module "@fontsource-variable/source-serif-4";
+2 -2
web/src/index.css
··· 2 2 @plugin "@egoist/tailwindcss-icons"; 3 3 4 4 @theme { 5 - --font-display: "Lora Variable", serif; 6 - --font-body: "Alegreya Variable", serif; 5 + --font-display: "Source Serif 4 Variable", serif; 6 + --font-body: "Figtree Variable", serif; 7 7 } 8 8 9 9 * {
+2 -2
web/src/index.tsx
··· 1 1 /* @refresh reload */ 2 - import "@fontsource-variable/alegreya"; 3 - import "@fontsource-variable/lora"; 2 + import "@fontsource-variable/figtree"; 3 + import "@fontsource-variable/source-serif-4"; 4 4 import { render } from "solid-js/web"; 5 5 import "./index.css"; 6 6 import App from "./App.tsx";
+20 -6
web/src/pages/DeckNew.tsx
··· 4 4 import { toast } from "$lib/toast"; 5 5 import { useNavigate } from "@solidjs/router"; 6 6 import type { Component } from "solid-js"; 7 + import { Motion } from "solid-motionone"; 7 8 8 9 const DeckNew: Component = () => { 9 10 const navigate = useNavigate(); ··· 26 27 }; 27 28 28 29 return ( 29 - <div class="max-w-3xl mx-auto"> 30 - <div class="mb-8"> 31 - <h1 class="text-3xl font-light text-[#F4F4F4] mb-2 tracking-tight">Create New Deck</h1> 30 + <Motion.div 31 + initial={{ opacity: 0 }} 32 + animate={{ opacity: 1 }} 33 + transition={{ duration: 0.3 }} 34 + class="max-w-3xl mx-auto"> 35 + <Motion.div 36 + initial={{ opacity: 0, y: -10 }} 37 + animate={{ opacity: 1, y: 0 }} 38 + transition={{ duration: 0.4 }} 39 + class="mb-8"> 40 + <h1 class="text-4xl text-[#F4F4F4] mb-2 tracking-tight">Create New Deck</h1> 32 41 <p class="text-[#C6C6C6] font-light">Start a new collection of flashcards.</p> 33 - </div> 34 - <DeckEditor onSave={handleSave} /> 35 - </div> 42 + </Motion.div> 43 + <Motion.div 44 + initial={{ opacity: 0, y: 20 }} 45 + animate={{ opacity: 1, y: 0 }} 46 + transition={{ duration: 0.4, delay: 0.1 }}> 47 + <DeckEditor onSave={handleSave} /> 48 + </Motion.div> 49 + </Motion.div> 36 50 ); 37 51 }; 38 52
+124 -75
web/src/pages/DeckView.tsx
··· 1 1 import { CommentSection } from "$components/social/CommentSection"; 2 2 import { FollowButton } from "$components/social/FollowButton"; 3 3 import { Button } from "$components/ui/Button"; 4 + import { Card } from "$components/ui/Card"; 4 5 import { Dialog } from "$components/ui/Dialog"; 6 + import { EmptyState } from "$components/ui/EmptyState"; 7 + import { Skeleton } from "$components/ui/Skeleton"; 8 + import { Tag } from "$components/ui/Tag"; 5 9 import { api } from "$lib/api"; 6 - import type { Card, Deck } from "$lib/model"; 10 + import type { Card as CardType, Deck } from "$lib/model"; 7 11 import { toast } from "$lib/toast"; 8 12 import { A, useNavigate, useParams } from "@solidjs/router"; 9 13 import type { Component } from "solid-js"; 10 - import { createResource, createSignal, For, Show } from "solid-js"; 14 + import { createResource, createSignal, For, Index, Show } from "solid-js"; 15 + import { Motion } from "solid-motionone"; 16 + 17 + const CardSkeleton: Component = () => ( 18 + <Card> 19 + <div class="flex justify-between items-start mb-2"> 20 + <Skeleton width="4rem" height="0.75rem" /> 21 + </div> 22 + <div class="grid md:grid-cols-2 gap-8"> 23 + <div class="space-y-2"> 24 + <Skeleton width="3rem" height="0.625rem" /> 25 + <Skeleton width="100%" height="1rem" /> 26 + </div> 27 + <div class="space-y-2 md:border-l md:border-[#393939] md:pl-8"> 28 + <Skeleton width="3rem" height="0.625rem" /> 29 + <Skeleton width="100%" height="1rem" /> 30 + </div> 31 + </div> 32 + </Card> 33 + ); 11 34 12 35 const DeckView: Component = () => { 13 36 const params = useParams(); ··· 15 38 const [showForkDialog, setShowForkDialog] = createSignal(false); 16 39 const [deck] = createResource(() => params.id, async (id) => { 17 40 const res = await api.getDeck(id); 18 - return res.ok ? (await res.json() as Deck) : null; 41 + return res.ok ? ((await res.json()) as Deck) : null; 19 42 }); 20 43 const [cards] = createResource(() => params.id, async (id) => { 21 44 const res = await api.getDeckCards(id); 22 - return res.ok ? (await res.json() as Card[]) : []; 45 + return res.ok ? ((await res.json()) as CardType[]) : []; 23 46 }); 24 47 25 48 const handleFork = async () => { ··· 43 66 }; 44 67 45 68 return ( 46 - <div class="max-w-4xl mx-auto px-6 py-12"> 47 - <Show when={!deck.loading} fallback={<div class="text-[#8D8D8D] font-light">Loading deck...</div>}> 69 + <Motion.div 70 + initial={{ opacity: 0 }} 71 + animate={{ opacity: 1 }} 72 + transition={{ duration: 0.3 }} 73 + class="max-w-4xl mx-auto px-6 py-12"> 74 + <Show 75 + when={!deck.loading} 76 + fallback={ 77 + <div class="space-y-6"> 78 + <Skeleton width="60%" height="2.5rem" /> 79 + <Skeleton width="40%" height="1rem" /> 80 + <Skeleton width="100%" height="1rem" /> 81 + <div class="flex gap-2"> 82 + <Skeleton width="4rem" height="1.5rem" rounded="full" /> 83 + <Skeleton width="3rem" height="1.5rem" rounded="full" /> 84 + </div> 85 + </div> 86 + }> 48 87 <Show 49 88 when={deck()} 50 89 fallback={ 51 - <div class="p-8 border border-red-900/50 bg-red-900/10 text-red-400"> 52 - Deck not found or you don't have access. 53 - </div> 90 + <EmptyState 91 + title="Deck not found" 92 + description="This deck doesn't exist or you don't have access to view it." 93 + icon={<span class="i-bi-exclamation-triangle text-4xl text-red-400" />} 94 + action={ 95 + <A href="/"> 96 + <Button variant="secondary">Back to Library</Button> 97 + </A> 98 + } /> 54 99 }> 55 - {deckValue => ( 100 + {(deckValue) => ( 56 101 <> 57 - <div class="mb-12"> 102 + <Motion.div 103 + initial={{ opacity: 0, y: 20 }} 104 + animate={{ opacity: 1, y: 0 }} 105 + transition={{ duration: 0.4 }} 106 + class="mb-12"> 58 107 <div class="flex justify-between items-start mb-4"> 59 - <h1 class="text-4xl font-light text-[#F4F4F4] tracking-tight">{deckValue().title}</h1> 108 + <h1 class="text-4xl text-[#F4F4F4] tracking-tight">{deckValue().title}</h1> 60 109 <Show when={deckValue().visibility.type !== "Public"}> 61 - <span class="text-xs uppercase font-bold tracking-widest px-2 py-1 bg-[#393939] text-[#C6C6C6]"> 62 - {deckValue().visibility.type} 63 - </span> 110 + <Tag label={deckValue().visibility.type} color="gray" /> 64 111 </Show> 65 112 </div> 66 113 ··· 72 119 <p class="text-[#C6C6C6] mb-6 font-light">{deckValue().description}</p> 73 120 74 121 <Show when={deckValue().tags.length > 0}> 75 - <div class="flex gap-2 mb-8"> 76 - <For each={deckValue().tags}> 77 - {(tag) => ( 78 - <span class="text-xs text-[#8D8D8D] bg-[#161616] px-2 py-1 border border-[#393939]"> 79 - #{tag} 80 - </span> 81 - )} 82 - </For> 122 + <div class="flex gap-2 mb-8 flex-wrap"> 123 + <For each={deckValue().tags}>{(tag) => <Tag label={`#${tag}`} color="blue" />}</For> 83 124 </div> 84 125 </Show> 85 126 86 127 <div class="flex gap-4 border-t border-[#393939] pt-6"> 87 - <button class="bg-[#0F62FE] hover:bg-[#0353E9] text-white px-6 py-3 font-medium text-sm transition-colors"> 88 - Study Deck (Coming Soon) 89 - </button> 90 - <Button 91 - onClick={() => setShowForkDialog(true)} 92 - variant="secondary" 93 - class="border border-[#393939] text-[#F4F4F4] hover:bg-[#262626] px-6 py-3 font-medium text-sm transition-colors"> 94 - Fork Deck 128 + <Button disabled> 129 + <span class="i-bi-play-fill" /> Study Deck 130 + </Button> 131 + <Button onClick={() => setShowForkDialog(true)} variant="secondary"> 132 + <span class="i-bi-box-arrow-up-right" /> Fork Deck 95 133 </Button> 96 - <A 97 - href="/" 98 - class="px-6 py-3 border border-[#393939] text-[#F4F4F4] hover:bg-[#262626] font-medium text-sm transition-colors"> 99 - Back to Library 134 + <A href="/"> 135 + <Button variant="ghost">Back to Library</Button> 100 136 </A> 101 137 </div> 102 - </div> 103 - <div> 138 + </Motion.div> 139 + 140 + <Motion.div 141 + initial={{ opacity: 0, y: 20 }} 142 + animate={{ opacity: 1, y: 0 }} 143 + transition={{ duration: 0.4, delay: 0.1 }}> 104 144 <h2 class="text-xl font-medium text-[#F4F4F4] mb-6 border-b border-[#393939] pb-4"> 105 - Cards <Show when={cards()}>{value => value().length}</Show> 145 + Cards <Show when={cards()}>{(value) => <span class="text-[#8D8D8D]">({value().length})</span>}</Show> 106 146 </h2> 107 147 108 - <Show when={cards.loading}> 109 - <div class="text-[#8D8D8D] font-light text-sm">Loading cards...</div> 148 + <Show when={!cards.loading} fallback={<Index each={Array(3)}>{() => <CardSkeleton />}</Index>}> 149 + <div class="grid gap-4"> 150 + <For 151 + each={cards()} 152 + fallback={ 153 + <EmptyState 154 + title="No cards in this deck" 155 + description="Add some cards to start studying." 156 + icon={<span class="i-bi-card-text text-4xl text-[#525252]" />} /> 157 + }> 158 + {(card, i) => ( 159 + <Motion.div 160 + initial={{ opacity: 0, y: 10 }} 161 + animate={{ opacity: 1, y: 0 }} 162 + transition={{ duration: 0.3, delay: i() * 0.03 }}> 163 + <Card class="hover:border-[#525252] transition-colors"> 164 + <div class="flex justify-between items-start mb-2 text-xs text-[#8D8D8D] font-mono"> 165 + <span class="opacity-50">CARD {i() + 1}</span> 166 + </div> 167 + <div class="grid md:grid-cols-2 gap-8"> 168 + <div> 169 + <div class="text-[10px] uppercase tracking-widest text-[#525252] mb-1">Front</div> 170 + <div class="text-[#E0E0E0]">{card.front}</div> 171 + </div> 172 + <div class="md:border-l md:border-[#393939] md:pl-8"> 173 + <div class="text-[10px] uppercase tracking-widest text-[#525252] mb-1">Back</div> 174 + <div class="text-[#C6C6C6]"> 175 + {card.back || <span class="italic opacity-50">Empty</span>} 176 + </div> 177 + </div> 178 + </div> 179 + </Card> 180 + </Motion.div> 181 + )} 182 + </For> 183 + </div> 110 184 </Show> 185 + </Motion.div> 111 186 112 - <div class="grid gap-4"> 113 - <For 114 - each={cards()} 115 - fallback={ 116 - <div class="text-center py-12 border border-dashed border-[#393939] text-[#8D8D8D] font-light italic"> 117 - No cards in this deck. 118 - </div> 119 - }> 120 - {(card, i) => ( 121 - <div class="p-6 bg-[#262626] border border-[#393939] hover:border-[#525252] transition-colors group"> 122 - <div class="flex justify-between items-start mb-2 text-xs text-[#8D8D8D] font-mono"> 123 - <span class="opacity-50">CARD {i() + 1}</span> 124 - </div> 125 - <div class="grid md:grid-cols-2 gap-8"> 126 - <div class="prose prose-invert prose-sm max-w-none"> 127 - <div class="text-[10px] uppercase tracking-widest text-[#525252] mb-1">Front</div> 128 - <div class="text-[#E0E0E0]">{card.front}</div> 129 - </div> 130 - <div class="prose prose-invert prose-sm max-w-none md:border-l md:border-[#393939] md:pl-8"> 131 - <div class="text-[10px] uppercase tracking-widest text-[#525252] mb-1">Back</div> 132 - <div class="text-[#C6C6C6]"> 133 - {card.back || <span class="italic opacity-50">Empty</span>} 134 - </div> 135 - </div> 136 - </div> 137 - </div> 138 - )} 139 - </For> 140 - </div> 141 - </div> 142 - <div class="mt-12 pt-8 border-t border-[#393939]"> 187 + <Motion.div 188 + initial={{ opacity: 0 }} 189 + animate={{ opacity: 1 }} 190 + transition={{ duration: 0.4, delay: 0.2 }} 191 + class="mt-12 pt-8 border-t border-[#393939]"> 143 192 <CommentSection deckId={deckValue().id} /> 144 - </div> 193 + </Motion.div> 145 194 </> 146 195 )} 147 196 </Show> ··· 157 206 <Button variant="primary" onClick={handleFork}>Fork Deck</Button> 158 207 </> 159 208 }> 160 - <p>Are you sure you want to fork "{deck()?.title}"?</p> 161 - <p class="text-sm text-gray-400 mt-2"> 209 + <p class="text-[#C6C6C6]">Are you sure you want to fork "{deck()?.title}"?</p> 210 + <p class="text-sm text-[#8D8D8D] mt-2"> 162 211 This will create a copy of this deck in your library that you can study and edit. 163 212 </p> 164 213 </Dialog> 165 - </div> 214 + </Motion.div> 166 215 ); 167 216 }; 168 217
+43 -30
web/src/pages/Discovery.tsx
··· 1 1 import { SearchInput } from "$components/SearchInput"; 2 + import { Skeleton } from "$components/ui/Skeleton"; 3 + import { Tag } from "$components/ui/Tag"; 2 4 import { api } from "$lib/api"; 3 5 import { A } from "@solidjs/router"; 4 6 import type { Component } from "solid-js"; 5 - import { createResource, For, Show } from "solid-js"; 7 + import { createResource, For, Index, Show } from "solid-js"; 8 + import { Motion } from "solid-motionone"; 6 9 7 - // TODO: type discovery response 8 10 const Discovery: Component = () => { 9 11 const [data] = createResource(async () => { 10 12 const res = await api.getDiscovery(); 11 - if (res.ok) return await res.json(); 13 + if (res.ok) return (await res.json()) as { top_tags: [string, number][] }; 12 14 return { top_tags: [] }; 13 15 }); 14 16 15 17 return ( 16 - <div class="container mx-auto p-4 space-y-8"> 17 - <div class="text-center space-y-4"> 18 - <h1 class="text-4xl font-extrabold bg-linear-to-r from-blue-600 to-purple-600 dark:from-blue-400 dark:to-purple-400 text-transparent bg-clip-text"> 19 - Discover Malfestio 20 - </h1> 21 - <p class="text-xl text-gray-600 dark:text-gray-300">Explore community decks and popular topics</p> 22 - <div class="max-w-2xl mx-auto"> 18 + <Motion.div 19 + initial={{ opacity: 0 }} 20 + animate={{ opacity: 1 }} 21 + transition={{ duration: 0.3 }} 22 + class="max-w-4xl mx-auto px-4 py-8 space-y-8"> 23 + <Motion.div 24 + initial={{ opacity: 0, y: -10 }} 25 + animate={{ opacity: 1, y: 0 }} 26 + transition={{ duration: 0.4 }} 27 + class="text-center space-y-4"> 28 + <h1 class="text-5xl text-[#F4F4F4] tracking-tight">Discover Malfestio</h1> 29 + <p class="text-xl text-[#C6C6C6] font-light">Explore community decks and popular topics</p> 30 + <div class="max-w-2xl mx-auto pt-4"> 23 31 <SearchInput /> 24 32 </div> 25 - </div> 33 + </Motion.div> 26 34 27 - <div class="space-y-4"> 28 - <h2 class="text-2xl font-bold flex items-center gap-2"> 29 - <div class="i-bi-tags-fill text-purple-500" /> 35 + <Motion.div 36 + initial={{ opacity: 0, y: 20 }} 37 + animate={{ opacity: 1, y: 0 }} 38 + transition={{ duration: 0.5, delay: 0.2 }} 39 + class="space-y-4"> 40 + <h2 class="text-2xl text-[#F4F4F4] flex items-center gap-2"> 41 + <span class="i-bi-tags-fill text-[#A855F7]" /> 30 42 Top Tags 31 43 </h2> 32 44 33 45 <Show 34 46 when={!data.loading} 35 47 fallback={ 36 - <div class="flex gap-2"> 37 - <div class="h-8 w-24 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" /> 48 + <div class="flex gap-3 flex-wrap"> 49 + <Index each={Array(8)}>{() => <Skeleton width="5rem" height="2.25rem" rounded="full" />}</Index> 38 50 </div> 39 51 }> 40 52 <div class="flex flex-wrap gap-3"> 41 53 <For each={data()?.top_tags}> 42 - {(tag: [string, number]) => ( 43 - <A 44 - href={`/search?q=${encodeURIComponent(tag[0])}`} 45 - class="px-4 py-2 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-full hover:shadow-md hover:border-blue-500 dark:hover:border-blue-500 transition-all flex items-center gap-2 group"> 46 - <span class="font-medium text-gray-700 dark:text-gray-200 group-hover:text-blue-600 dark:group-hover:text-blue-400"> 47 - #{tag[0]} 48 - </span> 49 - <span class="text-xs text-gray-400 bg-gray-100 dark:bg-gray-700 px-1.5 py-0.5 rounded-full"> 50 - {tag[1]} 51 - </span> 52 - </A> 54 + {(tag, i) => ( 55 + <Motion.div 56 + initial={{ opacity: 0, scale: 0.9 }} 57 + animate={{ opacity: 1, scale: 1 }} 58 + transition={{ duration: 0.3, delay: i() * 0.03 }}> 59 + <A 60 + href={`/search?q=${encodeURIComponent(tag[0])}`} 61 + class="group inline-flex items-center gap-2 px-4 py-2 bg-[#262626] border border-[#393939] rounded-full hover:border-[#0F62FE] transition-colors"> 62 + <Tag label={`#${tag[0]}`} color="purple" class="border-none bg-transparent px-0" /> 63 + <span class="text-xs text-[#8D8D8D] bg-[#161616] px-1.5 py-0.5 rounded-full">{tag[1]}</span> 64 + </A> 65 + </Motion.div> 53 66 )} 54 67 </For> 55 68 <Show when={data()?.top_tags.length === 0}> 56 - <p class="text-gray-500">No tags found yet. Create some decks!</p> 69 + <p class="text-[#8D8D8D] font-light">No tags found yet. Create some decks!</p> 57 70 </Show> 58 71 </div> 59 72 </Show> 60 - </div> 61 - </div> 73 + </Motion.div> 74 + </Motion.div> 62 75 ); 63 76 }; 64 77
+91 -47
web/src/pages/Feed.tsx
··· 2 2 import { Button } from "$components/ui/Button"; 3 3 import { Card } from "$components/ui/Card"; 4 4 import { Dialog } from "$components/ui/Dialog"; 5 + import { EmptyState } from "$components/ui/EmptyState"; 6 + import { Skeleton } from "$components/ui/Skeleton"; 5 7 import { Tabs } from "$components/ui/Tabs"; 8 + import { Tag } from "$components/ui/Tag"; 6 9 import { api } from "$lib/api"; 7 10 import type { Deck } from "$lib/model"; 8 11 import { toast } from "$lib/toast"; 9 12 import { A, useNavigate } from "@solidjs/router"; 10 - import { createResource, createSignal, For, Match, Show, Switch } from "solid-js"; 13 + import { createResource, createSignal, For, Index, Match, Show, Switch } from "solid-js"; 14 + import { Motion } from "solid-motionone"; 11 15 12 16 export default function Feed() { 13 17 const navigate = useNavigate(); ··· 15 19 16 20 const [followsFeed] = createResource(async () => { 17 21 const res = await api.getFeedFollows(); 18 - return res.ok ? (await res.json() as Deck[]) : []; 22 + return res.ok ? ((await res.json()) as Deck[]) : []; 19 23 }); 20 24 21 25 const [valuableFeed] = createResource(async () => { 22 26 const res = await api.getFeedTrending(); 23 - return res.ok ? (await res.json() as Deck[]) : []; 27 + return res.ok ? ((await res.json()) as Deck[]) : []; 24 28 }); 25 29 26 30 const handleFork = async () => { ··· 43 47 } 44 48 }; 45 49 46 - const DeckItem = (props: { deck: Deck }) => ( 50 + const DeckItem = (props: { deck: Deck; index: number }) => ( 51 + <Motion.div 52 + initial={{ opacity: 0, y: 15 }} 53 + animate={{ opacity: 1, y: 0 }} 54 + transition={{ duration: 0.3, delay: props.index * 0.05 }}> 55 + <Card class="mb-4"> 56 + <div class="flex justify-between items-start"> 57 + <div class="flex-1"> 58 + <h3 class="text-xl font-medium text-[#F4F4F4] mb-1">{props.deck.title}</h3> 59 + <p class="text-sm text-[#8D8D8D] mb-2"> 60 + By {props.deck.owner_did} •{" "} 61 + <Show when={props.deck.published_at} fallback="Draft"> 62 + {(published_at) => new Date(published_at()).toLocaleDateString()} 63 + </Show> 64 + </p> 65 + <p class="text-[#C6C6C6] mb-3 font-light">{props.deck.description}</p> 66 + <div class="flex gap-2 mb-3 flex-wrap"> 67 + <For each={props.deck.tags}>{(tag) => <Tag label={tag} color="blue" />}</For> 68 + </div> 69 + </div> 70 + <div class="ml-4"> 71 + <FollowButton did={props.deck.owner_did} /> 72 + </div> 73 + </div> 74 + <div class="flex gap-2 items-center mt-4 pt-4 border-t border-[#393939]"> 75 + <A href={`/decks/${props.deck.id}`}> 76 + <Button variant="secondary" size="sm">View</Button> 77 + </A> 78 + <Button variant="ghost" size="sm" onClick={() => setForkDialogDeck(props.deck)}>Fork</Button> 79 + </div> 80 + </Card> 81 + </Motion.div> 82 + ); 83 + 84 + const DeckSkeleton = () => ( 47 85 <Card class="mb-4"> 48 86 <div class="flex justify-between items-start"> 49 - <div> 50 - <h3 class="text-xl font-bold mb-1">{props.deck.title}</h3> 51 - <p class="text-sm text-gray-400 mb-2"> 52 - By {props.deck.owner_did} •{" "} 53 - <Show when={props.deck.published_at} fallback="Draft"> 54 - {published_at => new Date(published_at()).toLocaleDateString()} 55 - </Show> 56 - </p> 57 - <p class="mb-3">{props.deck.description}</p> 58 - <div class="flex gap-2 mb-3"> 59 - <For each={props.deck.tags}> 60 - {(tag) => <span class="bg-gray-800 px-2 py-1 rounded text-xs">{tag}</span>} 61 - </For> 87 + <div class="flex-1 space-y-3"> 88 + <Skeleton width="60%" height="1.5rem" /> 89 + <Skeleton width="40%" height="0.875rem" /> 90 + <Skeleton width="100%" height="1rem" /> 91 + <div class="flex gap-2"> 92 + <Skeleton width="4rem" height="1.5rem" rounded="full" /> 93 + <Skeleton width="3rem" height="1.5rem" rounded="full" /> 62 94 </div> 63 95 </div> 64 - <div class="ml-4"> 65 - <FollowButton did={props.deck.owner_did} /> 66 - </div> 67 - </div> 68 - <div class="flex gap-2 items-center mt-2"> 69 - <A href={`/decks/${props.deck.id}`} class="no-underline"> 70 - <Button variant="secondary" size="sm">View</Button> 71 - </A> 72 - <Button variant="ghost" size="sm" onClick={() => setForkDialogDeck(props.deck)}>Fork</Button> 96 + <Skeleton width="5rem" height="2rem" /> 73 97 </div> 74 98 </Card> 75 99 ); 76 100 77 101 return ( 78 - <div class="container mx-auto p-4 max-w-3xl"> 79 - <h1 class="text-3xl font-bold mb-6">Discovery</h1> 102 + <Motion.div 103 + initial={{ opacity: 0 }} 104 + animate={{ opacity: 1 }} 105 + transition={{ duration: 0.3 }} 106 + class="max-w-3xl mx-auto px-4 py-8"> 107 + <Motion.div 108 + initial={{ opacity: 0, y: -10 }} 109 + animate={{ opacity: 1, y: 0 }} 110 + transition={{ duration: 0.4 }} 111 + class="mb-8"> 112 + <h1 class="text-4xl text-[#F4F4F4] tracking-tight mb-2">Discovery</h1> 113 + <p class="text-[#C6C6C6] font-light">Explore content from people you follow and trending decks.</p> 114 + </Motion.div> 115 + 80 116 <Tabs tabs={[{ id: "following", label: "Following" }, { id: "trending", label: "Trending" }]}> 81 117 {(activeTab) => ( 82 118 <Switch> 83 119 <Match when={activeTab() === "following"}> 84 - <div class="mt-4"> 85 - <Show when={followsFeed()}> 86 - {feed => ( 87 - <For 88 - each={feed()} 89 - fallback={<div class="text-gray-500 py-8 text-center">No updates from followed users.</div>}> 90 - {(deck) => <DeckItem deck={deck} />} 91 - </For> 92 - )} 120 + <div class="mt-6"> 121 + <Show when={!followsFeed.loading} fallback={<Index each={Array(3)}>{() => <DeckSkeleton />}</Index>}> 122 + <For 123 + each={followsFeed()} 124 + fallback={ 125 + <EmptyState 126 + title="No updates from followed users" 127 + description="Follow some creators to see their latest decks here." 128 + icon={<span class="i-bi-people text-4xl text-[#525252]" />} /> 129 + }> 130 + {(deck, i) => <DeckItem deck={deck} index={i()} />} 131 + </For> 93 132 </Show> 94 133 </div> 95 134 </Match> 96 135 <Match when={activeTab() === "trending"}> 97 - <div class="mt-4"> 98 - <Show when={valuableFeed()}> 99 - {feed => ( 100 - <For each={feed()} fallback={<div class="text-gray-500 py-8 text-center">No trending decks.</div>}> 101 - {(deck) => <DeckItem deck={deck} />} 102 - </For> 103 - )} 136 + <div class="mt-6"> 137 + <Show when={!valuableFeed.loading} fallback={<Index each={Array(3)}>{() => <DeckSkeleton />}</Index>}> 138 + <For 139 + each={valuableFeed()} 140 + fallback={ 141 + <EmptyState 142 + title="No trending decks" 143 + description="Check back later for popular community content." 144 + icon={<span class="i-bi-fire text-4xl text-[#525252]" />} /> 145 + }> 146 + {(deck, i) => <DeckItem deck={deck} index={i()} />} 147 + </For> 104 148 </Show> 105 149 </div> 106 150 </Match> ··· 118 162 <Button variant="primary" onClick={handleFork}>Fork Deck</Button> 119 163 </> 120 164 }> 121 - <p>Are you sure you want to fork "{forkDialogDeck()?.title}"?</p> 122 - <p class="text-sm text-gray-400 mt-2">This will create a copy of this deck in your library.</p> 165 + <p class="text-[#C6C6C6]">Are you sure you want to fork "{forkDialogDeck()?.title}"?</p> 166 + <p class="text-sm text-[#8D8D8D] mt-2">This will create a copy of this deck in your library.</p> 123 167 </Dialog> 124 - </div> 168 + </Motion.div> 125 169 ); 126 170 }
+81 -48
web/src/pages/Home.tsx
··· 1 + import { Card } from "$components/ui/Card"; 2 + import { EmptyState } from "$components/ui/EmptyState"; 3 + import { Skeleton } from "$components/ui/Skeleton"; 4 + import { Tag } from "$components/ui/Tag"; 1 5 import { api } from "$lib/api"; 2 6 import type { Deck } from "$lib/model"; 7 + import { Button } from "$ui/Button"; 3 8 import { A } from "@solidjs/router"; 4 9 import type { Component } from "solid-js"; 5 - import { createResource, For, Show } from "solid-js"; 10 + import { createResource, For, Index, Show } from "solid-js"; 11 + import { Motion } from "solid-motionone"; 12 + 13 + const DeckCard: Component<{ deck: Deck; index: number }> = (props) => ( 14 + <Motion.div 15 + initial={{ opacity: 0, y: 20 }} 16 + animate={{ opacity: 1, y: 0 }} 17 + transition={{ duration: 0.4, delay: props.index * 0.05 }}> 18 + <Card class="h-full flex flex-col hover:border-[#0F62FE] transition-colors group"> 19 + <div class="flex justify-between items-start mb-2"> 20 + <h3 class="text-lg font-normal text-[#F4F4F4] group-hover:text-[#0F62FE] transition-colors line-clamp-1"> 21 + {props.deck.title} 22 + </h3> 23 + <Show when={props.deck.visibility.type !== "Public"}> 24 + <Tag label={props.deck.visibility.type} color="gray" class="text-[10px]" /> 25 + </Show> 26 + </div> 27 + <p class="text-sm text-[#C6C6C6] mb-6 line-clamp-2 grow font-light">{props.deck.description}</p> 28 + 29 + <div class="flex items-center gap-2 mb-4 flex-wrap"> 30 + <For each={props.deck.tags}>{(tag) => <Tag label={`#${tag}`} color="blue" />}</For> 31 + </div> 32 + 33 + <div class="flex justify-end pt-4 border-t border-[#393939] mt-auto"> 34 + <A 35 + href={`/decks/${props.deck.id}`} 36 + class="text-sm font-medium text-[#0F62FE] hover:text-[#0353E9] flex items-center gap-1"> 37 + View Deck <span class="group-hover:translate-x-1 transition-transform">→</span> 38 + </A> 39 + </div> 40 + </Card> 41 + </Motion.div> 42 + ); 6 43 7 - const DeckCard: Component<{ deck: Deck }> = (props) => ( 8 - <div class="bg-[#262626] border border-[#393939] p-4 hover:border-[#0F62FE] transition-colors group relative h-full flex flex-col"> 44 + const DeckCardSkeleton: Component = () => ( 45 + <Card class="h-full flex flex-col"> 9 46 <div class="flex justify-between items-start mb-2"> 10 - <h3 class="text-lg font-normal text-[#F4F4F4] group-hover:text-[#0F62FE] transition-colors line-clamp-1"> 11 - {props.deck.title} 12 - </h3> 13 - <Show when={props.deck.visibility.type !== "Public"}> 14 - <span class="text-[10px] uppercase font-bold tracking-widest px-2 py-0.5 bg-[#393939] text-[#C6C6C6]"> 15 - {props.deck.visibility.type} 16 - </span> 17 - </Show> 47 + <Skeleton width="60%" height="1.5rem" /> 18 48 </div> 19 - <p class="text-sm text-[#C6C6C6] mb-6 line-clamp-2 flex-grow font-light">{props.deck.description}</p> 20 - 21 - <div class="flex items-center gap-2 mb-4 flex-wrap"> 22 - <For each={props.deck.tags}> 23 - {(tag) => <span class="text-xs text-[#8D8D8D] bg-[#161616] px-2 py-0.5 border border-[#393939]">#{tag}</span>} 24 - </For> 49 + <div class="space-y-2 mb-6 grow"> 50 + <Skeleton width="100%" height="0.875rem" /> 51 + <Skeleton width="80%" height="0.875rem" /> 52 + </div> 53 + <div class="flex gap-2 mb-4"> 54 + <Skeleton width="4rem" height="1.5rem" rounded="full" /> 55 + <Skeleton width="3rem" height="1.5rem" rounded="full" /> 25 56 </div> 26 - 27 - <div class="flex justify-end pt-4 border-t border-[#393939] mt-auto"> 28 - <A 29 - href={`/decks/${props.deck.id}`} 30 - class="text-sm font-medium text-[#0F62FE] hover:text-[#0353E9] flex items-center gap-1"> 31 - View Deck <span class="group-hover:translate-x-1 transition-transform">→</span> 32 - </A> 57 + <div class="pt-4 border-t border-[#393939] mt-auto"> 58 + <Skeleton width="5rem" height="1rem" /> 33 59 </div> 34 - </div> 60 + </Card> 35 61 ); 36 62 37 63 const Home: Component = () => { 38 64 const [decks] = createResource(async () => { 39 65 const res = await api.getDecks(); 40 - return res.ok ? (await res.json() as Deck[]) : []; 66 + return res.ok ? ((await res.json()) as Deck[]) : []; 41 67 }); 42 68 43 69 return ( 44 - <div class="max-w-7xl mx-auto px-0 py-8"> 45 - <div class="flex justify-between items-end mb-12 border-b border-[#393939] pb-4"> 70 + <Motion.div 71 + initial={{ opacity: 0 }} 72 + animate={{ opacity: 1 }} 73 + transition={{ duration: 0.3 }} 74 + class="max-w-7xl mx-auto px-0 py-8"> 75 + <Motion.div 76 + initial={{ opacity: 0, y: -10 }} 77 + animate={{ opacity: 1, y: 0 }} 78 + transition={{ duration: 0.4 }} 79 + class="flex justify-between items-end mb-12 border-b border-[#393939] pb-4"> 46 80 <div> 47 - <h1 class="text-4xl font-light text-[#F4F4F4] tracking-tight mb-2">Library</h1> 81 + <h1 class="text-4xl text-[#F4F4F4] tracking-tight mb-2">Library</h1> 48 82 <p class="text-[#C6C6C6] font-light">Manage your study decks and discover new content.</p> 49 83 </div> 50 - <A 51 - href="/decks/new" 52 - class="bg-[#0F62FE] hover:bg-[#0353E9] text-white px-6 py-3 font-medium text-sm transition-colors flex items-center gap-2"> 53 - <span>+</span> Create Deck 84 + <A href="/decks/new"> 85 + <Button class="flex items-center gap-2"> 86 + <span class="i-bi-plus-lg" /> Create Deck 87 + </Button> 54 88 </A> 55 - </div> 89 + </Motion.div> 56 90 57 91 <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> 58 - <Show 59 - when={!decks.loading} 60 - fallback={ 61 - <div class="col-span-full h-32 flex items-center justify-center text-[#8D8D8D] font-light"> 62 - Loading library... 63 - </div> 64 - }> 92 + <Show when={!decks.loading} fallback={<Index each={Array(6)}>{() => <DeckCardSkeleton />}</Index>}> 65 93 <For 66 94 each={decks()} 67 95 fallback={ 68 - <div class="col-span-full py-16 text-center border border-dashed border-[#393939] bg-[#262626]/50"> 69 - <h3 class="text-lg font-medium text-[#F4F4F4] mb-2">No decks found</h3> 70 - <p class="text-sm text-[#C6C6C6] max-w-sm mx-auto font-light"> 71 - Create your first deck to get started with spaced repetition learning. 72 - </p> 96 + <div class="col-span-full"> 97 + <EmptyState 98 + title="No decks found" 99 + description="Create your first deck to get started with spaced repetition learning." 100 + icon={<span class="i-bi-collection text-4xl text-[#525252]" />} 101 + action={ 102 + <A href="/decks/new"> 103 + <Button>Create Your First Deck</Button> 104 + </A> 105 + } /> 73 106 </div> 74 107 }> 75 - {(deck) => <DeckCard deck={deck} />} 108 + {(deck, i) => <DeckCard deck={deck} index={i()} />} 76 109 </For> 77 110 </Show> 78 111 </div> 79 - </div> 112 + </Motion.div> 80 113 ); 81 114 }; 82 115
+3 -3
web/src/pages/Landing.tsx
··· 44 44 const Feature: Component<{ title: string; desc: string; icon: JSX.Element }> = (props) => ( 45 45 <div class="border border-neutral-800 p-6 hover:border-blue-600 transition-colors group h-full bg-neutral-900/50 backdrop-blur-sm"> 46 46 <div class="w-10 h-10 mb-4 text-blue-500 group-hover:text-blue-400 transition-colors">{props.icon}</div> 47 - <h3 class="text-xl font-light text-white mb-2 group-hover:text-blue-400 transition-colors">{props.title}</h3> 47 + <h3 class="text-xl text-white mb-2 group-hover:text-blue-400 transition-colors">{props.title}</h3> 48 48 <p class="text-neutral-400 font-light leading-relaxed">{props.desc}</p> 49 49 </div> 50 50 ); ··· 134 134 initial={{ opacity: 0, y: 20 }} 135 135 animate={{ opacity: 1, y: 0 }} 136 136 transition={{ duration: 0.6 }} 137 - class="text-5xl md:text-7xl font-light tracking-tight mb-8 leading-[1.1]"> 137 + class="text-7xl md:text-8xl font-medium tracking-tight mb-8 leading-[1.1]"> 138 138 Learning on <br /> 139 - <span class="text-neutral-500">the AT Protocol.</span> 139 + <h1 class="text-neutral-500">the AT Protocol.</h1> 140 140 </Motion.h1> 141 141 <Motion.p 142 142 initial={{ opacity: 0, y: 20 }}