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

Configure Feed

Select the types of activity you want to include in your feed.

feat: add Landing page and deck creation

+229 -44
+19 -2
web/src/App.tsx
··· 1 1 import { Route, Router } from "@solidjs/router"; 2 2 import type { Component } from "solid-js"; 3 + import { Show } from "solid-js"; 3 4 import { AppLayout } from "./components/layout/AppLayout"; 5 + import { authStore } from "./lib/store"; 6 + import DeckNew from "./pages/DeckNew"; 4 7 import Home from "./pages/Home"; 8 + import Landing from "./pages/Landing"; 5 9 import Login from "./pages/Login"; 6 10 11 + const Root: Component = () => { 12 + return ( 13 + <Show when={authStore.isAuthenticated()} fallback={<Landing />}> 14 + <AppLayout> 15 + <Router> 16 + <Route path="/" component={Home} /> 17 + <Route path="/decks/new" component={DeckNew} /> 18 + </Router> 19 + </AppLayout> 20 + </Show> 21 + ); 22 + }; 23 + 7 24 const App: Component = () => { 8 25 return ( 9 - <Router root={AppLayout}> 10 - <Route path="/" component={Home} /> 26 + <Router> 27 + <Route path="/" component={Root} /> 11 28 <Route path="/login" component={Login} /> 12 29 </Router> 13 30 );
+6 -1
web/src/components/DeckEditor.tsx
··· 3 3 import type { Visibility } from "../lib/store"; 4 4 import { toast } from "../lib/toast"; 5 5 6 - export function DeckEditor() { 6 + export function DeckEditor(props: { onSave?: (data: any) => void }) { 7 7 const [title, setTitle] = createSignal(""); 8 8 const [description, setDescription] = createSignal(""); 9 9 const [visibilityType, setVisibilityType] = createSignal<string>("Private"); ··· 20 20 } 21 21 22 22 const payload = { title: title(), description: description(), tags: [], visibility }; 23 + 24 + if (props.onSave) { 25 + props.onSave(payload); 26 + return; 27 + } 23 28 24 29 try { 25 30 await api.post("/decks", payload);
+1 -1
web/src/components/layout/AppLayout.tsx
··· 6 6 7 7 export const AppLayout: Component<AppLayoutProps> = (props) => { 8 8 return ( 9 - <div class="min-h-screen bg-black text-gray-100 font-sans selection:bg-blue-500/30"> 9 + <div class="min-h-screen bg-[#161616] text-[#F4F4F4] font-sans selection:bg-[#0F62FE]/30"> 10 10 <Toaster /> 11 11 <Header /> 12 12 <main class="container mx-auto px-4 py-8 md:px-6 lg:px-8 max-w-7xl">{props.children}</main>
+5 -1
web/src/lib/store.ts
··· 10 10 }; 11 11 12 12 function createAuthStore() { 13 - const [user, setUser] = createSignal<User | null>(null); 13 + const [user, setUser] = createSignal<User | null>( 14 + localStorage.getItem("did") 15 + ? { did: localStorage.getItem("did")!, handle: localStorage.getItem("handle") || "" } 16 + : null, 17 + ); 14 18 const [accessJwt, setAccessJwt] = createSignal<string | null>(localStorage.getItem("accessJwt")); 15 19 const [_refreshJwt, setRefreshJwt] = createSignal<string | null>(localStorage.getItem("refreshJwt")); 16 20
+37
web/src/pages/DeckNew.tsx
··· 1 + import { useNavigate } from "@solidjs/router"; 2 + import type { Component } from "solid-js"; 3 + import { DeckEditor } from "../components/DeckEditor"; 4 + import { api } from "../lib/api"; 5 + import { toast } from "../lib/toast"; 6 + 7 + const DeckNew: Component = () => { 8 + const navigate = useNavigate(); 9 + 10 + const handleSave = async (data: any) => { 11 + try { 12 + const res = await api.post("/decks", data); 13 + if (res.ok) { 14 + const deck = await res.json(); 15 + toast.success("Deck created successfully"); 16 + navigate(`/decks/${deck.id}`); 17 + } else { 18 + const err = await res.json(); 19 + toast.error(err.error || "Failed to create deck"); 20 + } 21 + } catch (e) { 22 + toast.error("Network error"); 23 + } 24 + }; 25 + 26 + return ( 27 + <div class="max-w-3xl mx-auto"> 28 + <div class="mb-8"> 29 + <h1 class="text-3xl font-light text-[#F4F4F4] mb-2 tracking-tight">Create New Deck</h1> 30 + <p class="text-[#C6C6C6] font-light">Start a new collection of flashcards.</p> 31 + </div> 32 + <DeckEditor onSave={handleSave} /> 33 + </div> 34 + ); 35 + }; 36 + 37 + export default DeckNew;
+20 -26
web/src/pages/Home.tsx
··· 22 22 23 23 const DeckCard: Component<{ deck: Deck }> = (props) => { 24 24 return ( 25 - <div class="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 p-4 hover:border-blue-500 transition-colors group relative"> 25 + <div class="bg-[#262626] border border-[#393939] p-4 hover:border-[#0F62FE] transition-colors group relative h-full flex flex-col"> 26 26 <div class="flex justify-between items-start mb-2"> 27 - <h3 class="text-lg font-normal text-neutral-900 dark:text-gray-100 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"> 27 + <h3 class="text-lg font-normal text-[#F4F4F4] group-hover:text-[#0F62FE] transition-colors line-clamp-1"> 28 28 {props.deck.title} 29 29 </h3> 30 30 <Show when={props.deck.visibility !== "Public"}> 31 - <span class="text-[10px] uppercase font-bold tracking-widest px-2 py-0.5 bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400"> 31 + <span class="text-[10px] uppercase font-bold tracking-widest px-2 py-0.5 bg-[#393939] text-[#C6C6C6]"> 32 32 {props.deck.visibility} 33 33 </span> 34 34 </Show> 35 35 </div> 36 - <p class="text-sm text-neutral-500 dark:text-neutral-400 mb-6 line-clamp-2 min-h-[2.5em] font-light"> 37 - {props.deck.description} 38 - </p> 36 + <p class="text-sm text-[#C6C6C6] mb-6 line-clamp-2 flex-grow font-light">{props.deck.description}</p> 39 37 40 38 <div class="flex items-center gap-2 mb-4 flex-wrap"> 41 39 <For each={props.deck.tags}> 42 - {(tag) => ( 43 - <span class="text-xs text-neutral-500 dark:text-neutral-400 bg-neutral-50 dark:bg-neutral-900/50 px-2 py-0.5 border border-neutral-100 dark:border-neutral-800"> 44 - #{tag} 45 - </span> 46 - )} 40 + {(tag) => <span class="text-xs text-[#8D8D8D] bg-[#161616] px-2 py-0.5 border border-[#393939]">#{tag}</span>} 47 41 </For> 48 42 </div> 49 43 50 - <div class="flex justify-end pt-4 border-t border-neutral-100 dark:border-neutral-800"> 44 + <div class="flex justify-end pt-4 border-t border-[#393939] mt-auto"> 51 45 <A 52 46 href={`/decks/${props.deck.id}`} 53 - class="text-sm font-medium text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300"> 54 - View Deck → 47 + class="text-sm font-medium text-[#0F62FE] hover:text-[#0353E9] flex items-center gap-1"> 48 + View Deck <span class="group-hover:translate-x-1 transition-transform">→</span> 55 49 </A> 56 50 </div> 57 51 </div> ··· 62 56 const [decks] = createResource(fetchDecks); 63 57 64 58 return ( 65 - <div class="max-w-7xl mx-auto px-6 py-12"> 66 - <div class="flex justify-between items-end mb-12 border-b border-neutral-200 dark:border-neutral-800 pb-4"> 59 + <div class="max-w-7xl mx-auto px-0 py-8"> 60 + <div class="flex justify-between items-end mb-12 border-b border-[#393939] pb-4"> 67 61 <div> 68 - <h1 class="text-4xl font-light text-neutral-900 dark:text-white tracking-tight mb-2">Library</h1> 69 - <p class="text-neutral-500 dark:text-neutral-400 font-light"> 70 - Manage your study decks and discover new content. 71 - </p> 62 + <h1 class="text-4xl font-light text-[#F4F4F4] tracking-tight mb-2">Library</h1> 63 + <p class="text-[#C6C6C6] font-light">Manage your study decks and discover new content.</p> 72 64 </div> 73 - <button class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 font-medium text-sm transition-colors flex items-center gap-2 shadow-sm"> 65 + <A 66 + href="/decks/new" 67 + class="bg-[#0F62FE] hover:bg-[#0353E9] text-white px-6 py-3 font-medium text-sm transition-colors flex items-center gap-2"> 74 68 <span>+</span> Create Deck 75 - </button> 69 + </A> 76 70 </div> 77 71 78 72 <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> 79 73 <Show when={decks.loading}> 80 - <div class="col-span-full h-32 flex items-center justify-center text-neutral-400 font-light"> 74 + <div class="col-span-full h-32 flex items-center justify-center text-[#8D8D8D] font-light"> 81 75 Loading library... 82 76 </div> 83 77 </Show> 84 78 85 79 <Show when={!decks.loading && decks()?.length === 0}> 86 - <div class="col-span-full py-16 text-center border border-dashed border-neutral-300 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900/30"> 87 - <h3 class="text-lg font-medium text-neutral-900 dark:text-gray-100 mb-2">No decks found</h3> 88 - <p class="text-sm text-neutral-500 max-w-sm mx-auto font-light"> 80 + <div class="col-span-full py-16 text-center border border-dashed border-[#393939] bg-[#262626]/50"> 81 + <h3 class="text-lg font-medium text-[#F4F4F4] mb-2">No decks found</h3> 82 + <p class="text-sm text-[#C6C6C6] max-w-sm mx-auto font-light"> 89 83 Create your first deck to get started with spaced repetition learning. 90 84 </p> 91 85 </div>
+39
web/src/pages/Landing.test.tsx
··· 1 + import { MemoryRouter, Route } from "@solidjs/router"; 2 + import { cleanup, render, screen } from "@solidjs/testing-library"; 3 + import { afterEach, describe, expect, it } from "vitest"; 4 + import Landing from "./Landing"; 5 + 6 + describe("Landing Page", () => { 7 + afterEach(cleanup); 8 + 9 + function renderLanding() { 10 + render(() => ( 11 + <MemoryRouter> 12 + <Route path="/" component={Landing} /> 13 + </MemoryRouter> 14 + )); 15 + } 16 + it("renders hero text correctly", () => { 17 + renderLanding(); 18 + 19 + expect(screen.getByText(/A Learning OS/i)).toBeInTheDocument(); 20 + expect(screen.getByText(/for daily study/i)).toBeInTheDocument(); 21 + expect(screen.getByText(/Master complex topics/i)).toBeInTheDocument(); 22 + }); 23 + 24 + it("renders 'Get Started' link pointing to login", () => { 25 + renderLanding(); 26 + 27 + const cta = screen.getByRole("link", { name: /Get Started/i }); 28 + expect(cta).toBeInTheDocument(); 29 + expect(cta).toHaveAttribute("href", "/login"); 30 + }); 31 + 32 + it("renders feature grid items", () => { 33 + renderLanding(); 34 + 35 + expect(screen.getByText("Flashcards")).toBeInTheDocument(); 36 + expect(screen.getByText("Linked Notes")).toBeInTheDocument(); 37 + expect(screen.getByText("Social Learning")).toBeInTheDocument(); 38 + }); 39 + });
+83
web/src/pages/Landing.tsx
··· 1 + import { A } from "@solidjs/router"; 2 + import type { Component } from "solid-js"; 3 + 4 + const Feature: Component<{ title: string; desc: string }> = (props) => ( 5 + <div class="border border-neutral-800 p-6 hover:border-blue-600 transition-colors group h-full"> 6 + <h3 class="text-xl font-light text-white mb-2 group-hover:text-blue-500 transition-colors">{props.title}</h3> 7 + <p class="text-neutral-400 font-light leading-relaxed">{props.desc}</p> 8 + </div> 9 + ); 10 + 11 + const Landing: Component = () => { 12 + return ( 13 + <div class="min-h-screen bg-black text-white font-sans selection:bg-blue-500/30"> 14 + <header class="border-b border-neutral-900"> 15 + <div class="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between"> 16 + <div class="font-bold tracking-tight text-xl">Malfestio</div> 17 + <A href="/login" class="text-sm font-medium text-neutral-400 hover:text-white transition-colors">Log in</A> 18 + </div> 19 + </header> 20 + 21 + <main> 22 + <section class="max-w-7xl mx-auto px-6 py-24 md:py-32 border-b border-neutral-900"> 23 + <div class="max-w-3xl"> 24 + <h1 class="text-5xl md:text-7xl font-light tracking-tight mb-8 leading-[1.1]"> 25 + A Learning OS <br /> 26 + <span class="text-neutral-500">for daily study.</span> 27 + </h1> 28 + <p class="text-xl text-neutral-400 font-light mb-12 max-w-2xl leading-relaxed"> 29 + Master complex topics with spaced repetition, linked notes, and active recall. Designed for serious 30 + learners who want to own their data. 31 + </p> 32 + <div class="flex gap-4"> 33 + <A 34 + href="/login" 35 + class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-4 font-medium text-lg transition-colors inline-flex items-center gap-2"> 36 + Get Started 37 + <span class="text-xl">→</span> 38 + </A> 39 + </div> 40 + </div> 41 + </section> 42 + 43 + <section class="max-w-7xl mx-auto px-6 py-24"> 44 + <div class="grid grid-cols-1 md:grid-cols-3 gap-6"> 45 + <Feature 46 + title="Flashcards" 47 + desc="Built-in spaced repetition system (SRS) ensuring you review the right material at the right time." /> 48 + <Feature 49 + title="Linked Notes" 50 + desc="Connect concepts with bidirectional links. Build a knowledge graph that grows with your understanding." /> 51 + <Feature 52 + title="Lectures & Articles" 53 + desc="Import content directly. Highlight, annotate, and turn key insights into flashcards instantly." /> 54 + <Feature 55 + title="Social Learning" 56 + desc="Publish your decks, follow curators, and fork existing content to improve it for everyone." /> 57 + <Feature 58 + title="Local-First" 59 + desc="Your data lives on your device. Offline-first architecture with ATProto for decentralized sync." /> 60 + <Feature 61 + title="Open Source" 62 + desc="Validates knowledge, not proprietary locks. Inspect the code, extend the schema, own the platform." /> 63 + </div> 64 + </section> 65 + </main> 66 + 67 + <footer class="border-t border-[#393939] py-12 bg-[#161616]"> 68 + <div class="max-w-7xl mx-auto px-6 text-[#C6C6C6] text-xs font-light flex flex-col md:flex-row justify-between items-center gap-4"> 69 + <p>© 2024 Malfestio. All rights reserved.</p> 70 + <div class="flex gap-6"> 71 + <a href="https://github.com/stormlightlabs" target="_blank" class="hover:text-[#F4F4F4] transition-colors"> 72 + GitHub 73 + </a> 74 + <a href="#" class="hover:text-[#F4F4F4] transition-colors">Docs</a> 75 + <a href="#" class="hover:text-[#F4F4F4] transition-colors">Privacy</a> 76 + </div> 77 + </div> 78 + </footer> 79 + </div> 80 + ); 81 + }; 82 + 83 + export default Landing;
+13 -12
web/src/pages/Login.tsx
··· 35 35 }; 36 36 37 37 return ( 38 - <div class="min-h-[calc(100vh-4rem)] flex items-center justify-center bg-neutral-100 dark:bg-black p-4"> 39 - <div class="w-full max-w-md bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 p-8 shadow-sm"> 40 - <h1 class="text-3xl font-light text-neutral-900 dark:text-white mb-8 tracking-tight">Login</h1> 38 + <div class="min-h-[calc(100vh-4rem)] flex items-center justify-center bg-[#161616] p-4 font-sans text-[#F4F4F4]"> 39 + <div class="w-full max-w-md bg-[#262626] border border-[#393939] p-8 shadow-lg"> 40 + <h1 class="text-3xl font-light text-[#F4F4F4] mb-2 tracking-tight">Log in</h1> 41 + <p class="text-[#C6C6C6] text-sm mb-8 font-light">Continue to Malfestio</p> 41 42 42 43 <form onSubmit={handleLogin} class="space-y-6"> 43 44 {error() && ( 44 - <div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm p-4 border border-red-200 dark:border-red-900/50"> 45 - {error()} 45 + <div class="bg-red-900/20 text-red-400 text-sm p-4 border-l-2 border-red-500 flex items-start gap-2"> 46 + <span class="font-bold">Error:</span> {error()} 46 47 </div> 47 48 )} 48 49 49 50 <div class="space-y-2"> 50 - <label class="block text-xs font-semibold text-neutral-500 uppercase tracking-wider">Handle</label> 51 + <label class="block text-xs font-semibold text-[#8D8D8D] uppercase tracking-wider">Handle</label> 51 52 <input 52 53 type="text" 53 54 value={identifier()} 54 55 onInput={(e) => setIdentifier(e.currentTarget.value)} 55 - class="w-full bg-neutral-100 dark:bg-neutral-800 border-b border-neutral-400 dark:border-neutral-600 focus:border-blue-500 focus:outline-none p-3 transition-colors text-neutral-900 dark:text-white rounded-t-sm" 56 + class="w-full bg-[#161616] border-b border-[#8D8D8D] focus:border-[#0F62FE] focus:outline-none p-4 transition-colors text-[#F4F4F4] placeholder-[#525252]" 56 57 placeholder="user.bsky.social" 57 58 required /> 58 59 </div> 59 60 60 61 <div class="space-y-2"> 61 - <label class="block text-xs font-semibold text-neutral-500 uppercase tracking-wider">App Password</label> 62 + <label class="block text-xs font-semibold text-[#8D8D8D] uppercase tracking-wider">App Password</label> 62 63 <input 63 64 type="password" 64 65 value={password()} 65 66 onInput={(e) => setPassword(e.currentTarget.value)} 66 - class="w-full bg-neutral-100 dark:bg-neutral-800 border-b border-neutral-400 dark:border-neutral-600 focus:border-blue-500 focus:outline-none p-3 transition-colors text-neutral-900 dark:text-white rounded-t-sm" 67 + class="w-full bg-[#161616] border-b border-[#8D8D8D] focus:border-[#0F62FE] focus:outline-none p-4 transition-colors text-[#F4F4F4] placeholder-[#525252]" 67 68 placeholder="••••••••" 68 69 required /> 69 70 </div> ··· 72 73 <button 73 74 type="submit" 74 75 disabled={isLoading()} 75 - class="w-full bg-neutral-900 dark:bg-white text-white dark:text-neutral-900 hover:bg-neutral-800 dark:hover:bg-neutral-100 py-4 font-medium text-sm text-left px-6 flex justify-between items-center transition-colors disabled:opacity-50 disabled:cursor-not-allowed"> 76 + class="w-full bg-[#0F62FE] hover:bg-[#0353E9] text-white py-4 font-medium text-sm text-left px-4 flex justify-between items-center transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-[#393939]"> 76 77 {isLoading() ? "Authenticating..." : "Continue"} 77 78 <span class="text-lg">→</span> 78 79 </button> 79 80 </div> 80 81 </form> 81 82 82 - <div class="mt-8 text-xs text-neutral-500 dark:text-neutral-400"> 83 - <p class="mt-2">Use your BlueSky App Password, not your main password.</p> 83 + <div class="mt-8 text-xs text-[#8D8D8D] border-t border-[#393939] pt-4"> 84 + <p>Use your BlueSky App Password, not your main password.</p> 84 85 </div> 85 86 </div> 86 87 </div>
+6 -1
web/vite.config.ts
··· 4 4 5 5 export default defineConfig({ 6 6 plugins: [solid(), tailwindcss()], 7 - test: { environment: "jsdom", ui: false, watch: false }, 7 + test: { 8 + environment: "jsdom", 9 + ui: false, 10 + watch: false, 11 + server: { deps: { inline: ["@solidjs/router", "solid-js"] } }, 12 + }, 8 13 });