diff --git a/src/App.tsx b/src/App.tsx index 1f93678..d616937 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,5 @@ import { ErrorBoundary } from "solid-js"; import { useSelectionHandler, useRenderer } from "@opentui/solid"; -import { useAuthStore } from "@/stores/auth"; import { useAudio } from "@/hooks/useAudio"; import { useMultimediaKeys } from "@/hooks/useMultimediaKeys"; import { Clipboard } from "@/utils/clipboard"; @@ -19,7 +18,6 @@ const DEBUG = import.meta.env.DEBUG; export function App() { const nav = useNavigation(); - const auth = useAuthStore(); const audio = useAudio(); const toast = useToast(); const renderer = useRenderer(); diff --git a/src/components/CodeValidation.tsx b/src/components/CodeValidation.tsx deleted file mode 100644 index a7481ec..0000000 --- a/src/components/CodeValidation.tsx +++ /dev/null @@ -1,180 +0,0 @@ -/** - * 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"; -import { useTheme } from "@/context/ThemeContext"; - -interface CodeValidationProps { - focused?: boolean; - onBack?: () => void; -} - -type FocusField = "code" | "submit" | "back"; - -export function CodeValidation(props: CodeValidationProps) { - const auth = useAuthStore(); - const { theme } = useTheme(); - 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 === "tab") { - 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/config/auth.ts b/src/config/auth.ts deleted file mode 100644 index 6b2b7ef..0000000 --- a/src/config/auth.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * 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/pages/Settings/LoginScreen.tsx b/src/pages/Settings/LoginScreen.tsx deleted file mode 100644 index 1641eac..0000000 --- a/src/pages/Settings/LoginScreen.tsx +++ /dev/null @@ -1,178 +0,0 @@ -/** - * 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 { useTheme } from "@/context/ThemeContext"; -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 { theme } = useTheme(); - 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") { - 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/pages/Settings/OAuthPlaceholder.tsx b/src/pages/Settings/OAuthPlaceholder.tsx deleted file mode 100644 index 840e981..0000000 --- a/src/pages/Settings/OAuthPlaceholder.tsx +++ /dev/null @@ -1,123 +0,0 @@ -/** - * 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"; -import { useTheme } from "@/context/ThemeContext"; - -interface OAuthPlaceholderProps { - focused?: boolean; - onBack?: () => void; - onNavigateToCode?: () => void; -} - -type FocusField = "code" | "back"; - -export function OAuthPlaceholder(props: OAuthPlaceholderProps) { - const { theme } = useTheme(); - 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") { - 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/pages/Settings/SettingsPage.tsx b/src/pages/Settings/SettingsPage.tsx index 6c11872..3ccce53 100644 --- a/src/pages/Settings/SettingsPage.tsx +++ b/src/pages/Settings/SettingsPage.tsx @@ -63,18 +63,12 @@ const SECTIONS: SettingsSectionDef[] = [ }, { id: 4, - label: "Account", - description: "Account login & OAuth (not yet implemented).", - }, - { - id: 5, label: "Downloads", description: "Manage downloaded episodes — delete by show or individually.", }, ]; -/** Resolve the items for a section id at render time. Section 4 (Account) has - * no items yet. */ +/** Resolve the items for a section id at render time. */ function sectionItems(sectionId: number): SettingItem[] { switch (sectionId) { case 0: @@ -85,7 +79,7 @@ function sectionItems(sectionId: number): SettingItem[] { return usePreferencesItems(); case 3: return useVisualizerItems(); - case 5: + case 4: return useDownloadItems(); default: return []; diff --git a/src/pages/Settings/SyncProfile.tsx b/src/pages/Settings/SyncProfile.tsx deleted file mode 100644 index 031ddd9..0000000 --- a/src/pages/Settings/SyncProfile.tsx +++ /dev/null @@ -1,157 +0,0 @@ -/** - * 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"; -import { useTheme } from "@/context/ThemeContext"; - -interface SyncProfileProps { - focused?: boolean; - onLogout?: () => void; - onManageSync?: () => void; -} - -type FocusField = "sync" | "export" | "logout"; - -export function SyncProfile(props: SyncProfileProps) { - const auth = useAuthStore(); - const { theme } = useTheme(); - 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") { - 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/stores/app.ts b/src/stores/app.ts index 8c65430..b42ae2f 100644 --- a/src/stores/app.ts +++ b/src/stores/app.ts @@ -55,7 +55,7 @@ export function createAppStore() { init(); const saveState = (next: AppState) => { - saveAppStateToFile(next).catch(() => {}); + saveAppStateToFile(next); }; const updateState = (next: AppState) => { diff --git a/src/stores/audio-nav.ts b/src/stores/audio-nav.ts index 2b8ffe0..66c8c5a 100644 --- a/src/stores/audio-nav.ts +++ b/src/stores/audio-nav.ts @@ -5,122 +5,122 @@ import { createSignal } from "solid-js"; import { - loadAudioNavFromFile, - saveAudioNavToFile, + loadAudioNavFromFile, + saveAudioNavToFile, } from "../utils/app-persistence"; /** Source type for audio navigation */ export enum AudioSource { - FEED = "feed", - MY_SHOWS = "my_shows", - SEARCH = "search", + FEED = "feed", + MY_SHOWS = "my_shows", + SEARCH = "search", } /** Audio navigation state */ export interface AudioNavState { - /** Current source type */ - source: AudioSource; - /** Index of current episode in the ordered list */ - currentIndex: number; - /** Podcast ID for My Shows source */ - podcastId?: string; - /** Timestamp when navigation state was last saved */ - lastUpdated: Date; + /** Current source type */ + source: AudioSource; + /** Index of current episode in the ordered list */ + currentIndex: number; + /** Podcast ID for My Shows source */ + podcastId?: string; + /** Timestamp when navigation state was last saved */ + lastUpdated: Date; } /** Default navigation state */ const defaultNavState: AudioNavState = { - source: AudioSource.FEED, - currentIndex: 0, - lastUpdated: new Date(), + source: AudioSource.FEED, + currentIndex: 0, + lastUpdated: new Date(), }; /** Create audio navigation store */ export function createAudioNavStore() { - const [navState, setNavState] = createSignal(defaultNavState); + const [navState, setNavState] = createSignal(defaultNavState); - /** Persist current navigation state to file (fire-and-forget) */ - function persist(): void { - saveAudioNavToFile(navState()).catch(() => {}); - } + /** Persist current navigation state to file (fire-and-forget) */ + function persist(): void { + saveAudioNavToFile(navState()); + } - /** Load navigation state from file */ - async function init(): Promise { - const loaded = await loadAudioNavFromFile(); - if (loaded) { - setNavState(loaded); - } - } + /** Load navigation state from file */ + async function init(): Promise { + const loaded = await loadAudioNavFromFile(); + if (loaded) { + setNavState(loaded); + } + } - /** Fire-and-forget initialization */ - init(); + /** Fire-and-forget initialization */ + init(); - return { - /** Get current navigation state */ - get state(): AudioNavState { - return navState(); - }, + return { + /** Get current navigation state */ + get state(): AudioNavState { + return navState(); + }, - /** Update source type */ - setSource: (source: AudioSource, podcastId?: string) => { - setNavState((prev) => ({ - ...prev, - source, - podcastId, - lastUpdated: new Date(), - })); - persist(); - }, + /** Update source type */ + setSource: (source: AudioSource, podcastId?: string) => { + setNavState((prev) => ({ + ...prev, + source, + podcastId, + lastUpdated: new Date(), + })); + persist(); + }, - /** Move to next episode */ - next: (currentIndex: number) => { - setNavState((prev) => ({ - ...prev, - currentIndex, - lastUpdated: new Date(), - })); - persist(); - }, + /** Move to next episode */ + next: (currentIndex: number) => { + setNavState((prev) => ({ + ...prev, + currentIndex, + lastUpdated: new Date(), + })); + persist(); + }, - /** Move to previous episode */ - prev: (currentIndex: number) => { - setNavState((prev) => ({ - ...prev, - currentIndex, - lastUpdated: new Date(), - })); - persist(); - }, + /** Move to previous episode */ + prev: (currentIndex: number) => { + setNavState((prev) => ({ + ...prev, + currentIndex, + lastUpdated: new Date(), + })); + persist(); + }, - /** Reset to default state */ - reset: () => { - setNavState(defaultNavState); - persist(); - }, + /** Reset to default state */ + reset: () => { + setNavState(defaultNavState); + persist(); + }, - /** Get current index */ - getCurrentIndex: (): number => { - return navState().currentIndex; - }, + /** Get current index */ + getCurrentIndex: (): number => { + return navState().currentIndex; + }, - /** Get current source */ - getSource: (): AudioSource => { - return navState().source; - }, + /** Get current source */ + getSource: (): AudioSource => { + return navState().source; + }, - /** Get current podcast ID */ - getPodcastId: (): string | undefined => { - return navState().podcastId; - }, - }; + /** Get current podcast ID */ + getPodcastId: (): string | undefined => { + return navState().podcastId; + }, + }; } /** Singleton instance */ let audioNavInstance: ReturnType | null = null; export function useAudioNavStore() { - if (!audioNavInstance) { - audioNavInstance = createAudioNavStore(); - } - return audioNavInstance; + if (!audioNavInstance) { + audioNavInstance = createAudioNavStore(); + } + return audioNavInstance; } diff --git a/src/stores/auth.ts b/src/stores/auth.ts deleted file mode 100644 index 3ee0bad..0000000 --- a/src/stores/auth.ts +++ /dev/null @@ -1,244 +0,0 @@ -/** - * 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/stores/download.ts b/src/stores/download.ts index e7d5313..79a41ed 100644 --- a/src/stores/download.ts +++ b/src/stores/download.ts @@ -12,7 +12,6 @@ import type { DownloadedEpisode } from "../types/episode"; import type { Episode } from "../types/episode"; import { downloadEpisode } from "../utils/episode-downloader"; import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir"; -import { backupConfigFile } from "../utils/config-backup"; const DOWNLOADS_FILE = "downloads.json"; const MAX_CONCURRENT = 2; @@ -94,7 +93,6 @@ export function createDownloadStore() { async function saveDownloads(): Promise { try { await ensureConfigDir(); - await backupConfigFile(DOWNLOADS_FILE); const map = downloads(); const records: DownloadRecord[] = []; for (const [, dl] of map) { diff --git a/src/stores/feed.ts b/src/stores/feed.ts index 82d19fd..50ee23c 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -19,7 +19,6 @@ import { } from "../utils/feeds-persistence"; import { useDownloadStore } from "./download"; import { DownloadStatus } from "../types/episode"; -import { useAuthStore } from "./auth"; /** Max episodes to load per page/chunk */ const MAX_EPISODES_REFRESH = 50; @@ -35,12 +34,12 @@ const episodeLoadCount = new Map(); /** Save feeds to file (async, fire-and-forget) */ function saveFeeds(feeds: Feed[]): void { - saveFeedsToFile(feeds).catch(() => {}); + saveFeedsToFile(feeds); } /** Save sources to file (async, fire-and-forget) */ function saveSources(sources: PodcastSource[]): void { - saveSourcesToFile(sources).catch(() => {}); + saveSourcesToFile(sources); } /** Create feed store */ @@ -62,18 +61,10 @@ export function createFeedStore() { const getFilteredFeeds = (): Feed[] => { let result = [...feeds()]; const f = filter(); - const authStore = useAuthStore(); // Filter by visibility if (f.visibility && f.visibility !== "all") { result = result.filter((feed) => feed.visibility === f.visibility); - } else if (f.visibility === "all") { - // Only show private feeds if authenticated - result = result.filter( - (feed) => - feed.visibility === FeedVisibility.PUBLIC || - authStore.isAuthenticated, - ); } // Filter by source diff --git a/src/stores/progress.ts b/src/stores/progress.ts index 1aa4417..7d80f31 100644 --- a/src/stores/progress.ts +++ b/src/stores/progress.ts @@ -8,8 +8,8 @@ import { createSignal } from "solid-js"; import type { Progress } from "../types/episode"; import { - loadProgressFromFile, - saveProgressToFile, + loadProgressFromFile, + saveProgressToFile, } from "../utils/app-persistence"; /** Threshold (fraction 0-1) at which an episode is considered completed */ @@ -21,146 +21,146 @@ const MIN_POSITION_TO_SAVE = 5; // --- Singleton store --- const [progressMap, setProgressMap] = createSignal>( - {}, + {}, ); /** Persist current progress map to file (fire-and-forget) */ function persist(): void { - saveProgressToFile(progressMap()).catch(() => {}); + saveProgressToFile(progressMap()); } /** Parse raw progress entries from file, reviving Date objects */ function parseProgressEntries( - raw: Record, + raw: Record, ): Record { - const result: Record = {}; - for (const [key, value] of Object.entries(raw)) { - const p = value as Record; - result[key] = { - episodeId: p.episodeId as string, - position: p.position as number, - duration: p.duration as number, - timestamp: new Date(p.timestamp as string), - playbackSpeed: p.playbackSpeed as number | undefined, - }; - } - return result; + const result: Record = {}; + for (const [key, value] of Object.entries(raw)) { + const p = value as Record; + result[key] = { + episodeId: p.episodeId as string, + position: p.position as number, + duration: p.duration as number, + timestamp: new Date(p.timestamp as string), + playbackSpeed: p.playbackSpeed as number | undefined, + }; + } + return result; } async function initProgress(): Promise { - const raw = await loadProgressFromFile(); - const parsed = parseProgressEntries(raw as Record); - setProgressMap(parsed); + const raw = await loadProgressFromFile(); + const parsed = parseProgressEntries(raw as Record); + setProgressMap(parsed); } // Fire-and-forget init initProgress(); function createProgressStore() { - return { - /** - * Get progress for a specific episode. - */ - get(episodeId: string): Progress | undefined { - return progressMap()[episodeId]; - }, + return { + /** + * Get progress for a specific episode. + */ + get(episodeId: string): Progress | undefined { + return progressMap()[episodeId]; + }, - /** - * Get all progress entries. - */ - all(): Record { - return progressMap(); - }, + /** + * Get all progress entries. + */ + all(): Record { + return progressMap(); + }, - /** - * Update progress for an episode. Only persists if position is meaningful. - */ - update( - episodeId: string, - position: number, - duration: number, - playbackSpeed?: number, - ): void { - if (position < MIN_POSITION_TO_SAVE && duration > 0) return; + /** + * Update progress for an episode. Only persists if position is meaningful. + */ + update( + episodeId: string, + position: number, + duration: number, + playbackSpeed?: number, + ): void { + if (position < MIN_POSITION_TO_SAVE && duration > 0) return; - setProgressMap((prev) => ({ - ...prev, - [episodeId]: { - episodeId, - position, - duration, - timestamp: new Date(), - playbackSpeed, - }, - })); - persist(); - }, + setProgressMap((prev) => ({ + ...prev, + [episodeId]: { + episodeId, + position, + duration, + timestamp: new Date(), + playbackSpeed, + }, + })); + persist(); + }, - /** - * Check if an episode is completed. - */ - isCompleted(episodeId: string): boolean { - const p = progressMap()[episodeId]; - if (!p || p.duration <= 0) return false; - return p.position / p.duration >= COMPLETION_THRESHOLD; - }, + /** + * Check if an episode is completed. + */ + isCompleted(episodeId: string): boolean { + const p = progressMap()[episodeId]; + if (!p || p.duration <= 0) return false; + return p.position / p.duration >= COMPLETION_THRESHOLD; + }, - /** - * Get progress percentage (0-100) for an episode. - */ - getPercent(episodeId: string): number { - const p = progressMap()[episodeId]; - if (!p || p.duration <= 0) return 0; - return Math.min(100, Math.round((p.position / p.duration) * 100)); - }, + /** + * Get progress percentage (0-100) for an episode. + */ + getPercent(episodeId: string): number { + const p = progressMap()[episodeId]; + if (!p || p.duration <= 0) return 0; + return Math.min(100, Math.round((p.position / p.duration) * 100)); + }, - /** - * Mark an episode as completed (set position to duration). - */ - markCompleted(episodeId: string): void { - const p = progressMap()[episodeId]; - const duration = p?.duration ?? 0; - setProgressMap((prev) => ({ - ...prev, - [episodeId]: { - episodeId, - position: duration, - duration, - timestamp: new Date(), - playbackSpeed: p?.playbackSpeed, - }, - })); - persist(); - }, + /** + * Mark an episode as completed (set position to duration). + */ + markCompleted(episodeId: string): void { + const p = progressMap()[episodeId]; + const duration = p?.duration ?? 0; + setProgressMap((prev) => ({ + ...prev, + [episodeId]: { + episodeId, + position: duration, + duration, + timestamp: new Date(), + playbackSpeed: p?.playbackSpeed, + }, + })); + persist(); + }, - /** - * Remove progress for an episode (e.g. "mark as new"). - */ - remove(episodeId: string): void { - setProgressMap((prev) => { - const next = { ...prev }; - delete next[episodeId]; - return next; - }); - persist(); - }, + /** + * Remove progress for an episode (e.g. "mark as new"). + */ + remove(episodeId: string): void { + setProgressMap((prev) => { + const next = { ...prev }; + delete next[episodeId]; + return next; + }); + persist(); + }, - /** - * Clear all progress data. - */ - clear(): void { - setProgressMap({}); - persist(); - }, - }; + /** + * Clear all progress data. + */ + clear(): void { + setProgressMap({}); + persist(); + }, + }; } // Singleton instance let instance: ReturnType | null = null; export function useProgressStore() { - if (!instance) { - instance = createProgressStore(); - } - return instance; + if (!instance) { + instance = createProgressStore(); + } + return instance; } diff --git a/src/types/auth.ts b/src/types/auth.ts deleted file mode 100644 index e2a4b5f..0000000 --- a/src/types/auth.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * 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/utils/app-persistence.ts b/src/utils/app-persistence.ts index 974c1c7..f05568e 100644 --- a/src/utils/app-persistence.ts +++ b/src/utils/app-persistence.ts @@ -1,158 +1,151 @@ /** - * App state persistence via JSON file in XDG_CONFIG_HOME + * App state persistence — settings, preferences, and custom theme are stored + * in the centralized `config.json` (see utils/config.ts). Playback progress + * and audio-nav state stay in separate files (they change on every seek and + * would thrash config.json). * - * Reads and writes app settings, preferences, and custom theme to a JSON file + * No backups — writes always overwrite. */ import { ensureConfigDir, getConfigFilePath } from "./config-dir"; -import { backupConfigFile } from "./config-backup"; +import { loadConfig, updateConfig } from "./config"; import type { - AppState, - AppSettings, - UserPreferences, - VisualizerSettings, + AppState, + AppSettings, + UserPreferences, + VisualizerSettings, } from "../types/settings"; import { DEFAULT_THEME } from "../constants/themes"; -const APP_STATE_FILE = "app-state.json"; -const PROGRESS_FILE = "progress.json"; -const AUDIO_NAV_FILE = "audio-nav.json"; - // --- Defaults --- const defaultVisualizerSettings: VisualizerSettings = { - bars: 32, - sensitivity: 1, - noiseReduction: 0.77, - lowCutOff: 50, - highCutOff: 10000, + bars: 32, + sensitivity: 1, + noiseReduction: 0.77, + lowCutOff: 50, + highCutOff: 10000, }; const defaultSettings: AppSettings = { - theme: "system", - fontSize: 14, - playbackSpeed: 1, - downloadPath: "", - visualizer: defaultVisualizerSettings, + theme: "system", + fontSize: 14, + playbackSpeed: 1, + downloadPath: "", + visualizer: defaultVisualizerSettings, }; const defaultPreferences: UserPreferences = { - showExplicit: false, - autoDownload: false, + showExplicit: false, + autoDownload: false, }; const defaultState: AppState = { - settings: defaultSettings, - preferences: defaultPreferences, - customTheme: DEFAULT_THEME, + settings: defaultSettings, + preferences: defaultPreferences, + customTheme: DEFAULT_THEME, }; -// --- App State --- +// ── App State (config.json) ───────────────────────────────────────────────── -/** Load app state from JSON file */ +/** Load app state from config.json */ export async function loadAppStateFromFile(): Promise { - try { - const filePath = getConfigFilePath(APP_STATE_FILE); - const file = Bun.file(filePath); - if (!(await file.exists())) return defaultState; - - const raw = await file.json(); - if (!raw || typeof raw !== "object") return defaultState; - - const parsed = raw as Partial; - return { - settings: { ...defaultSettings, ...parsed.settings }, - preferences: { ...defaultPreferences, ...parsed.preferences }, - customTheme: { ...DEFAULT_THEME, ...parsed.customTheme }, - }; - } catch { - return defaultState; - } + try { + const cfg = await loadConfig(); + if (!cfg || typeof cfg !== "object") return defaultState; + return { + settings: { ...defaultSettings, ...cfg.settings }, + preferences: { ...defaultPreferences, ...cfg.preferences }, + customTheme: { ...DEFAULT_THEME, ...cfg.customTheme }, + }; + } catch { + return defaultState; + } } -/** Save app state to JSON file */ -export async function saveAppStateToFile(state: AppState): Promise { - try { - await ensureConfigDir(); - await backupConfigFile(APP_STATE_FILE); - const filePath = getConfigFilePath(APP_STATE_FILE); - await Bun.write(filePath, JSON.stringify(state, null, 2)); - } catch { - // Silently ignore write errors - } +/** Save app state to config.json */ +export function saveAppStateToFile(state: AppState): void { + updateConfig({ + settings: state.settings, + preferences: state.preferences, + customTheme: state.customTheme, + }); } +// ── Playback Progress (separate file — changes on every seek) ─────────────── + +const PROGRESS_FILE = "progress.json"; + interface ProgressEntry { - episodeId: string; - position: number; - duration: number; - timestamp: string | Date; - playbackSpeed?: number; + episodeId: string; + position: number; + duration: number; + timestamp: string | Date; + playbackSpeed?: number; } /** Load progress map from JSON file */ export async function loadProgressFromFile(): Promise< - Record + Record > { - try { - const filePath = getConfigFilePath(PROGRESS_FILE); - const file = Bun.file(filePath); - if (!(await file.exists())) return {}; + try { + const filePath = getConfigFilePath(PROGRESS_FILE); + const file = Bun.file(filePath); + if (!(await file.exists())) return {}; - const raw = await file.json(); - if (!raw || typeof raw !== "object") return {}; - return raw as Record; - } catch { - return {}; - } + const raw = await file.json(); + if (!raw || typeof raw !== "object") return {}; + return raw as Record; + } catch { + return {}; + } } -/** Save progress map to JSON file */ -export async function saveProgressToFile( - data: Record, -): Promise { - try { - await ensureConfigDir(); - await backupConfigFile(PROGRESS_FILE); - const filePath = getConfigFilePath(PROGRESS_FILE); - await Bun.write(filePath, JSON.stringify(data, null, 2)); - } catch { - // Silently ignore write errors - } +/** Save progress map to JSON file (overwrite, no backup) */ +export function saveProgressToFile(data: Record): void { + (async () => { + try { + await ensureConfigDir(); + await Bun.write( + getConfigFilePath(PROGRESS_FILE), + JSON.stringify(data, null, 2), + ); + } catch { + // Silently ignore write errors + } + })(); } -interface AudioNavEntry { - source: string; - currentIndex: number; - podcastId?: string; - lastUpdated: string; -} +// ── Audio Nav State (separate file — changes on every track change) ────────── + +const AUDIO_NAV_FILE = "audio-nav.json"; /** Load audio navigation state from JSON file */ export async function loadAudioNavFromFile(): Promise { - try { - const filePath = getConfigFilePath(AUDIO_NAV_FILE); - const file = Bun.file(filePath); - if (!(await file.exists())) return null; + try { + const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE)); + if (!(await file.exists())) return null; - const raw = await file.json(); - if (!raw || typeof raw !== "object") return null; + const raw = await file.json(); + if (!raw || typeof raw !== "object") return null; - return raw as T; - } catch { - return null; - } + return raw as T; + } catch { + return null; + } } -/** Save audio navigation state to JSON file */ -export async function saveAudioNavToFile( - data: T, -): Promise { - try { - await ensureConfigDir(); - const filePath = getConfigFilePath(AUDIO_NAV_FILE); - await Bun.write(filePath, JSON.stringify(data, null, 2)); - } catch { - // Silently ignore write errors - } +/** Save audio navigation state to JSON file (overwrite, no backup) */ +export function saveAudioNavToFile(data: T): void { + (async () => { + try { + await ensureConfigDir(); + await Bun.write( + getConfigFilePath(AUDIO_NAV_FILE), + JSON.stringify(data, null, 2), + ); + } catch { + // Silently ignore write errors + } + })(); } diff --git a/src/utils/config-backup.ts b/src/utils/config-backup.ts deleted file mode 100644 index 6f29e04..0000000 --- a/src/utils/config-backup.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Config file backup utility for PodTUI - * - * Creates timestamped backups of config files before updates. - * Keeps the most recent N backups and cleans up older ones. - */ - -import { readdir, unlink } from "fs/promises" -import path from "path" -import { getConfigDir, ensureConfigDir } from "./config-dir" - -/** Maximum number of backup files to keep per config file */ -const MAX_BACKUPS = 5 - -/** - * Generate a timestamped backup filename. - * Example: feeds.json -> feeds.json.2026-02-05T120000.backup - */ -function backupFilename(originalName: string): string { - const ts = new Date().toISOString().replace(/[:.]/g, "").slice(0, 15) - return `${originalName}.${ts}.backup` -} - -/** - * Create a backup of a config file before overwriting it. - * No-op if the source file does not exist. - */ -export async function backupConfigFile(filename: string): Promise { - try { - await ensureConfigDir() - const dir = getConfigDir() - const srcPath = path.join(dir, filename) - const srcFile = Bun.file(srcPath) - - if (!(await srcFile.exists())) return false - - const content = await srcFile.text() - if (!content || content.trim().length === 0) return false - - const backupName = backupFilename(filename) - const backupPath = path.join(dir, backupName) - await Bun.write(backupPath, content) - - // Clean up old backups - await pruneBackups(filename) - - return true - } catch { - return false - } -} - -/** - * Keep only the most recent MAX_BACKUPS backup files for a given config file. - */ -async function pruneBackups(filename: string): Promise { - try { - const dir = getConfigDir() - const entries = await readdir(dir) - - // Match pattern: filename.*.backup - const prefix = `${filename}.` - const suffix = ".backup" - const backups = entries - .filter((e) => e.startsWith(prefix) && e.endsWith(suffix)) - .sort() // Lexicographic sort works because timestamps are ISO-like - - if (backups.length <= MAX_BACKUPS) return - - const toRemove = backups.slice(0, backups.length - MAX_BACKUPS) - for (const name of toRemove) { - await unlink(path.join(dir, name)).catch(() => {}) - } - } catch { - // Silently ignore cleanup errors - } -} - -/** - * List existing backup files for a given config file, newest first. - */ -export async function listBackups(filename: string): Promise { - try { - const dir = getConfigDir() - const entries = await readdir(dir) - - const prefix = `${filename}.` - const suffix = ".backup" - return entries - .filter((e) => e.startsWith(prefix) && e.endsWith(suffix)) - .sort() - .reverse() - } catch { - return [] - } -} diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 0000000..e679b7d --- /dev/null +++ b/src/utils/config.ts @@ -0,0 +1,187 @@ +/** + * Centralized PodTui configuration — a single `config.json` holding every + * user-facing bit needed to migrate to a new machine by copying one file. + * + * Contains: settings, preferences, custom theme, feeds (subscriptions), and + * sources (podcast search/RSS sources). + * + * Runtime state that changes on every playback action (progress, downloads, + * audio-nav) stays in separate files to avoid rewriting this file on every + * seek. Keybinds remain in `keybinds.jsonc` (user-editable JSONC). + * + * Writes are serialized to avoid concurrent read-modify-write races, and + * always overwrite — no backup files are created. + */ + +import { ensureConfigDir, getConfigDir, getConfigFilePath } from "./config-dir"; +import type { + AppSettings, + UserPreferences, + ThemeColors, +} from "../types/settings"; +import type { Feed } from "../types/feed"; +import type { PodcastSource } from "../types/source"; + +/** Everything a user needs to migrate, in one file. */ +export interface PodTuiConfig { + settings?: AppSettings; + preferences?: UserPreferences; + customTheme?: ThemeColors; + feeds?: Feed[]; + sources?: PodcastSource[]; +} + +const CONFIG_FILE = "config.json"; + +/** Legacy per-section files, migrated into config.json on first load. */ +const LEGACY_FILES = ["app-state.json", "feeds.json", "sources.json"] as const; + +/** Load the full config from disk. Returns {} if missing or corrupt. + * Runs one-time legacy migration on first call. */ +export async function loadConfig(): Promise { + await migrateOnce(); + try { + const file = Bun.file(getConfigFilePath(CONFIG_FILE)); + if (!(await file.exists())) return {}; + const raw = await file.json(); + if (!raw || typeof raw !== "object") return {}; + return raw as PodTuiConfig; + } catch { + return {}; + } +} + +// ── Write serialization ──────────────────────────────────────────────────── +// A simple promise chain ensures reads-modify-writes execute sequentially so +// two concurrent saves can't clobber each other's sections. +let writeChain: Promise = Promise.resolve(); + +/** Update sections of config.json (read-modify-write, serialized, overwrite). */ +export function updateConfig(patch: Partial): void { + writeChain = writeChain.then(async () => { + try { + await ensureConfigDir(); + const current = await loadConfig(); + const next = { ...current, ...patch }; + await Bun.write( + getConfigFilePath(CONFIG_FILE), + JSON.stringify(next, null, 2), + ); + } catch { + // Fire-and-forget persistence — silently ignore write errors. + } + }); +} + +/** Await all pending config writes (used by sync/export flows). */ +export async function flushConfig(): Promise { + await writeChain; +} + +/** Guards so migration runs exactly once per process. */ +let migrationDone = false; +let migrationPromise: Promise | null = null; + +/** Run legacy migration + backup cleanup once, before the first config read. */ +async function migrateOnce(): Promise { + if (migrationDone) return; + if (!migrationPromise) migrationPromise = migrateLegacyConfig(); + await migrationPromise; + migrationDone = true; +} + +/** + * One-time migration: if config.json doesn't exist but legacy per-section + * files do, merge them into a single config.json. Also cleans up any stale + * backup files (`.backup` suffix) left by the old config-backup module. + * + * Safe to call on every startup — no-op once config.json exists (except for + * backup cleanup, which runs unconditionally since those files are now dead). + */ +export async function migrateLegacyConfig(): Promise { + try { + await ensureConfigDir(); + const dir = getConfigDir(); + const configExists = await Bun.file( + getConfigFilePath(CONFIG_FILE), + ).exists(); + + if (!configExists) { + const merged: PodTuiConfig = {}; + + // app-state.json → settings, preferences, customTheme + const appStateFile = Bun.file(getConfigFilePath("app-state.json")); + if (await appStateFile.exists()) { + try { + const raw = await appStateFile.json(); + if (raw && typeof raw === "object") { + merged.settings = raw.settings; + merged.preferences = raw.preferences; + merged.customTheme = raw.customTheme; + } + } catch { + // ignore corrupt legacy file + } + } + + // feeds.json → feeds + const feedsFile = Bun.file(getConfigFilePath("feeds.json")); + if (await feedsFile.exists()) { + try { + const raw = await feedsFile.json(); + if (Array.isArray(raw)) merged.feeds = raw; + } catch { + // ignore + } + } + + // sources.json → sources + const sourcesFile = Bun.file(getConfigFilePath("sources.json")); + if (await sourcesFile.exists()) { + try { + const raw = await sourcesFile.json(); + if (Array.isArray(raw)) merged.sources = raw; + } catch { + // ignore + } + } + + if (Object.keys(merged).length > 0) { + await Bun.write( + getConfigFilePath(CONFIG_FILE), + JSON.stringify(merged, null, 2), + ); + // Remove migrated legacy files + for (const name of LEGACY_FILES) { + await Bun.file(getConfigFilePath(name)) + .exists() + .then(async (exists) => { + if (exists) + await import("fs/promises").then((fs) => + fs.unlink(getConfigFilePath(name)).catch(() => {}), + ); + }); + } + } + } + + // Clean up stale backup files (no longer created, remove old ones) + await cleanBackups(dir); + } catch { + // Migration is best-effort — never block startup. + } +} + +/** Remove all `.backup` files from the config directory. */ +async function cleanBackups(dir: string): Promise { + try { + const { readdir, unlink } = await import("fs/promises"); + const entries = await readdir(dir); + const backups = entries.filter((e) => e.endsWith(".backup")); + for (const name of backups) { + await unlink(`${dir}/${name}`).catch(() => {}); + } + } catch { + // ignore + } +} diff --git a/src/utils/event-bus.ts b/src/utils/event-bus.ts index d8b2be7..9be949e 100644 --- a/src/utils/event-bus.ts +++ b/src/utils/event-bus.ts @@ -105,8 +105,6 @@ export type AppEvents = { "player.play": { episodeId: string }; "player.pause": { episodeId: string }; "player.stop": {}; - "auth.login": { userId: string }; - "auth.logout": {}; "toast.show": { message: string; variant: "info" | "success" | "warning" | "error"; diff --git a/src/utils/feeds-persistence.ts b/src/utils/feeds-persistence.ts index c5eda7b..fca9f5b 100644 --- a/src/utils/feeds-persistence.ts +++ b/src/utils/feeds-persistence.ts @@ -1,82 +1,56 @@ /** - * Feeds persistence via JSON file in XDG_CONFIG_HOME - * - * Reads and writes feeds to a JSON file instead of localStorage. + * Feeds & sources persistence — stored in the centralized `config.json` + * (see utils/config.ts). No backups; writes always overwrite. */ -import { ensureConfigDir, getConfigFilePath } from "./config-dir"; -import { backupConfigFile } from "./config-backup"; +import { loadConfig, updateConfig } from "./config"; import type { Feed } from "../types/feed"; - -const FEEDS_FILE = "feeds.json"; -const SOURCES_FILE = "sources.json"; +import type { PodcastSource } from "../types/source"; /** Deserialize date strings back to Date objects in feed data */ function reviveDates(feed: Feed): Feed { - return { - ...feed, - lastUpdated: new Date(feed.lastUpdated), - podcast: { - ...feed.podcast, - lastUpdated: new Date(feed.podcast.lastUpdated), - }, - episodes: feed.episodes.map((ep) => ({ - ...ep, - pubDate: new Date(ep.pubDate), - })), - }; + return { + ...feed, + lastUpdated: new Date(feed.lastUpdated), + podcast: { + ...feed.podcast, + lastUpdated: new Date(feed.podcast.lastUpdated), + }, + episodes: feed.episodes.map((ep) => ({ + ...ep, + pubDate: new Date(ep.pubDate), + })), + }; } -/** Load feeds from JSON file */ +/** Load feeds from config.json */ export async function loadFeedsFromFile(): Promise { - try { - const filePath = getConfigFilePath(FEEDS_FILE); - const file = Bun.file(filePath); - if (!(await file.exists())) return []; - - const raw = await file.json(); - if (!Array.isArray(raw)) return []; - return raw.map(reviveDates); - } catch { - return []; - } + try { + const cfg = await loadConfig(); + if (!Array.isArray(cfg.feeds)) return []; + return cfg.feeds.map(reviveDates); + } catch { + return []; + } } -/** Save feeds to JSON file */ -export async function saveFeedsToFile(feeds: Feed[]): Promise { - try { - await ensureConfigDir(); - await backupConfigFile(FEEDS_FILE); - const filePath = getConfigFilePath(FEEDS_FILE); - await Bun.write(filePath, JSON.stringify(feeds, null, 2)); - } catch { - // Silently ignore write errors - } +/** Save feeds to config.json */ +export function saveFeedsToFile(feeds: Feed[]): void { + updateConfig({ feeds }); } -/** Load sources from JSON file */ +/** Load sources from config.json */ export async function loadSourcesFromFile(): Promise { - try { - const filePath = getConfigFilePath(SOURCES_FILE); - const file = Bun.file(filePath); - if (!(await file.exists())) return null; - - const raw = await file.json(); - if (!Array.isArray(raw)) return null; - return raw as T[]; - } catch { - return null; - } + try { + const cfg = await loadConfig(); + if (!Array.isArray(cfg.sources)) return null; + return cfg.sources as T[]; + } catch { + return null; + } } -/** Save sources to JSON file */ -export async function saveSourcesToFile(sources: T[]): Promise { - try { - await ensureConfigDir(); - await backupConfigFile(SOURCES_FILE); - const filePath = getConfigFilePath(SOURCES_FILE); - await Bun.write(filePath, JSON.stringify(sources, null, 2)); - } catch { - // Silently ignore write errors - } +/** Save sources to config.json */ +export function saveSourcesToFile(sources: T[]): void { + updateConfig({ sources: sources as unknown as PodcastSource[] }); } diff --git a/src/utils/persistence.ts b/src/utils/persistence.ts deleted file mode 100644 index 2d1286c..0000000 --- a/src/utils/persistence.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { AppSettings, UserPreferences } from "../types/settings" -import type { Feed } from "../types/feed" - -const STORAGE_KEYS = { - settings: "podtui_settings", - preferences: "podtui_preferences", - feeds: "podtui_feeds", -} - -export const savePreference = (key: keyof UserPreferences, value: boolean) => { - const current = loadPreferences() - const next = { ...current, [key]: value } - savePreferences(next) -} - -export const loadPreference = (key: keyof UserPreferences) => { - return loadPreferences()[key] -} - -export const saveSettings = (settings: AppSettings) => { - if (typeof localStorage === "undefined") return - try { - localStorage.setItem(STORAGE_KEYS.settings, JSON.stringify(settings)) - } catch { - // ignore - } -} - -export const loadSettings = (): AppSettings | null => { - if (typeof localStorage === "undefined") return null - try { - const raw = localStorage.getItem(STORAGE_KEYS.settings) - return raw ? (JSON.parse(raw) as AppSettings) : null - } catch { - return null - } -} - -export const savePreferences = (preferences: UserPreferences) => { - if (typeof localStorage === "undefined") return - try { - localStorage.setItem(STORAGE_KEYS.preferences, JSON.stringify(preferences)) - } catch { - // ignore - } -} - -export const loadPreferences = (): UserPreferences => { - if (typeof localStorage === "undefined") { - return { showExplicit: false, autoDownload: false } - } - try { - const raw = localStorage.getItem(STORAGE_KEYS.preferences) - return raw ? (JSON.parse(raw) as UserPreferences) : { showExplicit: false, autoDownload: false } - } catch { - return { showExplicit: false, autoDownload: false } - } -} - -export const saveFeeds = (feeds: Feed[]) => { - if (typeof localStorage === "undefined") return - try { - localStorage.setItem(STORAGE_KEYS.feeds, JSON.stringify(feeds)) - } catch { - // ignore - } -} - -export const loadFeeds = (): Feed[] => { - if (typeof localStorage === "undefined") return [] - try { - const raw = localStorage.getItem(STORAGE_KEYS.feeds) - return raw ? (JSON.parse(raw) as Feed[]) : [] - } catch { - return [] - } -}