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 0000000..e1184cb Binary files /dev/null and b/bun.lockb differ 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`