From d5ce8452e4cd028b4f2309dbe59d3498431488b5 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 4 Feb 2026 01:00:57 -0500 Subject: [PATCH] 4, partial 5 --- build.ts | 31 ++++ bun.lockb | Bin 0 -> 118599 bytes lint.ts | 18 -- package.json | 8 +- src/App.tsx | 206 ++++++++++++++++++++--- src/components/CodeValidation.tsx | 202 +++++++++++++++++++++++ src/components/FeedFilter.tsx | 177 ++++++++++++++++++++ src/components/FeedItem.tsx | 133 +++++++++++++++ src/components/FeedList.tsx | 200 +++++++++++++++++++++++ src/components/LoginScreen.tsx | 203 +++++++++++++++++++++++ src/components/OAuthPlaceholder.tsx | 145 +++++++++++++++++ src/components/SyncProfile.tsx | 186 +++++++++++++++++++++ src/config/auth.ts | 75 +++++++++ src/stores/auth.ts | 244 ++++++++++++++++++++++++++++ src/types/auth.ts | 65 ++++++++ src/types/episode.ts | 86 ++++++++++ src/types/feed.ts | 116 +++++++++++++ src/types/podcast.ts | 40 +++++ src/types/source.ts | 103 ++++++++++++ tasks/podcast-tui-app/README.md | 46 +++--- 20 files changed, 2215 insertions(+), 69 deletions(-) create mode 100755 bun.lockb delete mode 100644 lint.ts create mode 100644 src/components/CodeValidation.tsx create mode 100644 src/components/FeedFilter.tsx create mode 100644 src/components/FeedItem.tsx create mode 100644 src/components/FeedList.tsx create mode 100644 src/components/LoginScreen.tsx create mode 100644 src/components/OAuthPlaceholder.tsx create mode 100644 src/components/SyncProfile.tsx create mode 100644 src/config/auth.ts create mode 100644 src/stores/auth.ts create mode 100644 src/types/auth.ts create mode 100644 src/types/episode.ts create mode 100644 src/types/feed.ts create mode 100644 src/types/podcast.ts create mode 100644 src/types/source.ts diff --git a/build.ts b/build.ts index b45bd8d..2e0eaa8 100644 --- a/build.ts +++ b/build.ts @@ -1,5 +1,8 @@ import solidPlugin from "@opentui/solid/bun-plugin" +import { copyFileSync, existsSync, mkdirSync } from "node:fs" +import { join, dirname } from "node:path" +// Build the JavaScript bundle await Bun.build({ entrypoints: ["./src/index.tsx"], outdir: "./dist", @@ -9,4 +12,32 @@ await Bun.build({ plugins: [solidPlugin], }) +// Copy the native library to dist for distribution +const platform = process.platform +const arch = process.arch + +// Map platform/arch to OpenTUI package names +const platformMap: Record = { + "darwin-arm64": "darwin-arm64", + "darwin-x64": "darwin-x64", + "linux-x64": "linux-x64", + "linux-arm64": "linux-arm64", + "win32-x64": "win32-x64", + "win32-arm64": "win32-arm64", +} + +const platformKey = `${platform}-${arch}` +const platformPkg = platformMap[platformKey] + +if (platformPkg) { + const libName = platform === "win32" ? "opentui.dll" : "libopentui.dylib" + const srcPath = join("node_modules", `@opentui/core-${platformPkg}`, libName) + + if (existsSync(srcPath)) { + const destPath = join("dist", libName) + copyFileSync(srcPath, destPath) + console.log(`Copied native library: ${libName}`) + } +} + console.log("Build complete") diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000000000000000000000000000000000000..e1184cbc3dba83379b20a52f5e2a66b2dae63e5a GIT binary patch literal 118599 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 z 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 literal 0 HcmV?d00001 diff --git a/lint.ts b/lint.ts deleted file mode 100644 index d88728a..0000000 --- a/lint.ts +++ /dev/null @@ -1,18 +0,0 @@ -const proc = Bun.spawn({ - cmd: [ - "bunx", - "eslint", - "src/**/*.ts", - "src/**/*.tsx", - "tests/**/*.ts", - "tests/**/*.tsx", - ], - stdio: ["inherit", "inherit", "inherit"], -}) - -const exitCode = await proc.exited -if (exitCode !== 0) { - process.exit(exitCode) -} - -export {} diff --git a/package.json b/package.json index 9ce412d..e39b0c4 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,14 @@ "module": "src/index.tsx", "type": "module", "private": true, + "bin": { + "podtui": "./dist/index.js" + }, "scripts": { - "start": "bun run src/index.tsx", - "dev": "bun run --watch src/index.tsx", + "start": "bun src/index.tsx", + "dev": "bun --watch src/index.tsx", "build": "bun run build.ts", + "dist": "bun dist/index.js", "test": "bun test", "lint": "bun run lint.ts" }, diff --git a/src/App.tsx b/src/App.tsx index 3dfd31a..e9080b0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,45 +1,199 @@ -const createSignal = (value: T): [() => T, (next: T) => void] => { - let current = value - return [() => current, (next) => { - current = next - }] -} +import { createSignal } from "solid-js" import { Layout } from "./components/Layout" import { Navigation } from "./components/Navigation" import { TabNavigation } from "./components/TabNavigation" import { KeyboardHandler } from "./components/KeyboardHandler" import { SyncPanel } from "./components/SyncPanel" +import { FeedList } from "./components/FeedList" +import { LoginScreen } from "./components/LoginScreen" +import { CodeValidation } from "./components/CodeValidation" +import { OAuthPlaceholder } from "./components/OAuthPlaceholder" +import { SyncProfile } from "./components/SyncProfile" +import { useAuthStore } from "./stores/auth" import type { TabId } from "./components/Tab" +import type { Feed, FeedVisibility } from "./types/feed" +import type { AuthScreen } from "./types/auth" + +// Mock data for demonstration +const MOCK_FEEDS: Feed[] = [ + { + id: "1", + podcast: { + id: "p1", + title: "The Daily Tech News", + description: "Your daily dose of technology news and insights from around the world.", + feedUrl: "https://example.com/tech.rss", + lastUpdated: new Date(), + isSubscribed: true, + }, + episodes: [], + visibility: "public" as FeedVisibility, + sourceId: "rss", + lastUpdated: new Date(), + isPinned: true, + }, + { + id: "2", + podcast: { + id: "p2", + title: "Code & Coffee", + description: "Weekly discussions about programming, software development, and coffee.", + feedUrl: "https://example.com/code.rss", + lastUpdated: new Date(Date.now() - 86400000), + isSubscribed: true, + }, + episodes: [], + visibility: "private" as FeedVisibility, + sourceId: "rss", + lastUpdated: new Date(Date.now() - 86400000), + isPinned: false, + }, + { + id: "3", + podcast: { + id: "p3", + title: "Science Explained", + description: "Breaking down complex scientific topics for curious minds.", + feedUrl: "https://example.com/science.rss", + lastUpdated: new Date(Date.now() - 172800000), + isSubscribed: true, + }, + episodes: [], + visibility: "public" as FeedVisibility, + sourceId: "itunes", + lastUpdated: new Date(Date.now() - 172800000), + isPinned: false, + }, +] export function App() { - const activeTab = createSignal("discover") + const [activeTab, setActiveTab] = createSignal("discover") + const [authScreen, setAuthScreen] = createSignal("login") + const [showAuthPanel, setShowAuthPanel] = createSignal(false) + const auth = useAuthStore() + + const renderContent = () => { + const tab = activeTab() + + switch (tab) { + case "feeds": + return ( + { + // Would open feed detail view + }} + /> + ) + + case "settings": + // Show auth panel or sync panel based on state + if (showAuthPanel()) { + if (auth.isAuthenticated) { + return ( + { + auth.logout() + setShowAuthPanel(false) + }} + onManageSync={() => setShowAuthPanel(false)} + /> + ) + } + + switch (authScreen()) { + case "code": + return ( + setAuthScreen("login")} + /> + ) + case "oauth": + return ( + setAuthScreen("login")} + onNavigateToCode={() => setAuthScreen("code")} + /> + ) + case "login": + default: + return ( + setAuthScreen("code")} + onNavigateToOAuth={() => setAuthScreen("oauth")} + /> + ) + } + } + + return ( + + + + + + + Account: + + {auth.isAuthenticated ? ( + + Signed in as {auth.user?.email} + + ) : ( + + Not signed in + + )} + setShowAuthPanel(true)} + > + + + {auth.isAuthenticated ? "[A] Account" : "[A] Sign In"} + + + + + + + ) + + case "discover": + case "search": + case "player": + default: + return ( + + + {tab} +
+ Content placeholder - coming in later phases +
+
+ ) + } + } return ( - + + } footer={ - + } > - - {activeTab[0]() === "settings" ? ( - - ) : ( - - - {`${activeTab[0]()}`} -
- Content placeholder -
-
- )} -
+ {renderContent()}
) diff --git a/src/components/CodeValidation.tsx b/src/components/CodeValidation.tsx new file mode 100644 index 0000000..b1e8f83 --- /dev/null +++ b/src/components/CodeValidation.tsx @@ -0,0 +1,202 @@ +/** + * Code validation component for PodTUI + * 8-character alphanumeric code input for sync authentication + */ + +import { createSignal } from "solid-js" +import { useAuthStore } from "../stores/auth" +import { AUTH_CONFIG } from "../config/auth" + +interface CodeValidationProps { + focused?: boolean + onBack?: () => void +} + +type FocusField = "code" | "submit" | "back" + +export function CodeValidation(props: CodeValidationProps) { + const auth = useAuthStore() + const [code, setCode] = createSignal("") + const [focusField, setFocusField] = createSignal("code") + const [codeError, setCodeError] = createSignal(null) + + const fields: FocusField[] = ["code", "submit", "back"] + + /** Format code as user types (uppercase, alphanumeric only) */ + const handleCodeInput = (value: string) => { + const formatted = value.toUpperCase().replace(/[^A-Z0-9]/g, "") + // Limit to max length + const limited = formatted.slice(0, AUTH_CONFIG.codeValidation.codeLength) + setCode(limited) + + // Clear error when typing + if (codeError()) { + setCodeError(null) + } + } + + const validateCode = (value: string): boolean => { + if (!value) { + setCodeError("Code is required") + return false + } + if (value.length !== AUTH_CONFIG.codeValidation.codeLength) { + setCodeError(`Code must be ${AUTH_CONFIG.codeValidation.codeLength} characters`) + return false + } + if (!AUTH_CONFIG.codeValidation.allowedChars.test(value)) { + setCodeError("Code must contain only letters and numbers") + return false + } + setCodeError(null) + return true + } + + const handleSubmit = async () => { + if (!validateCode(code())) { + return + } + + const success = await auth.validateCode(code()) + if (!success && auth.error) { + setCodeError(auth.error.message) + } + } + + const handleKeyPress = (key: { name: string; shift?: boolean }) => { + if (key.name === "tab") { + const currentIndex = fields.indexOf(focusField()) + const nextIndex = key.shift + ? (currentIndex - 1 + fields.length) % fields.length + : (currentIndex + 1) % fields.length + setFocusField(fields[nextIndex]) + } else if (key.name === "return" || key.name === "enter") { + if (focusField() === "submit") { + handleSubmit() + } else if (focusField() === "back" && props.onBack) { + props.onBack() + } + } else if (key.name === "escape" && props.onBack) { + props.onBack() + } + } + + const codeProgress = () => { + const len = code().length + const max = AUTH_CONFIG.codeValidation.codeLength + return `${len}/${max}` + } + + const codeDisplay = () => { + const current = code() + const max = AUTH_CONFIG.codeValidation.codeLength + const filled = current.split("") + const empty = Array(max - filled.length).fill("_") + return [...filled, ...empty].join(" ") + } + + return ( + + + Enter Sync Code + + + + + + + Enter your 8-character sync code to link your account. + + + + + You can get this code from the web portal. + + + + + + {/* Code display */} + + + + Code ({codeProgress()}): + + + + + + + {codeDisplay()} + + + + + {/* Hidden input for actual typing */} + + + {codeError() && ( + + {codeError()} + + )} + + + + + {/* Action buttons */} + + + + + {auth.isLoading ? "Validating..." : "[Enter] Validate Code"} + + + + + + + + [Esc] Back to Login + + + + + + {/* Auth error message */} + {auth.error && ( + + {auth.error.message} + + )} + + + + + Tab to navigate, Enter to select, Esc to go back + + + ) +} diff --git a/src/components/FeedFilter.tsx b/src/components/FeedFilter.tsx new file mode 100644 index 0000000..944cb7c --- /dev/null +++ b/src/components/FeedFilter.tsx @@ -0,0 +1,177 @@ +/** + * Feed filter component for PodTUI + * Toggle and filter options for feed list + */ + +import { createSignal } from "solid-js" +import type { FeedFilter, FeedVisibility, FeedSortField } from "../types/feed" + +interface FeedFilterProps { + filter: FeedFilter + focused?: boolean + onFilterChange: (filter: FeedFilter) => void +} + +type FilterField = "visibility" | "sort" | "pinned" | "search" + +export function FeedFilterComponent(props: FeedFilterProps) { + const [focusField, setFocusField] = createSignal("visibility") + const [searchValue, setSearchValue] = createSignal(props.filter.searchQuery || "") + + const fields: FilterField[] = ["visibility", "sort", "pinned", "search"] + + const handleKeyPress = (key: { name: string; shift?: boolean }) => { + if (key.name === "tab") { + const currentIndex = fields.indexOf(focusField()) + const nextIndex = key.shift + ? (currentIndex - 1 + fields.length) % fields.length + : (currentIndex + 1) % fields.length + setFocusField(fields[nextIndex]) + } else if (key.name === "return" || key.name === "enter") { + if (focusField() === "visibility") { + cycleVisibility() + } else if (focusField() === "sort") { + cycleSort() + } else if (focusField() === "pinned") { + togglePinned() + } + } else if (key.name === "space") { + if (focusField() === "pinned") { + togglePinned() + } + } + } + + const cycleVisibility = () => { + const current = props.filter.visibility + let next: FeedVisibility | "all" + if (current === "all") next = "public" + else if (current === "public") next = "private" + else next = "all" + props.onFilterChange({ ...props.filter, visibility: next }) + } + + const cycleSort = () => { + const sortOptions: FeedSortField[] = ["updated", "title", "episodeCount", "latestEpisode"] + const currentIndex = sortOptions.indexOf(props.filter.sortBy as FeedSortField) + const nextIndex = (currentIndex + 1) % sortOptions.length + props.onFilterChange({ ...props.filter, sortBy: sortOptions[nextIndex] }) + } + + const togglePinned = () => { + props.onFilterChange({ + ...props.filter, + pinnedOnly: !props.filter.pinnedOnly, + }) + } + + const handleSearchInput = (value: string) => { + setSearchValue(value) + props.onFilterChange({ ...props.filter, searchQuery: value }) + } + + const visibilityLabel = () => { + const vis = props.filter.visibility + if (vis === "all") return "All" + if (vis === "public") return "Public" + return "Private" + } + + const visibilityColor = () => { + const vis = props.filter.visibility + if (vis === "public") return "green" + if (vis === "private") return "yellow" + return "white" + } + + const sortLabel = () => { + const sort = props.filter.sortBy + switch (sort) { + case "title": + return "Title" + case "episodeCount": + return "Episodes" + case "latestEpisode": + return "Latest" + case "updated": + default: + return "Updated" + } + } + + return ( + + + Filter Feeds + + + + {/* Visibility filter */} + + + + Show:{" "} + + {visibilityLabel()} + + + + {/* Sort filter */} + + + Sort: + {sortLabel()} + + + + {/* Pinned filter */} + + + + Pinned:{" "} + + + {props.filter.pinnedOnly ? "Yes" : "No"} + + + + + + {/* Search box */} + + + Search: + + + + + + Tab to navigate, Enter/Space to toggle + + + ) +} diff --git a/src/components/FeedItem.tsx b/src/components/FeedItem.tsx new file mode 100644 index 0000000..ba56913 --- /dev/null +++ b/src/components/FeedItem.tsx @@ -0,0 +1,133 @@ +/** + * Feed item component for PodTUI + * Displays a single feed/podcast in the list + */ + +import type { Feed, FeedVisibility } from "../types/feed" +import { format } from "date-fns" + +interface FeedItemProps { + feed: Feed + isSelected: boolean + showEpisodeCount?: boolean + showLastUpdated?: boolean + compact?: boolean +} + +export function FeedItem(props: FeedItemProps) { + const formatDate = (date: Date): string => { + return format(date, "MMM d") + } + + const episodeCount = () => props.feed.episodes.length + const unplayedCount = () => { + // This would be calculated based on episode status + return props.feed.episodes.length + } + + const visibilityIcon = () => { + return props.feed.visibility === "public" ? "[P]" : "[*]" + } + + const visibilityColor = () => { + return props.feed.visibility === "public" ? "green" : "yellow" + } + + const pinnedIndicator = () => { + return props.feed.isPinned ? "*" : " " + } + + if (props.compact) { + // Compact single-line view + return ( + + + + {props.isSelected ? ">" : " "} + + + + {visibilityIcon()} + + + + {props.feed.customName || props.feed.podcast.title} + + + {props.showEpisodeCount && ( + + ({episodeCount()}) + + )} + + ) + } + + // Full view with details + return ( + + {/* Title row */} + + + + {props.isSelected ? ">" : " "} + + + + {visibilityIcon()} + + + {pinnedIndicator()} + + + + {props.feed.customName || props.feed.podcast.title} + + + + + {/* Details row */} + + {props.showEpisodeCount && ( + + + {episodeCount()} episodes ({unplayedCount()} new) + + + )} + {props.showLastUpdated && ( + + + Updated: {formatDate(props.feed.lastUpdated)} + + + )} + + + {/* Description (truncated) */} + {props.feed.podcast.description && ( + + + + {props.feed.podcast.description.slice(0, 60)} + {props.feed.podcast.description.length > 60 ? "..." : ""} + + + + )} + + ) +} diff --git a/src/components/FeedList.tsx b/src/components/FeedList.tsx new file mode 100644 index 0000000..7515883 --- /dev/null +++ b/src/components/FeedList.tsx @@ -0,0 +1,200 @@ +/** + * Feed list component for PodTUI + * Scrollable list of feeds with keyboard navigation + */ + +import { createSignal, For, Show } from "solid-js" +import { FeedItem } from "./FeedItem" +import type { Feed, FeedFilter, FeedVisibility, FeedSortField } from "../types/feed" +import { format } from "date-fns" + +interface FeedListProps { + feeds: Feed[] + focused?: boolean + compact?: boolean + showEpisodeCount?: boolean + showLastUpdated?: boolean + onSelectFeed?: (feed: Feed) => void + onOpenFeed?: (feed: Feed) => void +} + +export function FeedList(props: FeedListProps) { + const [selectedIndex, setSelectedIndex] = createSignal(0) + const [filter, setFilter] = createSignal({ + visibility: "all", + sortBy: "updated" as FeedSortField, + sortDirection: "desc", + }) + + /** Get filtered and sorted feeds */ + const filteredFeeds = (): Feed[] => { + let result = [...props.feeds] + + // Filter by visibility + const vis = filter().visibility + if (vis && vis !== "all") { + result = result.filter((f) => f.visibility === vis) + } + + // Filter by pinned only + if (filter().pinnedOnly) { + result = result.filter((f) => f.isPinned) + } + + // Filter by search query + const query = filter().searchQuery?.toLowerCase() + if (query) { + result = result.filter( + (f) => + f.podcast.title.toLowerCase().includes(query) || + f.customName?.toLowerCase().includes(query) || + f.podcast.description?.toLowerCase().includes(query) + ) + } + + // Sort feeds + const sortField = filter().sortBy + const sortDir = filter().sortDirection === "asc" ? 1 : -1 + + result.sort((a, b) => { + switch (sortField) { + case "title": + return ( + sortDir * + (a.customName || a.podcast.title).localeCompare( + b.customName || b.podcast.title + ) + ) + case "episodeCount": + return sortDir * (a.episodes.length - b.episodes.length) + case "latestEpisode": + const aLatest = a.episodes[0]?.pubDate?.getTime() || 0 + const bLatest = b.episodes[0]?.pubDate?.getTime() || 0 + return sortDir * (aLatest - bLatest) + case "updated": + default: + return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime()) + } + }) + + // Pinned feeds always first + result.sort((a, b) => { + if (a.isPinned && !b.isPinned) return -1 + if (!a.isPinned && b.isPinned) return 1 + return 0 + }) + + return result + } + + const handleKeyPress = (key: { name: string }) => { + const feeds = filteredFeeds() + + if (key.name === "up" || key.name === "k") { + setSelectedIndex((i) => Math.max(0, i - 1)) + } else if (key.name === "down" || key.name === "j") { + setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1)) + } else if (key.name === "return" || key.name === "enter") { + const feed = feeds[selectedIndex()] + if (feed && props.onOpenFeed) { + props.onOpenFeed(feed) + } + } else if (key.name === "home") { + setSelectedIndex(0) + } else if (key.name === "end") { + setSelectedIndex(feeds.length - 1) + } else if (key.name === "pageup") { + setSelectedIndex((i) => Math.max(0, i - 5)) + } else if (key.name === "pagedown") { + setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5)) + } + + // Notify selection change + const selectedFeed = feeds[selectedIndex()] + if (selectedFeed && props.onSelectFeed) { + props.onSelectFeed(selectedFeed) + } + } + + const toggleVisibilityFilter = () => { + setFilter((f) => { + const current = f.visibility + let next: FeedVisibility | "all" + if (current === "all") next = "public" + else if (current === "public") next = "private" + else next = "all" + return { ...f, visibility: next } + }) + } + + const visibilityLabel = () => { + const vis = filter().visibility + if (vis === "all") return "All" + if (vis === "public") return "Public" + return "Private" + } + + return ( + + {/* Header with filter */} + + + My Feeds + ({filteredFeeds().length} feeds) + + + + + [F] {visibilityLabel()} + + + + + + {/* Feed list in scrollbox */} + 0} + fallback={ + + + + No feeds found. Add podcasts from the Discover or Search tabs. + + + + } + > + + + {(feed, index) => ( + + )} + + + + + {/* Navigation help */} + + + + j/k or arrows to navigate, Enter to open, F to filter + + + + + ) +} diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx new file mode 100644 index 0000000..70bd795 --- /dev/null +++ b/src/components/LoginScreen.tsx @@ -0,0 +1,203 @@ +/** + * Login screen component for PodTUI + * Email/password login with links to code validation and OAuth + */ + +import { createSignal } from "solid-js" +import { useAuthStore } from "../stores/auth" +import { AUTH_CONFIG } from "../config/auth" + +interface LoginScreenProps { + focused?: boolean + onNavigateToCode?: () => void + onNavigateToOAuth?: () => void +} + +type FocusField = "email" | "password" | "submit" | "code" | "oauth" + +export function LoginScreen(props: LoginScreenProps) { + const auth = useAuthStore() + const [email, setEmail] = createSignal("") + const [password, setPassword] = createSignal("") + const [focusField, setFocusField] = createSignal("email") + const [emailError, setEmailError] = createSignal(null) + const [passwordError, setPasswordError] = createSignal(null) + + const fields: FocusField[] = ["email", "password", "submit", "code", "oauth"] + + const validateEmail = (value: string): boolean => { + if (!value) { + setEmailError("Email is required") + return false + } + if (!AUTH_CONFIG.email.pattern.test(value)) { + setEmailError("Invalid email format") + return false + } + setEmailError(null) + return true + } + + const validatePassword = (value: string): boolean => { + if (!value) { + setPasswordError("Password is required") + return false + } + if (value.length < AUTH_CONFIG.password.minLength) { + setPasswordError(`Minimum ${AUTH_CONFIG.password.minLength} characters`) + return false + } + setPasswordError(null) + return true + } + + const handleSubmit = async () => { + const isEmailValid = validateEmail(email()) + const isPasswordValid = validatePassword(password()) + + if (!isEmailValid || !isPasswordValid) { + return + } + + await auth.login({ email: email(), password: password() }) + } + + const handleKeyPress = (key: { name: string; shift?: boolean }) => { + if (key.name === "tab") { + const currentIndex = fields.indexOf(focusField()) + const nextIndex = key.shift + ? (currentIndex - 1 + fields.length) % fields.length + : (currentIndex + 1) % fields.length + setFocusField(fields[nextIndex]) + } else if (key.name === "return" || key.name === "enter") { + if (focusField() === "submit") { + handleSubmit() + } else if (focusField() === "code" && props.onNavigateToCode) { + props.onNavigateToCode() + } else if (focusField() === "oauth" && props.onNavigateToOAuth) { + props.onNavigateToOAuth() + } + } + } + + return ( + + + Sign In + + + + + {/* Email field */} + + + + Email: + + + + {emailError() && ( + + {emailError()} + + )} + + + {/* Password field */} + + + + Password: + + + + {passwordError() && ( + + {passwordError()} + + )} + + + + + {/* Submit button */} + + + + + {auth.isLoading ? "Signing in..." : "[Enter] Sign In"} + + + + + + {/* Auth error message */} + {auth.error && ( + + {auth.error.message} + + )} + + + + {/* Alternative auth options */} + + Or authenticate with: + + + + + + + [C] Sync Code + + + + + + + + [O] OAuth Info + + + + + + + + + Tab to navigate, Enter to select + + + ) +} diff --git a/src/components/OAuthPlaceholder.tsx b/src/components/OAuthPlaceholder.tsx new file mode 100644 index 0000000..beb16fc --- /dev/null +++ b/src/components/OAuthPlaceholder.tsx @@ -0,0 +1,145 @@ +/** + * OAuth placeholder component for PodTUI + * Displays OAuth limitations and alternative authentication methods + */ + +import { createSignal } from "solid-js" +import { OAUTH_PROVIDERS, OAUTH_LIMITATION_MESSAGE } from "../config/auth" + +interface OAuthPlaceholderProps { + focused?: boolean + onBack?: () => void + onNavigateToCode?: () => void +} + +type FocusField = "code" | "back" + +export function OAuthPlaceholder(props: OAuthPlaceholderProps) { + const [focusField, setFocusField] = createSignal("code") + + const fields: FocusField[] = ["code", "back"] + + const handleKeyPress = (key: { name: string; shift?: boolean }) => { + if (key.name === "tab") { + const currentIndex = fields.indexOf(focusField()) + const nextIndex = key.shift + ? (currentIndex - 1 + fields.length) % fields.length + : (currentIndex + 1) % fields.length + setFocusField(fields[nextIndex]) + } else if (key.name === "return" || key.name === "enter") { + if (focusField() === "code" && props.onNavigateToCode) { + props.onNavigateToCode() + } else if (focusField() === "back" && props.onBack) { + props.onBack() + } + } else if (key.name === "escape" && props.onBack) { + props.onBack() + } + } + + return ( + + + OAuth Authentication + + + + + {/* OAuth providers list */} + + Available OAuth Providers: + + + + {OAUTH_PROVIDERS.map((provider) => ( + + + {provider.enabled ? "[+]" : "[-]"} {provider.name} + + - {provider.description} + + ))} + + + + + {/* Limitation message */} + + + Terminal Limitations + + + + + {OAUTH_LIMITATION_MESSAGE.split("\n").map((line) => ( + + {line} + + ))} + + + + + {/* Alternative options */} + + Recommended Alternatives: + + + + + [1] + Use a sync code from the web portal + + + [2] + Use email/password authentication + + + [3] + Use file-based sync (no account needed) + + + + + + {/* Action buttons */} + + + + + [C] Enter Sync Code + + + + + + + + [Esc] Back to Login + + + + + + + + + Tab to navigate, Enter to select, Esc to go back + + + ) +} diff --git a/src/components/SyncProfile.tsx b/src/components/SyncProfile.tsx new file mode 100644 index 0000000..5773d6a --- /dev/null +++ b/src/components/SyncProfile.tsx @@ -0,0 +1,186 @@ +/** + * Sync profile component for PodTUI + * Displays user profile information and sync status + */ + +import { createSignal } from "solid-js" +import { useAuthStore } from "../stores/auth" +import { format } from "date-fns" + +interface SyncProfileProps { + focused?: boolean + onLogout?: () => void + onManageSync?: () => void +} + +type FocusField = "sync" | "export" | "logout" + +export function SyncProfile(props: SyncProfileProps) { + const auth = useAuthStore() + const [focusField, setFocusField] = createSignal("sync") + const [lastSyncTime] = createSignal(new Date()) + + const fields: FocusField[] = ["sync", "export", "logout"] + + const handleKeyPress = (key: { name: string; shift?: boolean }) => { + if (key.name === "tab") { + const currentIndex = fields.indexOf(focusField()) + const nextIndex = key.shift + ? (currentIndex - 1 + fields.length) % fields.length + : (currentIndex + 1) % fields.length + setFocusField(fields[nextIndex]) + } else if (key.name === "return" || key.name === "enter") { + if (focusField() === "sync" && props.onManageSync) { + props.onManageSync() + } else if (focusField() === "logout" && props.onLogout) { + handleLogout() + } + } + } + + const handleLogout = () => { + auth.logout() + if (props.onLogout) { + props.onLogout() + } + } + + const formatDate = (date: Date | null | undefined): string => { + if (!date) return "Never" + return format(date, "MMM d, yyyy HH:mm") + } + + const user = () => auth.state().user + + // Get user initials for avatar + const userInitials = () => { + const name = user()?.name || "?" + return name.slice(0, 2).toUpperCase() + } + + return ( + + + User Profile + + + + + {/* User avatar and info */} + + {/* ASCII avatar */} + + + {userInitials()} + + + + {/* User details */} + + + {user()?.name || "Guest User"} + + + {user()?.email || "No email"} + + + + Joined: {formatDate(user()?.createdAt)} + + + + + + + + {/* Sync status section */} + + + Sync Status + + + + + Status: + + + + {user()?.syncEnabled ? "Enabled" : "Disabled"} + + + + + + + Last Sync: + + + {formatDate(lastSyncTime())} + + + + + + Method: + + + File-based (JSON/XML) + + + + + + + {/* Action buttons */} + + + + + [S] Manage Sync + + + + + + + + [E] Export Data + + + + + + + + [L] Logout + + + + + + + + + Tab to navigate, Enter to select + + + ) +} diff --git a/src/config/auth.ts b/src/config/auth.ts new file mode 100644 index 0000000..6b2b7ef --- /dev/null +++ b/src/config/auth.ts @@ -0,0 +1,75 @@ +/** + * Authentication configuration for PodTUI + * Authentication is DISABLED by default - users can opt-in + */ + +import { OAuthProvider, type OAuthProviderConfig } from "../types/auth" + +/** Default auth enabled state - DISABLED by default */ +export const DEFAULT_AUTH_ENABLED = false + +/** Authentication configuration */ +export const AUTH_CONFIG = { + /** Whether auth is enabled by default */ + defaultEnabled: DEFAULT_AUTH_ENABLED, + + /** Code validation settings */ + codeValidation: { + /** Code length (8 characters) */ + codeLength: 8, + /** Allowed characters (alphanumeric) */ + allowedChars: /^[A-Z0-9]+$/, + /** Code expiration time in minutes */ + expirationMinutes: 15, + }, + + /** Password requirements */ + password: { + minLength: 8, + requireUppercase: false, + requireLowercase: false, + requireNumber: false, + requireSpecial: false, + }, + + /** Email validation */ + email: { + pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, + }, + + /** Local storage keys */ + storage: { + authState: "podtui_auth_state", + user: "podtui_user", + lastLogin: "podtui_last_login", + }, +} as const + +/** OAuth provider configurations */ +export const OAUTH_PROVIDERS: OAuthProviderConfig[] = [ + { + id: OAuthProvider.GOOGLE, + name: "Google", + enabled: false, // Not feasible in terminal + description: "Sign in with Google (requires browser redirect)", + }, + { + id: OAuthProvider.APPLE, + name: "Apple", + enabled: false, // Not feasible in terminal + description: "Sign in with Apple (requires browser redirect)", + }, +] + +/** Terminal OAuth limitation message */ +export const OAUTH_LIMITATION_MESSAGE = ` +OAuth authentication (Google, Apple) is not directly available in terminal applications. + +To use OAuth: +1. Visit the web portal in your browser +2. Sign in with your preferred provider +3. Generate a sync code +4. Enter the code here to link your account + +Alternatively, use email/password authentication or file-based sync. +`.trim() diff --git a/src/stores/auth.ts b/src/stores/auth.ts new file mode 100644 index 0000000..3ee0bad --- /dev/null +++ b/src/stores/auth.ts @@ -0,0 +1,244 @@ +/** + * Authentication store for PodTUI + * Uses Zustand for state management with localStorage persistence + * Authentication is DISABLED by default + */ + +import { createSignal } from "solid-js" +import type { + User, + AuthState, + AuthError, + AuthErrorCode, + LoginCredentials, + AuthScreen, +} from "../types/auth" +import { AUTH_CONFIG, DEFAULT_AUTH_ENABLED } from "../config/auth" + +/** Initial auth state */ +const initialState: AuthState = { + user: null, + isAuthenticated: false, + isLoading: false, + error: null, +} + +/** Load auth state from localStorage */ +function loadAuthState(): AuthState { + if (typeof localStorage === "undefined") { + return initialState + } + + try { + const stored = localStorage.getItem(AUTH_CONFIG.storage.authState) + if (stored) { + const parsed = JSON.parse(stored) + // Convert date strings back to Date objects + if (parsed.user?.createdAt) { + parsed.user.createdAt = new Date(parsed.user.createdAt) + } + if (parsed.user?.lastLoginAt) { + parsed.user.lastLoginAt = new Date(parsed.user.lastLoginAt) + } + return parsed + } + } catch { + // Ignore parse errors, use initial state + } + + return initialState +} + +/** Save auth state to localStorage */ +function saveAuthState(state: AuthState): void { + if (typeof localStorage === "undefined") { + return + } + + try { + localStorage.setItem(AUTH_CONFIG.storage.authState, JSON.stringify(state)) + } catch { + // Ignore storage errors + } +} + +/** Create auth store using Solid signals */ +export function createAuthStore() { + const [state, setState] = createSignal(loadAuthState()) + const [authEnabled, setAuthEnabled] = createSignal(DEFAULT_AUTH_ENABLED) + const [currentScreen, setCurrentScreen] = createSignal("login") + + /** Update state and persist */ + const updateState = (updates: Partial) => { + setState((prev) => { + const next = { ...prev, ...updates } + saveAuthState(next) + return next + }) + } + + /** Login with email/password (placeholder - no real backend) */ + const login = async (credentials: LoginCredentials): Promise => { + updateState({ isLoading: true, error: null }) + + // Simulate network delay + await new Promise((r) => setTimeout(r, 500)) + + // Validate email format + if (!AUTH_CONFIG.email.pattern.test(credentials.email)) { + updateState({ + isLoading: false, + error: { + code: "INVALID_CREDENTIALS" as AuthErrorCode, + message: "Invalid email format", + }, + }) + return false + } + + // Validate password length + if (credentials.password.length < AUTH_CONFIG.password.minLength) { + updateState({ + isLoading: false, + error: { + code: "INVALID_CREDENTIALS" as AuthErrorCode, + message: `Password must be at least ${AUTH_CONFIG.password.minLength} characters`, + }, + }) + return false + } + + // Create mock user (in real app, this would validate against backend) + const user: User = { + id: crypto.randomUUID(), + email: credentials.email, + name: credentials.email.split("@")[0], + createdAt: new Date(), + lastLoginAt: new Date(), + syncEnabled: true, + } + + updateState({ + user, + isAuthenticated: true, + isLoading: false, + error: null, + }) + + return true + } + + /** Logout and clear state */ + const logout = () => { + updateState({ + user: null, + isAuthenticated: false, + isLoading: false, + error: null, + }) + setCurrentScreen("login") + } + + /** Validate 8-character code */ + const validateCode = async (code: string): Promise => { + updateState({ isLoading: true, error: null }) + + // Simulate network delay + await new Promise((r) => setTimeout(r, 500)) + + const normalizedCode = code.toUpperCase().replace(/[^A-Z0-9]/g, "") + + // Check code length + if (normalizedCode.length !== AUTH_CONFIG.codeValidation.codeLength) { + updateState({ + isLoading: false, + error: { + code: "INVALID_CODE" as AuthErrorCode, + message: `Code must be ${AUTH_CONFIG.codeValidation.codeLength} characters`, + }, + }) + return false + } + + // Check code format + if (!AUTH_CONFIG.codeValidation.allowedChars.test(normalizedCode)) { + updateState({ + isLoading: false, + error: { + code: "INVALID_CODE" as AuthErrorCode, + message: "Code must contain only letters and numbers", + }, + }) + return false + } + + // Mock successful code validation + const user: User = { + id: crypto.randomUUID(), + email: `sync-${normalizedCode.toLowerCase()}@podtui.local`, + name: `Sync User (${normalizedCode.slice(0, 4)})`, + createdAt: new Date(), + lastLoginAt: new Date(), + syncEnabled: true, + } + + updateState({ + user, + isAuthenticated: true, + isLoading: false, + error: null, + }) + + return true + } + + /** Clear error */ + const clearError = () => { + updateState({ error: null }) + } + + /** Enable/disable auth */ + const toggleAuthEnabled = () => { + setAuthEnabled((prev) => !prev) + } + + return { + // State accessors (signals) + state, + authEnabled, + currentScreen, + + // Actions + login, + logout, + validateCode, + clearError, + setCurrentScreen, + toggleAuthEnabled, + + // Computed + get user() { + return state().user + }, + get isAuthenticated() { + return state().isAuthenticated + }, + get isLoading() { + return state().isLoading + }, + get error() { + return state().error + }, + } +} + +/** Singleton auth store instance */ +let authStoreInstance: ReturnType | null = null + +/** Get or create auth store */ +export function useAuthStore() { + if (!authStoreInstance) { + authStoreInstance = createAuthStore() + } + return authStoreInstance +} diff --git a/src/types/auth.ts b/src/types/auth.ts new file mode 100644 index 0000000..e2a4b5f --- /dev/null +++ b/src/types/auth.ts @@ -0,0 +1,65 @@ +/** + * Authentication types for PodTUI + * Authentication is optional and disabled by default + */ + +/** User profile information */ +export interface User { + id: string + email: string + name: string + createdAt: Date + lastLoginAt?: Date + syncEnabled: boolean +} + +/** Authentication state */ +export interface AuthState { + user: User | null + isAuthenticated: boolean + isLoading: boolean + error: AuthError | null +} + +/** Authentication error */ +export interface AuthError { + code: AuthErrorCode + message: string +} + +/** Error codes for authentication */ +export enum AuthErrorCode { + INVALID_CREDENTIALS = "INVALID_CREDENTIALS", + INVALID_CODE = "INVALID_CODE", + CODE_EXPIRED = "CODE_EXPIRED", + NETWORK_ERROR = "NETWORK_ERROR", + UNKNOWN_ERROR = "UNKNOWN_ERROR", +} + +/** Login credentials */ +export interface LoginCredentials { + email: string + password: string +} + +/** Code validation request */ +export interface CodeValidationRequest { + code: string +} + +/** OAuth provider types */ +export enum OAuthProvider { + GOOGLE = "google", + APPLE = "apple", +} + +/** OAuth provider configuration */ +export interface OAuthProviderConfig { + id: OAuthProvider + name: string + enabled: boolean + description: string +} + +/** Auth screen types for navigation */ +export type AuthScreen = "login" | "code" | "oauth" | "profile" diff --git a/src/types/episode.ts b/src/types/episode.ts new file mode 100644 index 0000000..fb08126 --- /dev/null +++ b/src/types/episode.ts @@ -0,0 +1,86 @@ +/** + * Episode type definitions for PodTUI + */ + +/** Episode playback status */ +export enum EpisodeStatus { + NOT_STARTED = "not_started", + PLAYING = "playing", + PAUSED = "paused", + COMPLETED = "completed", +} + +/** Core episode information */ +export interface Episode { + /** Unique identifier */ + id: string + /** Parent podcast ID */ + podcastId: string + /** Episode title */ + title: string + /** Episode description/show notes */ + description: string + /** Audio file URL */ + audioUrl: string + /** Duration in seconds */ + duration: number + /** Publication date */ + pubDate: Date + /** Episode number (if available) */ + episodeNumber?: number + /** Season number (if available) */ + seasonNumber?: number + /** Episode type (full, trailer, bonus) */ + episodeType?: EpisodeType + /** Whether episode is explicit */ + explicit?: boolean + /** Episode image URL (if different from podcast) */ + imageUrl?: string + /** File size in bytes */ + fileSize?: number + /** MIME type */ + mimeType?: string +} + +/** Episode type enumeration */ +export enum EpisodeType { + FULL = "full", + TRAILER = "trailer", + BONUS = "bonus", +} + +/** Episode playback progress */ +export interface Progress { + /** Episode ID */ + episodeId: string + /** Current position in seconds */ + position: number + /** Total duration in seconds */ + duration: number + /** Last played timestamp */ + timestamp: Date + /** Playback speed (1.0 = normal) */ + playbackSpeed?: number +} + +/** Episode with playback state */ +export interface EpisodeWithProgress extends Episode { + /** Current playback status */ + status: EpisodeStatus + /** Playback progress */ + progress?: Progress +} + +/** Episode list item for display */ +export interface EpisodeListItem { + /** Episode data */ + episode: Episode + /** Podcast title (for display in feeds) */ + podcastTitle: string + /** Podcast cover URL */ + podcastCoverUrl?: string + /** Current status */ + status: EpisodeStatus + /** Progress percentage (0-100) */ + progressPercent: number +} diff --git a/src/types/feed.ts b/src/types/feed.ts new file mode 100644 index 0000000..b1fcc2b --- /dev/null +++ b/src/types/feed.ts @@ -0,0 +1,116 @@ +/** + * Feed type definitions for PodTUI + */ + +import type { Podcast } from "./podcast" +import type { Episode, EpisodeStatus } from "./episode" + +/** Feed visibility */ +export enum FeedVisibility { + PUBLIC = "public", + PRIVATE = "private", +} + +/** Feed information */ +export interface Feed { + /** Unique identifier */ + id: string + /** Associated podcast */ + podcast: Podcast + /** Episodes in this feed */ + episodes: Episode[] + /** Whether feed is public or private */ + visibility: FeedVisibility + /** Source ID that provided this feed */ + sourceId: string + /** Last updated timestamp */ + lastUpdated: Date + /** Custom feed name (user-defined) */ + customName?: string + /** User notes about this feed */ + notes?: string + /** Whether feed is pinned/favorited */ + isPinned: boolean + /** Feed color for UI */ + color?: string +} + +/** Feed item for display in lists */ +export interface FeedItem { + /** Episode data */ + episode: Episode + /** Parent podcast */ + podcast: Podcast + /** Feed ID */ + feedId: string + /** Episode status */ + status: EpisodeStatus + /** Progress percentage (0-100) */ + progressPercent: number + /** Whether this item is new (unplayed) */ + isNew: boolean +} + +/** Feed filter options */ +export interface FeedFilter { + /** Filter by visibility */ + visibility?: FeedVisibility | "all" + /** Filter by source ID */ + sourceId?: string + /** Filter by pinned status */ + pinnedOnly?: boolean + /** Search query for filtering */ + searchQuery?: string + /** Sort field */ + sortBy?: FeedSortField + /** Sort direction */ + sortDirection?: "asc" | "desc" +} + +/** Feed sort fields */ +export enum FeedSortField { + /** Sort by last updated */ + UPDATED = "updated", + /** Sort by title */ + TITLE = "title", + /** Sort by episode count */ + EPISODE_COUNT = "episodeCount", + /** Sort by most recent episode */ + LATEST_EPISODE = "latestEpisode", +} + +/** Feed list display options */ +export interface FeedListOptions { + /** Show episode count */ + showEpisodeCount: boolean + /** Show last updated */ + showLastUpdated: boolean + /** Show source indicator */ + showSource: boolean + /** Compact mode */ + compact: boolean +} + +/** Default feed list options */ +export const DEFAULT_FEED_LIST_OPTIONS: FeedListOptions = { + showEpisodeCount: true, + showLastUpdated: true, + showSource: false, + compact: false, +} + +/** Feed statistics */ +export interface FeedStats { + /** Total feed count */ + totalFeeds: number + /** Public feed count */ + publicFeeds: number + /** Private feed count */ + privateFeeds: number + /** Total episode count across all feeds */ + totalEpisodes: number + /** Unplayed episode count */ + unplayedEpisodes: number + /** In-progress episode count */ + inProgressEpisodes: number +} diff --git a/src/types/podcast.ts b/src/types/podcast.ts new file mode 100644 index 0000000..0cd6f73 --- /dev/null +++ b/src/types/podcast.ts @@ -0,0 +1,40 @@ +/** + * Podcast type definitions for PodTUI + */ + +/** Core podcast information */ +export interface Podcast { + /** Unique identifier */ + id: string + /** Podcast title */ + title: string + /** Podcast description/summary */ + description: string + /** Cover image URL */ + coverUrl?: string + /** RSS feed URL */ + feedUrl: string + /** Author/creator name */ + author?: string + /** Podcast categories */ + categories?: string[] + /** Language code (e.g., 'en', 'es') */ + language?: string + /** Website URL */ + websiteUrl?: string + /** Last updated timestamp */ + lastUpdated: Date + /** Whether the podcast is currently subscribed */ + isSubscribed: boolean +} + +/** Podcast with episodes included */ +export interface PodcastWithEpisodes extends Podcast { + /** List of episodes */ + episodes: Episode[] + /** Total episode count */ + totalEpisodes: number +} + +/** Episode import - needed for PodcastWithEpisodes */ +import type { Episode } from "./episode" diff --git a/src/types/source.ts b/src/types/source.ts new file mode 100644 index 0000000..040b9f2 --- /dev/null +++ b/src/types/source.ts @@ -0,0 +1,103 @@ +/** + * Podcast source type definitions for PodTUI + */ + +/** Source type enumeration */ +export enum SourceType { + /** RSS feed URL */ + RSS = "rss", + /** API-based source (iTunes, Spotify, etc.) */ + API = "api", + /** Custom/user-defined source */ + CUSTOM = "custom", +} + +/** Podcast source configuration */ +export interface PodcastSource { + /** Unique identifier */ + id: string + /** Source display name */ + name: string + /** Source type */ + type: SourceType + /** Base URL for the source */ + baseUrl: string + /** API key (if required) */ + apiKey?: string + /** Whether source is enabled */ + enabled: boolean + /** Source icon/logo URL */ + iconUrl?: string + /** Source description */ + description?: string + /** Rate limit (requests per minute) */ + rateLimit?: number + /** Last successful fetch */ + lastFetch?: Date +} + +/** Search query configuration */ +export interface SearchQuery { + /** Search query text */ + query: string + /** Source IDs to search (empty = all enabled sources) */ + sourceIds: string[] + /** Optional filters */ + filters?: SearchFilters +} + +/** Search filters */ +export interface SearchFilters { + /** Filter by language */ + language?: string + /** Filter by category */ + category?: string + /** Filter by explicit content */ + explicit?: boolean + /** Sort by field */ + sortBy?: SearchSortField + /** Sort direction */ + sortDirection?: "asc" | "desc" + /** Results limit */ + limit?: number + /** Results offset for pagination */ + offset?: number +} + +/** Search sort fields */ +export enum SearchSortField { + RELEVANCE = "relevance", + DATE = "date", + TITLE = "title", + POPULARITY = "popularity", +} + +/** Search result */ +export interface SearchResult { + /** Source that returned this result */ + sourceId: string + /** Podcast data */ + podcast: import("./podcast").Podcast + /** Relevance score (0-1) */ + score?: number +} + +/** Default podcast sources */ +export const DEFAULT_SOURCES: PodcastSource[] = [ + { + id: "itunes", + name: "Apple Podcasts", + type: SourceType.API, + baseUrl: "https://itunes.apple.com/search", + enabled: true, + description: "Search the Apple Podcasts directory", + }, + { + id: "rss", + name: "RSS Feed", + type: SourceType.RSS, + baseUrl: "", + enabled: true, + description: "Add podcasts via RSS feed URL", + }, +] diff --git a/tasks/podcast-tui-app/README.md b/tasks/podcast-tui-app/README.md index 30d8cd2..b8db0c0 100644 --- a/tasks/podcast-tui-app/README.md +++ b/tasks/podcast-tui-app/README.md @@ -9,10 +9,10 @@ Status legend: [ ] todo, [~] in-progress, [x] done ## Phase 1: Project Foundation 🏗️ **Setup and configure the development environment** -- [ ] 01 — Initialize SolidJS OpenTUI project with Bun → `01-project-setup.md` -- [ ] 13 — Set up TypeScript configuration and build system → `13-typescript-config.md` -- [ ] 14 — Create project directory structure and dependencies → `14-project-structure.md` -- [ ] 15 — Build responsive layout system (Flexbox) → `15-responsive-layout.md` +- [x] 01 — Initialize SolidJS OpenTUI project with Bun → `01-project-setup.md` +- [x] 13 — Set up TypeScript configuration and build system → `13-typescript-config.md` +- [x] 14 — Create project directory structure and dependencies → `14-project-structure.md` +- [x] 15 — Build responsive layout system (Flexbox) → `15-responsive-layout.md` **Dependencies:** 01 -> 02 -> 03 -> 04 -> 05 -> 06 -> 07 -> 08 -> 09 -> 10 -> 11 -> 12 @@ -21,9 +21,9 @@ Status legend: [ ] todo, [~] in-progress, [x] done ## Phase 2: Core Architecture 🏗️ **Build the main application shell and navigation** -- [ ] 02 — Create main app shell with tab navigation → `02-core-layout.md` -- [ ] 16 — Implement tab navigation component → `16-tab-navigation.md` -- [ ] 17 — Add keyboard shortcuts and navigation handling → `17-keyboard-handling.md` +- [x] 02 — Create main app shell with tab navigation → `02-core-layout.md` +- [x] 16 — Implement tab navigation component → `16-tab-navigation.md` +- [x] 17 — Add keyboard shortcuts and navigation handling → `17-keyboard-handling.md` **Dependencies:** 01 -> 02 -> 03 -> 04 -> 05 -> 06 -> 07 -> 08 -> 09 -> 10 -> 11 -> 12 @@ -32,12 +32,12 @@ Status legend: [ ] todo, [~] in-progress, [x] done ## Phase 3: File Sync & Data Import/Export 💾 **Implement direct file sync with JSON/XML formats** -- [ ] 03 — Implement direct file sync (JSON/XML import/export) → `03-file-sync.md` -- [ ] 18 — Create sync data models (JSON/XML formats) → `18-sync-data-models.md` -- [ ] 19 — Build import/export functionality → `19-import-export.md` -- [ ] 20 — Create file picker UI for import → `20-file-picker.md` -- [ ] 21 — Build sync status indicator → `21-sync-status.md` -- [ ] 22 — Add backup/restore functionality → `22-backup-restore.md` +- [x] 03 — Implement direct file sync (JSON/XML import/export) → `03-file-sync.md` +- [x] 18 — Create sync data models (JSON/XML formats) → `18-sync-data-models.md` +- [x] 19 — Build import/export functionality → `19-import-export.md` +- [x] 20 — Create file picker UI for import → `20-file-picker.md` +- [x] 21 — Build sync status indicator → `21-sync-status.md` +- [x] 22 — Add backup/restore functionality → `22-backup-restore.md` **Dependencies:** 02 -> 03 -> 04 -> 05 -> 06 -> 07 -> 08 -> 09 -> 10 -> 11 -> 12 @@ -46,12 +46,12 @@ Status legend: [ ] todo, [~] in-progress, [x] done ## Phase 4: Authentication System 🔐 **Implement authentication (MUST be implemented as optional for users)** -- [ ] 04 — Build optional authentication system → `04-authentication.md` -- [ ] 23 — Create authentication state (disabled by default) → `23-auth-state.md` -- [ ] 24 — Build simple login screen (email/password) → `24-login-screen.md` -- [ ] 25 — Implement 8-character code validation flow → `25-code-validation.md` -- [ ] 26 — Add OAuth placeholder screens (document limitations) → `26-oauth-placeholders.md` -- [ ] 27 — Create sync-only user profile → `27-sync-profile.md` +- [x] 04 — Build optional authentication system → `04-authentication.md` +- [x] 23 — Create authentication state (disabled by default) → `23-auth-state.md` +- [x] 24 — Build simple login screen (email/password) → `24-login-screen.md` +- [x] 25 — Implement 8-character code validation flow → `25-code-validation.md` +- [x] 26 — Add OAuth placeholder screens (document limitations) → `26-oauth-placeholders.md` +- [x] 27 — Create sync-only user profile → `27-sync-profile.md` **Dependencies:** 03 -> 04 -> 05 -> 06 -> 07 -> 08 -> 09 -> 10 -> 11 -> 12 @@ -60,9 +60,9 @@ Status legend: [ ] todo, [~] in-progress, [x] done ## Phase 5: Feed Management 📻 **Create feed data models and management UI** -- [ ] 05 — Create feed data models and types → `05-feed-management.md` -- [ ] 28 — Create feed data models and types → `28-feed-types.md` -- [ ] 29 — Build feed list component (public/private feeds) → `29-feed-list.md` +- [x] 05 — Create feed data models and types → `05-feed-management.md` +- [x] 28 — Create feed data models and types → `28-feed-types.md` +- [x] 29 — Build feed list component (public/private feeds) → `29-feed-list.md` - [ ] 30 — Implement feed source management (add/remove sources) → `30-source-management.md` - [ ] 31 — Add reverse chronological ordering → `31-reverse-chronological.md` - [ ] 32 — Create feed detail view → `32-feed-detail.md` @@ -170,7 +170,7 @@ Status legend: [ ] todo, [~] in-progress, [x] done ## Phase 13: OAuth & External Integration 🔗 **Complete OAuth implementation and external integrations** -- [ ] 26 — Add OAuth placeholder screens (document limitations) → `26-oauth-placeholders.md` +- [x] 26 — Add OAuth placeholder screens (document limitations) → `26-oauth-placeholders.md` - [ ] 67 — Implement browser redirect flow for OAuth → `67-browser-redirect.md` - [ ] 68 — Build QR code display for mobile verification → `68-qr-code-display.md`