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

refactor: centralize shared type definitions in model module

* rearrange conditional rendering

+1 -1
web/src/components/CardEditor.tsx
··· 1 - import type { CardType } from "$lib/store"; 1 + import type { CardType } from "$lib/model"; 2 2 import { Button } from "$ui/Button"; 3 3 import { createEffect, createSignal, Show } from "solid-js"; 4 4
+15 -13
web/src/components/DeckEditor.tsx
··· 1 + import { fadeIn, scaleIn } from "$lib/animations"; 1 2 import { api } from "$lib/api"; 2 - import type { Card, CardType, CreateDeckPayload, Visibility } from "$lib/store"; 3 + import type { Card, CardType, CreateDeckPayload, Visibility } from "$lib/model"; 3 4 import { toast } from "$lib/toast"; 4 5 import { Button } from "$ui/Button"; 5 6 import { createSignal, For, Show } from "solid-js"; 7 + import { Motion } from "solid-motionone"; 6 8 import { CardEditor } from "./CardEditor"; 7 9 10 + type CardData = Card & { hints: string[]; cardType: CardType }; 11 + 8 12 export function DeckEditor(props: { onSave?: (deck: CreateDeckPayload) => void }) { 9 13 const [title, setTitle] = createSignal(""); 10 14 const [description, setDescription] = createSignal(""); 11 15 const [tags, setTags] = createSignal(""); 12 - const [visibilityType, setVisibilityType] = createSignal<string>("Private"); 16 + const [visibilityType, setVisibilityType] = createSignal<Visibility["type"]>("Private"); 13 17 const [sharedWith, setSharedWith] = createSignal(""); 14 18 15 19 const [cards, setCards] = createSignal<Card[]>([]); ··· 22 26 if (visibilityType() === "SharedWith") { 23 27 visibility = { type: "SharedWith", content: sharedWith().split(",").map(s => s.trim()).filter(s => s) }; 24 28 } else { 25 - visibility = { type: visibilityType() as "Private" | "Unlisted" | "Public" }; 29 + visibility = { type: visibilityType() as Exclude<Visibility["type"], "SharedWith"> }; 26 30 } 27 31 28 32 const tagsArray = tags().split(",").map(t => t.trim()).filter(t => t); ··· 45 49 } 46 50 }; 47 51 48 - const addCard = ( 49 - cardData: { front: string; back: string; mediaUrl?: string; cardType: CardType; hints: string[] }, 50 - ) => { 52 + const addCard = (cardData: CardData) => { 51 53 const card: Card = { 52 54 front: cardData.front, 53 55 back: cardData.back, ··· 59 61 setShowCardEditor(false); 60 62 }; 61 63 62 - const removeCard = (index: number) => { 63 - setCards(cards().filter((_, i) => i !== index)); 64 - }; 64 + const removeCard = (index: number) => setCards(cards().filter((_, i) => i !== index)); 65 65 66 66 const moveCard = (from: number, to: number) => { 67 67 if (to < 0 || to >= cards().length) return; ··· 113 113 <select 114 114 id="visibility" 115 115 value={visibilityType()} 116 - onChange={(e) => setVisibilityType(e.target.value)} 116 + onChange={(e) => setVisibilityType(e.target.value as Visibility["type"])} 117 117 class="w-full bg-gray-800 border-gray-700 text-white rounded p-2 focus:ring-blue-500 focus:border-blue-500" 118 118 aria-label="Visibility"> 119 119 <option value="Private">Private</option> ··· 124 124 </div> 125 125 126 126 <Show when={visibilityType() === "SharedWith"}> 127 - <div> 127 + <Motion.div {...fadeIn}> 128 128 <label class="block text-sm font-medium text-gray-400 mb-1">Share with DIDs (comma separated)</label> 129 129 <input 130 130 type="text" ··· 132 132 onInput={(e) => setSharedWith(e.target.value)} 133 133 class="w-full bg-gray-800 border-gray-700 text-white rounded p-2 focus:ring-blue-500 focus:border-blue-500" 134 134 placeholder="did:plc:..., did:plc:..." /> 135 - </div> 135 + </Motion.div> 136 136 </Show> 137 137 </div> 138 138 ··· 191 191 Add Card 192 192 </Button> 193 193 }> 194 - <CardEditor onSave={addCard} onCancel={() => setShowCardEditor(false)} /> 194 + <Motion.div {...scaleIn}> 195 + <CardEditor onSave={addCard} onCancel={() => setShowCardEditor(false)} /> 196 + </Motion.div> 195 197 </Show> 196 198 </div> 197 199
+33 -33
web/src/components/ReviewStats.tsx
··· 1 1 import { fadeIn } from "$lib/animations"; 2 2 import { api } from "$lib/api"; 3 - import type { ReviewCard, StudyStats } from "$lib/store"; 3 + import type { ReviewCard, StudyStats } from "$lib/model"; 4 4 import { Skeleton } from "$ui/Skeleton"; 5 - import type { Component } from "solid-js"; 5 + import { type Component, Show } from "solid-js"; 6 6 import { Motion } from "solid-motionone"; 7 7 8 8 type ReviewStatsProps = { stats: StudyStats | null; loading?: boolean }; ··· 10 10 export const ReviewStats: Component<ReviewStatsProps> = (props) => { 11 11 return ( 12 12 <Motion.div {...fadeIn} class="bg-gray-900 rounded-xl p-6 border border-gray-800"> 13 - {/* TODO: use solid conditional components instead of ternary */} 14 - {props.loading 15 - ? ( 13 + <Show 14 + when={!props.loading} 15 + fallback={ 16 16 <div class="space-y-4"> 17 17 <Skeleton class="h-6 w-32" /> 18 18 <Skeleton class="h-4 w-48" /> 19 19 <Skeleton class="h-4 w-40" /> 20 20 </div> 21 - ) 22 - : props.stats 23 - ? ( 24 - <div class="space-y-4"> 25 - <div class="flex items-center justify-between"> 26 - <h3 class="text-lg font-semibold text-white">Study Progress</h3> 27 - {/* TODO: fire icon */} 28 - <span class="text-2xl">🔥 {props.stats.current_streak} day streak</span> 29 - </div> 30 - 31 - <div class="grid grid-cols-3 gap-4 text-center"> 32 - <div class="bg-gray-800 rounded-lg p-4"> 33 - <p class="text-3xl font-bold text-blue-400">{props.stats.due_count}</p> 34 - <p class="text-sm text-gray-400">Due Today</p> 35 - </div> 36 - <div class="bg-gray-800 rounded-lg p-4"> 37 - <p class="text-3xl font-bold text-green-400">{props.stats.reviewed_today}</p> 38 - <p class="text-sm text-gray-400">Reviewed</p> 21 + }> 22 + <Show when={props.stats} fallback={<p class="text-gray-400">No stats available</p>}> 23 + {stats => ( 24 + <div class="space-y-4"> 25 + <div class="flex items-center justify-between"> 26 + <h3 class="text-lg font-semibold text-white">Study Progress</h3> 27 + {/* TODO: fire icon */} 28 + <span class="text-2xl">🔥 {stats().current_streak} day streak</span> 39 29 </div> 40 - <div class="bg-gray-800 rounded-lg p-4"> 41 - <p class="text-3xl font-bold text-purple-400">{props.stats.total_reviews}</p> 42 - <p class="text-sm text-gray-400">Total</p> 30 + 31 + <div class="grid grid-cols-3 gap-4 text-center"> 32 + <div class="bg-gray-800 rounded-lg p-4"> 33 + <p class="text-3xl font-bold text-blue-400">{stats().due_count}</p> 34 + <p class="text-sm text-gray-400">Due Today</p> 35 + </div> 36 + <div class="bg-gray-800 rounded-lg p-4"> 37 + <p class="text-3xl font-bold text-green-400">{stats().reviewed_today}</p> 38 + <p class="text-sm text-gray-400">Reviewed</p> 39 + </div> 40 + <div class="bg-gray-800 rounded-lg p-4"> 41 + <p class="text-3xl font-bold text-purple-400">{stats().total_reviews}</p> 42 + <p class="text-sm text-gray-400">Total</p> 43 + </div> 43 44 </div> 45 + <Show when={stats().longest_streak > 0}> 46 + <p class="text-sm text-gray-500 text-center">Longest streak: {stats().longest_streak} days</p> 47 + </Show> 44 48 </div> 45 - 46 - {props.stats.longest_streak > 0 && ( 47 - <p class="text-sm text-gray-500 text-center">Longest streak: {props.stats.longest_streak} days</p> 48 - )} 49 - </div> 50 - ) 51 - : <p class="text-gray-400">No stats available</p>} 49 + )} 50 + </Show> 51 + </Show> 52 52 </Motion.div> 53 53 ); 54 54 };
+2 -14
web/src/components/StudySession.tsx
··· 1 1 import { scaleIn, slideInUp } from "$lib/animations"; 2 2 import { api } from "$lib/api"; 3 - import type { Grade, ReviewCard } from "$lib/store"; 3 + import type { Grade, ReviewCard } from "$lib/model"; 4 4 import { Button } from "$ui/Button"; 5 5 import { Dialog } from "$ui/Dialog"; 6 6 import { ProgressBar } from "$ui/ProgressBar"; ··· 27 27 const currentCard = () => props.cards[currentIndex()]; 28 28 const progress = () => ((currentIndex() + 1) / props.cards.length) * 100; 29 29 const isComplete = () => currentIndex() >= props.cards.length; 30 - 31 - const handleFlip = () => { 32 - if (!isFlipped()) { 33 - setIsFlipped(true); 34 - } 35 - }; 30 + const handleFlip = () => !isFlipped() ? setIsFlipped(true) : void 0; 36 31 37 32 const handleGrade = async (grade: Grade) => { 38 33 const card = currentCard(); ··· 94 89 window.removeEventListener("keydown", handleKeyDown); 95 90 }); 96 91 97 - // Check for completion 98 92 createEffect(() => { 99 93 if (isComplete()) { 100 94 props.onComplete(); ··· 114 108 <ProgressBar value={progress()} color="green" size="md" /> 115 109 </div> 116 110 117 - {/* Card */} 118 111 <Show when={currentCard()}> 119 112 {(card) => ( 120 113 <Motion.div {...scaleIn} class="w-full max-w-2xl"> ··· 122 115 onClick={handleFlip} 123 116 class="relative min-h-[400px] rounded-2xl cursor-pointer perspective-1000" 124 117 style={{ "transform-style": "preserve-3d" }}> 125 - {/* Front */} 126 118 <div 127 119 class={`absolute inset-0 rounded-2xl bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 p-8 flex flex-col items-center justify-center backface-hidden transition-transform duration-400 ${ 128 120 isFlipped() ? "rotate-y-180" : "" ··· 135 127 </Show> 136 128 </div> 137 129 138 - {/* Back */} 139 130 <div 140 131 class={`absolute inset-0 rounded-2xl bg-gradient-to-br from-gray-800 to-gray-900 border border-gray-700 p-8 flex flex-col items-center justify-center backface-hidden transition-transform duration-400 ${ 141 132 isFlipped() ? "" : "rotate-y-180" ··· 154 145 )} 155 146 </Show> 156 147 157 - {/* Grade Buttons */} 158 148 <Show when={isFlipped()}> 159 149 <Motion.div {...slideInUp} class="w-full max-w-2xl mt-8"> 160 150 <p class="text-center text-gray-400 text-sm mb-4">How well did you know this?</p> ··· 176 166 </Motion.div> 177 167 </Show> 178 168 179 - {/* Keyboard Hints */} 180 169 <div class="fixed bottom-4 left-1/2 -translate-x-1/2 text-gray-600 text-xs flex gap-4"> 181 170 <span>Space: Flip</span> 182 171 <span>1-5: Grade</span> ··· 184 173 <span>Esc: Exit</span> 185 174 </div> 186 175 187 - {/* Edit Dialog */} 188 176 <Dialog open={showEditDialog()} onClose={() => setShowEditDialog(false)} title="Edit Card"> 189 177 <Show when={currentCard()}> 190 178 {(card) => (
+3 -1
web/src/components/social/CommentSection.tsx
··· 3 3 import { Button } from "$ui/Button"; 4 4 import { createResource, createSignal, For, Show } from "solid-js"; 5 5 6 + // TODO: move to model.ts 6 7 type Comment = { 7 8 id: string; 8 9 deck_id: string; ··· 90 91 class="border rounded p-2 flex-1 w-full" 91 92 rows={2} 92 93 placeholder="Add a comment..." 93 - value={replyTo() ? "" : newComment()} // Clear if replying elsewhere, actually separate state might be better but simple for now 94 + // TODO: separate state 95 + value={replyTo() ? "" : newComment()} 94 96 onInput={(e) => { 95 97 if (!replyTo()) setNewComment(e.currentTarget.value); 96 98 }} />
+2 -2
web/src/components/social/FollowButton.tsx
··· 1 1 import { api } from "$lib/api"; 2 2 import { authStore } from "$lib/store"; 3 3 import { Button } from "$ui/Button"; 4 - import { createSignal, onMount } from "solid-js"; 4 + import { createSignal, onMount, Show } from "solid-js"; 5 5 6 6 type FollowButtonProps = { did: string; initialIsFollowing?: boolean }; 7 7 ··· 45 45 46 46 return ( 47 47 <Button onClick={toggle} disabled={loading()} variant={isFollowing() ? "secondary" : undefined}> 48 - {isFollowing() ? "Unfollow" : "Follow"} 48 + <Show when={isFollowing()} fallback="Follow">Unfollow</Show> 49 49 </Button> 50 50 ); 51 51 }
+60
web/src/lib/model.ts
··· 1 + export type User = { did: string; handle: string }; 2 + 3 + export type Visibility = { type: "Private" } | { type: "Unlisted" } | { type: "Public" } | { 4 + type: "SharedWith"; 5 + content: string[]; 6 + }; 7 + 8 + export type CardType = "basic" | "cloze"; 9 + 10 + export type Card = { 11 + id?: string; 12 + front: string; 13 + back: string; 14 + mediaUrl?: string; 15 + cardType?: CardType; 16 + hints?: string[]; 17 + }; 18 + 19 + export type Deck = { 20 + id: string; 21 + owner_did: string; 22 + title: string; 23 + description: string; 24 + tags: string[]; 25 + visibility: Visibility; 26 + published_at?: string; 27 + fork_of?: string; 28 + }; 29 + 30 + export type CreateDeckPayload = { 31 + title: string; 32 + description: string; 33 + tags: string[]; 34 + visibility: Visibility; 35 + cards: Card[]; 36 + }; 37 + 38 + export type Grade = 0 | 1 | 2 | 3 | 4 | 5; 39 + 40 + export type ReviewCard = { 41 + review_id: string; 42 + card_id: string; 43 + deck_id: string; 44 + deck_title: string; 45 + front: string; 46 + back: string; 47 + media_url?: string; 48 + hints: string[]; 49 + due_at: string; 50 + }; 51 + 52 + export type StudyStats = { 53 + due_count: number; 54 + current_streak: number; 55 + longest_streak: number; 56 + reviewed_today: number; 57 + total_reviews: number; 58 + }; 59 + 60 + export type ReviewResponse = { ease_factor: number; interval_days: number; repetitions: number; due_at: string };
+1 -61
web/src/lib/store.ts
··· 1 1 import { createRoot, createSignal } from "solid-js"; 2 - 3 - export type User = { did: string; handle: string }; 2 + import type { User } from "./model"; 4 3 5 4 export type AuthState = { 6 5 user: User | null; ··· 40 39 } 41 40 42 41 export const authStore = createRoot(createAuthStore); 43 - 44 - export type Visibility = { type: "Private" } | { type: "Unlisted" } | { type: "Public" } | { 45 - type: "SharedWith"; 46 - content: string[]; 47 - }; 48 - 49 - export type CardType = "basic" | "cloze"; 50 - 51 - export type Card = { 52 - id?: string; 53 - front: string; 54 - back: string; 55 - mediaUrl?: string; 56 - cardType?: CardType; 57 - hints?: string[]; 58 - }; 59 - 60 - export type Deck = { 61 - id: string; 62 - owner_did: string; 63 - title: string; 64 - description: string; 65 - tags: string[]; 66 - visibility: Visibility; 67 - published_at?: string; 68 - fork_of?: string; 69 - }; 70 - 71 - export type CreateDeckPayload = { 72 - title: string; 73 - description: string; 74 - tags: string[]; 75 - visibility: Visibility; 76 - cards: Card[]; 77 - }; 78 - 79 - export type Grade = 0 | 1 | 2 | 3 | 4 | 5; 80 - 81 - export type ReviewCard = { 82 - review_id: string; 83 - card_id: string; 84 - deck_id: string; 85 - deck_title: string; 86 - front: string; 87 - back: string; 88 - media_url?: string; 89 - hints: string[]; 90 - due_at: string; 91 - }; 92 - 93 - export type StudyStats = { 94 - due_count: number; 95 - current_streak: number; 96 - longest_streak: number; 97 - reviewed_today: number; 98 - total_reviews: number; 99 - }; 100 - 101 - export type ReviewResponse = { ease_factor: number; interval_days: number; repetitions: number; due_at: string };
+2 -1
web/src/pages/DeckNew.tsx
··· 1 1 import { DeckEditor } from "$components/DeckEditor"; 2 2 import { api } from "$lib/api"; 3 - import type { Card, CreateDeckPayload } from "$lib/store"; 3 + import type { Card, CreateDeckPayload } from "$lib/model"; 4 4 import { toast } from "$lib/toast"; 5 5 import { useNavigate } from "@solidjs/router"; 6 6 import type { Component } from "solid-js"; ··· 9 9 const navigate = useNavigate(); 10 10 11 11 const handleSave = async (data: CreateDeckPayload) => { 12 + // TODO: some of this can be in api.ts 12 13 try { 13 14 const { cards, ...deckPayload } = data; 14 15 const res = await api.post("/decks", deckPayload);
+94 -99
web/src/pages/DeckView.tsx
··· 2 2 import { FollowButton } from "$components/social/FollowButton"; 3 3 import { Button } from "$components/ui/Button"; 4 4 import { api } from "$lib/api"; 5 - import type { Visibility } from "$lib/store"; 5 + import type { Card, Deck } from "$lib/model"; 6 6 import { A, useParams } from "@solidjs/router"; 7 7 import type { Component } from "solid-js"; 8 8 import { createResource, For, Show } from "solid-js"; 9 - 10 - type Deck = { 11 - id: string; 12 - title: string; 13 - description: string; 14 - tags: string[]; 15 - visibility: Visibility; 16 - owner_did: string; 17 - }; 18 - 19 - type Card = { id: string; front: string; back?: string }; 20 9 21 10 // TODO: use api.ts 22 11 const fetchDeck = async (id: string): Promise<Deck | null> => { ··· 64 53 65 54 return ( 66 55 <div class="max-w-4xl mx-auto px-6 py-12"> 67 - <Show when={deck.loading}> 68 - <div class="text-[#8D8D8D] font-light">Loading deck...</div> 69 - </Show> 56 + <Show when={!deck.loading} fallback={<div class="text-[#8D8D8D] font-light">Loading deck...</div>}> 57 + <Show 58 + when={deck()} 59 + fallback={ 60 + <div class="p-8 border border-red-900/50 bg-red-900/10 text-red-400"> 61 + Deck not found or you don't have access. 62 + </div> 63 + }> 64 + {deckValue => ( 65 + <> 66 + <div class="mb-12"> 67 + <div class="flex justify-between items-start mb-4"> 68 + <h1 class="text-4xl font-light text-[#F4F4F4] tracking-tight">{deckValue().title}</h1> 69 + <Show when={deckValue().visibility.type !== "Public"}> 70 + <span class="text-xs uppercase font-bold tracking-widest px-2 py-1 bg-[#393939] text-[#C6C6C6]"> 71 + {deckValue().visibility.type} 72 + </span> 73 + </Show> 74 + </div> 70 75 71 - <Show when={!deck.loading && deck() === null}> 72 - <div class="p-8 border border-red-900/50 bg-red-900/10 text-red-400"> 73 - Deck not found or you don't have access. 74 - </div> 75 - </Show> 76 + <div class="flex items-center gap-4 mb-6"> 77 + <div class="text-[#C6C6C6] font-light">By {deckValue().owner_did}</div> 78 + <FollowButton did={deckValue().owner_did || ""} /> 79 + </div> 76 80 77 - <Show when={deck()}> 78 - <div class="mb-12"> 79 - <div class="flex justify-between items-start mb-4"> 80 - <h1 class="text-4xl font-light text-[#F4F4F4] tracking-tight">{deck()?.title}</h1> 81 - <Show when={deck()?.visibility.type !== "Public"}> 82 - <span class="text-xs uppercase font-bold tracking-widest px-2 py-1 bg-[#393939] text-[#C6C6C6]"> 83 - {deck()?.visibility.type} 84 - </span> 85 - </Show> 86 - </div> 81 + <p class="text-[#C6C6C6] mb-6 font-light">{deckValue().description}</p> 87 82 88 - <div class="flex items-center gap-4 mb-6"> 89 - <div class="text-[#C6C6C6] font-light">By {deck()?.owner_did}</div> 90 - <FollowButton did={deck()?.owner_did || ""} /> 91 - </div> 83 + <Show when={deckValue().tags.length > 0}> 84 + <div class="flex gap-2 mb-8"> 85 + <For each={deckValue().tags}> 86 + {(tag) => ( 87 + <span class="text-xs text-[#8D8D8D] bg-[#161616] px-2 py-1 border border-[#393939]"> 88 + #{tag} 89 + </span> 90 + )} 91 + </For> 92 + </div> 93 + </Show> 92 94 93 - <p class="text-[#C6C6C6] mb-6 font-light">{deck()?.description}</p> 95 + <div class="flex gap-4 border-t border-[#393939] pt-6"> 96 + <button class="bg-[#0F62FE] hover:bg-[#0353E9] text-white px-6 py-3 font-medium text-sm transition-colors"> 97 + Study Deck (Coming Soon) 98 + </button> 99 + <Button 100 + onClick={handleFork} 101 + variant="secondary" 102 + class="border border-[#393939] text-[#F4F4F4] hover:bg-[#262626] px-6 py-3 font-medium text-sm transition-colors"> 103 + Fork Deck 104 + </Button> 105 + <A 106 + href="/" 107 + class="px-6 py-3 border border-[#393939] text-[#F4F4F4] hover:bg-[#262626] font-medium text-sm transition-colors"> 108 + Back to Library 109 + </A> 110 + </div> 111 + </div> 112 + <div> 113 + <h2 class="text-xl font-medium text-[#F4F4F4] mb-6 border-b border-[#393939] pb-4"> 114 + Cards <Show when={cards()}>{value => value().length}</Show> 115 + </h2> 94 116 95 - <div class="flex gap-2 mb-8"> 96 - <For each={deck()?.tags}> 97 - {(tag) => ( 98 - <span class="text-xs text-[#8D8D8D] bg-[#161616] px-2 py-1 border border-[#393939]">#{tag}</span> 99 - )} 100 - </For> 101 - </div> 117 + <Show when={cards.loading}> 118 + <div class="text-[#8D8D8D] font-light text-sm">Loading cards...</div> 119 + </Show> 102 120 103 - <div class="flex gap-4 border-t border-[#393939] pt-6"> 104 - <button class="bg-[#0F62FE] hover:bg-[#0353E9] text-white px-6 py-3 font-medium text-sm transition-colors"> 105 - Study Deck (Coming Soon) 106 - </button> 107 - <Button 108 - onClick={handleFork} 109 - variant="secondary" 110 - class="border border-[#393939] text-[#F4F4F4] hover:bg-[#262626] px-6 py-3 font-medium text-sm transition-colors"> 111 - Fork Deck 112 - </Button> 113 - <A 114 - href="/" 115 - class="px-6 py-3 border border-[#393939] text-[#F4F4F4] hover:bg-[#262626] font-medium text-sm transition-colors"> 116 - Back to Library 117 - </A> 118 - </div> 119 - </div> 120 - 121 - <div> 122 - <h2 class="text-xl font-medium text-[#F4F4F4] mb-6 border-b border-[#393939] pb-4"> 123 - Cards ({cards()?.length || 0}) 124 - </h2> 125 - 126 - <Show when={cards.loading}> 127 - <div class="text-[#8D8D8D] font-light text-sm">Loading cards...</div> 128 - </Show> 129 - 130 - <div class="grid gap-4"> 131 - <For each={cards()}> 132 - {(card, i) => ( 133 - <div class="p-6 bg-[#262626] border border-[#393939] hover:border-[#525252] transition-colors group"> 134 - <div class="flex justify-between items-start mb-2 text-xs text-[#8D8D8D] font-mono"> 135 - <span class="opacity-50">CARD {i() + 1}</span> 136 - </div> 137 - <div class="grid md:grid-cols-2 gap-8"> 138 - <div class="prose prose-invert prose-sm max-w-none"> 139 - <div class="text-[10px] uppercase tracking-widest text-[#525252] mb-1">Front</div> 140 - <div class="text-[#E0E0E0]">{card.front}</div> 141 - </div> 142 - <div class="prose prose-invert prose-sm max-w-none md:border-l md:border-[#393939] md:pl-8"> 143 - <div class="text-[10px] uppercase tracking-widest text-[#525252] mb-1">Back</div> 144 - <div class="text-[#C6C6C6]">{card.back || <span class="italic opacity-50">Empty</span>}</div> 145 - </div> 146 - </div> 121 + <div class="grid gap-4"> 122 + <For 123 + each={cards()} 124 + fallback={ 125 + <div class="text-center py-12 border border-dashed border-[#393939] text-[#8D8D8D] font-light italic"> 126 + No cards in this deck. 127 + </div> 128 + }> 129 + {(card, i) => ( 130 + <div class="p-6 bg-[#262626] border border-[#393939] hover:border-[#525252] transition-colors group"> 131 + <div class="flex justify-between items-start mb-2 text-xs text-[#8D8D8D] font-mono"> 132 + <span class="opacity-50">CARD {i() + 1}</span> 133 + </div> 134 + <div class="grid md:grid-cols-2 gap-8"> 135 + <div class="prose prose-invert prose-sm max-w-none"> 136 + <div class="text-[10px] uppercase tracking-widest text-[#525252] mb-1">Front</div> 137 + <div class="text-[#E0E0E0]">{card.front}</div> 138 + </div> 139 + <div class="prose prose-invert prose-sm max-w-none md:border-l md:border-[#393939] md:pl-8"> 140 + <div class="text-[10px] uppercase tracking-widest text-[#525252] mb-1">Back</div> 141 + <div class="text-[#C6C6C6]"> 142 + {card.back || <span class="italic opacity-50">Empty</span>} 143 + </div> 144 + </div> 145 + </div> 146 + </div> 147 + )} 148 + </For> 147 149 </div> 148 - )} 149 - </For> 150 - 151 - <Show when={!cards.loading && cards()?.length === 0}> 152 - <div class="text-center py-12 border border-dashed border-[#393939] text-[#8D8D8D] font-light italic"> 153 - No cards in this deck. 154 150 </div> 155 - </Show> 156 - </div> 157 - </div> 158 - 159 - <div class="mt-12 pt-8 border-t border-[#393939]"> 160 - <CommentSection deckId={deck()!.id} /> 161 - </div> 151 + <div class="mt-12 pt-8 border-t border-[#393939]"> 152 + <CommentSection deckId={deckValue().id} /> 153 + </div> 154 + </> 155 + )} 156 + </Show> 162 157 </Show> 163 158 </div> 164 159 );
+16 -8
web/src/pages/Feed.tsx
··· 3 3 import { Card } from "$components/ui/Card"; 4 4 import { Tabs } from "$components/ui/Tabs"; 5 5 import { api } from "$lib/api"; 6 + import type { Deck } from "$lib/model"; 6 7 import { A } from "@solidjs/router"; 7 8 import { createResource, For, Match, Show, Switch } from "solid-js"; 8 - 9 - type Deck = { id: string; title: string; description: string; owner_did: string; published_at: string; tags: string[] }; 10 9 11 10 export default function Feed() { 12 11 const [followsFeed] = createResource(async () => { ··· 25 24 <div> 26 25 <h3 class="text-xl font-bold mb-1">{props.deck.title}</h3> 27 26 <p class="text-sm text-gray-400 mb-2"> 28 - By {props.deck.owner_did} • {new Date(props.deck.published_at).toLocaleDateString()} 27 + By {props.deck.owner_did} •{" "} 28 + <Show when={props.deck.published_at} fallback="Draft"> 29 + {published_at => new Date(published_at()).toLocaleDateString()} 30 + </Show> 29 31 </p> 30 32 <p class="mb-3">{props.deck.description}</p> 31 33 <div class="flex gap-2 mb-3"> ··· 67 69 <div class="mt-4"> 68 70 <Show when={followsFeed()}> 69 71 {feed => ( 70 - <Show 71 - when={feed().length > 0} 72 + <For 73 + each={feed()} 72 74 fallback={<div class="text-gray-500 py-8 text-center">No updates from followed users.</div>}> 73 - <For each={feed()}>{(deck) => <DeckItem deck={deck} />}</For> 74 - </Show> 75 + {(deck) => <DeckItem deck={deck} />} 76 + </For> 75 77 )} 76 78 </Show> 77 79 </div> 78 80 </Match> 79 81 <Match when={activeTab() === "trending"}> 80 82 <div class="mt-4"> 81 - <For each={valuableFeed()}>{(deck) => <DeckItem deck={deck} />}</For> 83 + <Show when={valuableFeed()}> 84 + {feed => ( 85 + <For each={feed()} fallback={<div class="text-gray-500 py-8 text-center">No trending decks.</div>}> 86 + {(deck) => <DeckItem deck={deck} />} 87 + </For> 88 + )} 89 + </Show> 82 90 </div> 83 91 </Match> 84 92 </Switch>
+47 -53
web/src/pages/Home.tsx
··· 1 1 import { api } from "$lib/api"; 2 - import type { Visibility } from "$lib/store"; 2 + import type { Deck } from "$lib/model"; 3 3 import { A } from "@solidjs/router"; 4 4 import type { Component } from "solid-js"; 5 5 import { createResource, For, Show } from "solid-js"; 6 6 7 - type Deck = { 8 - id: string; 9 - title: string; 10 - description: string; 11 - tags: string[]; 12 - visibility: Visibility; 13 - owner_did: string; 14 - }; 15 - 7 + // TODO: use api.ts 16 8 const fetchDecks = async (): Promise<Deck[]> => { 17 9 const res = await api.get("/decks"); 18 10 if (!res.ok) return []; 19 11 return res.json(); 20 12 }; 21 13 22 - const DeckCard: Component<{ deck: Deck }> = (props) => { 23 - return ( 24 - <div class="bg-[#262626] border border-[#393939] p-4 hover:border-[#0F62FE] transition-colors group relative h-full flex flex-col"> 25 - <div class="flex justify-between items-start mb-2"> 26 - <h3 class="text-lg font-normal text-[#F4F4F4] group-hover:text-[#0F62FE] transition-colors line-clamp-1"> 27 - {props.deck.title} 28 - </h3> 29 - <Show when={props.deck.visibility.type !== "Public"}> 30 - <span class="text-[10px] uppercase font-bold tracking-widest px-2 py-0.5 bg-[#393939] text-[#C6C6C6]"> 31 - {props.deck.visibility.type} 32 - </span> 33 - </Show> 34 - </div> 35 - <p class="text-sm text-[#C6C6C6] mb-6 line-clamp-2 flex-grow font-light">{props.deck.description}</p> 14 + const DeckCard: Component<{ deck: Deck }> = (props) => ( 15 + <div class="bg-[#262626] border border-[#393939] p-4 hover:border-[#0F62FE] transition-colors group relative h-full flex flex-col"> 16 + <div class="flex justify-between items-start mb-2"> 17 + <h3 class="text-lg font-normal text-[#F4F4F4] group-hover:text-[#0F62FE] transition-colors line-clamp-1"> 18 + {props.deck.title} 19 + </h3> 20 + <Show when={props.deck.visibility.type !== "Public"}> 21 + <span class="text-[10px] uppercase font-bold tracking-widest px-2 py-0.5 bg-[#393939] text-[#C6C6C6]"> 22 + {props.deck.visibility.type} 23 + </span> 24 + </Show> 25 + </div> 26 + <p class="text-sm text-[#C6C6C6] mb-6 line-clamp-2 flex-grow font-light">{props.deck.description}</p> 36 27 37 - <div class="flex items-center gap-2 mb-4 flex-wrap"> 38 - <For each={props.deck.tags}> 39 - {(tag) => <span class="text-xs text-[#8D8D8D] bg-[#161616] px-2 py-0.5 border border-[#393939]">#{tag}</span>} 40 - </For> 41 - </div> 28 + <div class="flex items-center gap-2 mb-4 flex-wrap"> 29 + <For each={props.deck.tags}> 30 + {(tag) => <span class="text-xs text-[#8D8D8D] bg-[#161616] px-2 py-0.5 border border-[#393939]">#{tag}</span>} 31 + </For> 32 + </div> 42 33 43 - <div class="flex justify-end pt-4 border-t border-[#393939] mt-auto"> 44 - <A 45 - href={`/decks/${props.deck.id}`} 46 - class="text-sm font-medium text-[#0F62FE] hover:text-[#0353E9] flex items-center gap-1"> 47 - View Deck <span class="group-hover:translate-x-1 transition-transform">→</span> 48 - </A> 49 - </div> 34 + <div class="flex justify-end pt-4 border-t border-[#393939] mt-auto"> 35 + <A 36 + href={`/decks/${props.deck.id}`} 37 + class="text-sm font-medium text-[#0F62FE] hover:text-[#0353E9] flex items-center gap-1"> 38 + View Deck <span class="group-hover:translate-x-1 transition-transform">→</span> 39 + </A> 50 40 </div> 51 - ); 52 - }; 41 + </div> 42 + ); 53 43 54 44 const Home: Component = () => { 55 45 const [decks] = createResource(fetchDecks); ··· 69 59 </div> 70 60 71 61 <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> 72 - <Show when={decks.loading}> 73 - <div class="col-span-full h-32 flex items-center justify-center text-[#8D8D8D] font-light"> 74 - Loading library... 75 - </div> 76 - </Show> 77 - 78 - <Show when={!decks.loading && decks()?.length === 0}> 79 - <div class="col-span-full py-16 text-center border border-dashed border-[#393939] bg-[#262626]/50"> 80 - <h3 class="text-lg font-medium text-[#F4F4F4] mb-2">No decks found</h3> 81 - <p class="text-sm text-[#C6C6C6] max-w-sm mx-auto font-light"> 82 - Create your first deck to get started with spaced repetition learning. 83 - </p> 84 - </div> 62 + <Show 63 + when={!decks.loading} 64 + fallback={ 65 + <div class="col-span-full h-32 flex items-center justify-center text-[#8D8D8D] font-light"> 66 + Loading library... 67 + </div> 68 + }> 69 + <For 70 + each={decks()} 71 + fallback={ 72 + <div class="col-span-full py-16 text-center border border-dashed border-[#393939] bg-[#262626]/50"> 73 + <h3 class="text-lg font-medium text-[#F4F4F4] mb-2">No decks found</h3> 74 + <p class="text-sm text-[#C6C6C6] max-w-sm mx-auto font-light"> 75 + Create your first deck to get started with spaced repetition learning. 76 + </p> 77 + </div> 78 + }> 79 + {(deck) => <DeckCard deck={deck} />} 80 + </For> 85 81 </Show> 86 - 87 - <For each={decks()}>{(deck) => <DeckCard deck={deck} />}</For> 88 82 </div> 89 83 </div> 90 84 );
+17 -11
web/src/pages/Review.tsx
··· 1 1 import { fetchDueCards, fetchStudyStats, ReviewStats } from "$components/ReviewStats"; 2 2 import { StudySession } from "$components/StudySession"; 3 3 import { fadeIn } from "$lib/animations"; 4 - import type { ReviewCard, StudyStats as StudyStatsType } from "$lib/store"; 4 + import type { ReviewCard, StudyStats as StudyStatsType } from "$lib/model"; 5 5 import { Button } from "$ui/Button"; 6 6 import { Skeleton } from "$ui/Skeleton"; 7 7 import { useNavigate, useParams } from "@solidjs/router"; ··· 66 66 when={cards().length > 0} 67 67 fallback={ 68 68 <div class="text-center py-8"> 69 + {/* TODO: replace with an icon */} 69 70 <p class="text-4xl mb-4">🎉</p> 70 - <h2 class="text-xl font-semibold text-white mb-2"> 71 - {sessionComplete() ? "Session Complete!" : "All Caught Up!"} 72 - </h2> 73 - <p class="text-gray-400 mb-6"> 74 - {sessionComplete() 75 - ? "Great job! You've reviewed all your due cards." 76 - : "You have no cards due for review right now."} 77 - </p> 71 + <Show 72 + when={sessionComplete()} 73 + fallback={ 74 + <> 75 + <h2 class="text-xl font-semibold text-white mb-2">All Caught Up!</h2> 76 + <p class="text-gray-400 mb-6">You have no cards due for review right now.</p> 77 + </> 78 + }> 79 + <> 80 + <h2 class="text-xl font-semibold text-white mb-2">Session Complete!</h2> 81 + <p class="text-gray-400 mb-6">Great job! You've reviewed all your due cards.</p> 82 + </> 83 + </Show> 78 84 <Button onClick={() => navigate("/")} variant="secondary">Back to Library</Button> 79 85 </div> 80 86 }> ··· 89 95 </Show> 90 96 </div> 91 97 92 - <div class="mt-8 bg-gray-900/50 rounded-xl p-6 border border-gray-800/50"> 98 + <Motion.div {...fadeIn} class="mt-8 bg-gray-900/50 rounded-xl p-6 border border-gray-800/50"> 93 99 <h3 class="text-sm font-semibold text-gray-400 mb-4">Keyboard Shortcuts</h3> 94 100 <div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm"> 95 101 <div class="flex items-center gap-2"> ··· 109 115 <span class="text-gray-400">Exit session</span> 110 116 </div> 111 117 </div> 112 - </div> 118 + </Motion.div> 113 119 </Motion.div> 114 120 </Show> 115 121 );