+2
-2
web/package.json
+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
+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
+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
+2
-2
web/src/fonts.d.ts
+2
-2
web/src/index.css
+2
-2
web/src/index.css
+2
-2
web/src/index.tsx
+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
+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
+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
+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
+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
+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
+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 }}