drop oauth plan (copy config)
This commit is contained in:
@@ -1,6 +1,5 @@
|
|||||||
import { ErrorBoundary } from "solid-js";
|
import { ErrorBoundary } from "solid-js";
|
||||||
import { useSelectionHandler, useRenderer } from "@opentui/solid";
|
import { useSelectionHandler, useRenderer } from "@opentui/solid";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
|
||||||
import { useAudio } from "@/hooks/useAudio";
|
import { useAudio } from "@/hooks/useAudio";
|
||||||
import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
|
import { useMultimediaKeys } from "@/hooks/useMultimediaKeys";
|
||||||
import { Clipboard } from "@/utils/clipboard";
|
import { Clipboard } from "@/utils/clipboard";
|
||||||
@@ -19,7 +18,6 @@ const DEBUG = import.meta.env.DEBUG;
|
|||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
const auth = useAuthStore();
|
|
||||||
const audio = useAudio();
|
const audio = useAudio();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const renderer = useRenderer();
|
const renderer = useRenderer();
|
||||||
|
|||||||
@@ -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<FocusField>("code");
|
|
||||||
const [codeError, setCodeError] = createSignal<string | null>(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 (
|
|
||||||
<box flexDirection="column" border padding={2} gap={1} borderColor={theme.border}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<strong>Enter Sync Code</strong>
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>
|
|
||||||
Enter your 8-character sync code to link your account.
|
|
||||||
</text>
|
|
||||||
<text fg={theme.textMuted}>You can get this code from the web portal.</text>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Code display */}
|
|
||||||
<box flexDirection="column" gap={0}>
|
|
||||||
<text fg={focusField() === "code" ? theme.primary : undefined}>
|
|
||||||
Code ({codeProgress()}):
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<box border padding={1} borderColor={theme.border}>
|
|
||||||
<text
|
|
||||||
fg={
|
|
||||||
code().length === AUTH_CONFIG.codeValidation.codeLength
|
|
||||||
? theme.success
|
|
||||||
: theme.warning
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{codeDisplay()}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Hidden input for actual typing */}
|
|
||||||
<input
|
|
||||||
value={code()}
|
|
||||||
onInput={handleCodeInput}
|
|
||||||
placeholder=""
|
|
||||||
focused={props.focused && focusField() === "code"}
|
|
||||||
width={30}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{codeError() && <text fg={theme.error}>{codeError()}</text>}
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "submit" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "submit" ? theme.primary : undefined}>
|
|
||||||
{auth.isLoading ? "Validating..." : "[Enter] Validate Code"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "back" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "back" ? theme.warning : theme.textMuted}>
|
|
||||||
[Esc] Back to Login
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Auth error message */}
|
|
||||||
{auth.error && <text fg={theme.error}>{auth.error.message}</text>}
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>Tab to navigate, Enter to select, Esc to go back</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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()
|
|
||||||
@@ -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<FocusField>("email");
|
|
||||||
const [emailError, setEmailError] = createSignal<string | null>(null);
|
|
||||||
const [passwordError, setPasswordError] = createSignal<string | null>(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 (
|
|
||||||
<box flexDirection="column" border borderColor={theme.border} padding={2} gap={1}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<strong>Sign In</strong>
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Email field */}
|
|
||||||
<box flexDirection="column" gap={0}>
|
|
||||||
<text fg={focusField() === "email" ? theme.primary : theme.textMuted}>
|
|
||||||
Email:
|
|
||||||
</text>
|
|
||||||
<input
|
|
||||||
value={email()}
|
|
||||||
onInput={setEmail}
|
|
||||||
placeholder="your@email.com"
|
|
||||||
focused={props.focused && focusField() === "email"}
|
|
||||||
width={30}
|
|
||||||
/>
|
|
||||||
{emailError() && <text fg={theme.error}>{emailError()}</text>}
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Password field */}
|
|
||||||
<box flexDirection="column" gap={0}>
|
|
||||||
<text fg={focusField() === "password" ? theme.primary : theme.textMuted}>
|
|
||||||
Password:
|
|
||||||
</text>
|
|
||||||
<input
|
|
||||||
value={password()}
|
|
||||||
onInput={setPassword}
|
|
||||||
placeholder="********"
|
|
||||||
focused={props.focused && focusField() === "password"}
|
|
||||||
width={30}
|
|
||||||
/>
|
|
||||||
{passwordError() && <text fg={theme.error}>{passwordError()}</text>}
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Submit button */}
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
borderColor={theme.border}
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={
|
|
||||||
focusField() === "submit" ? theme.primary : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "submit" ? theme.text : undefined}>
|
|
||||||
{auth.isLoading ? "Signing in..." : "[Enter] Sign In"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* Auth error message */}
|
|
||||||
{auth.error && <text fg={theme.error}>{auth.error.message}</text>}
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Alternative auth options */}
|
|
||||||
<text fg={theme.textMuted}>Or authenticate with:</text>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
borderColor={theme.border}
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "code" ? theme.primary : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "code" ? theme.accent : theme.textMuted}>
|
|
||||||
[C] Sync Code
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
borderColor={theme.border}
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "oauth" ? theme.primary : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "oauth" ? theme.accent : theme.textMuted}>
|
|
||||||
[O] OAuth Info
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>Tab to navigate, Enter to select</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<FocusField>("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 (
|
|
||||||
<box flexDirection="column" border padding={2} gap={1} borderColor={theme.border}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<strong>OAuth Authentication</strong>
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* OAuth providers list */}
|
|
||||||
<text fg={theme.primary}>Available OAuth Providers:</text>
|
|
||||||
|
|
||||||
<box flexDirection="column" gap={0} paddingLeft={2}>
|
|
||||||
{OAUTH_PROVIDERS.map((provider) => (
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={provider.enabled ? theme.success : theme.textMuted}>
|
|
||||||
{provider.enabled ? "[+]" : "[-]"} {provider.name}
|
|
||||||
</text>
|
|
||||||
<text fg={theme.textMuted}>- {provider.description}</text>
|
|
||||||
</box>
|
|
||||||
))}
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Limitation message */}
|
|
||||||
<box border padding={1} borderColor={theme.warning}>
|
|
||||||
<text fg={theme.warning}>Terminal Limitations</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box paddingLeft={1}>
|
|
||||||
{OAUTH_LIMITATION_MESSAGE.split("\n").map((line) => (
|
|
||||||
<text fg={theme.textMuted}>{line}</text>
|
|
||||||
))}
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Alternative options */}
|
|
||||||
<text fg={theme.primary}>Recommended Alternatives:</text>
|
|
||||||
|
|
||||||
<box flexDirection="column" gap={0} paddingLeft={2}>
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={theme.success}>[1]</text>
|
|
||||||
<text fg={theme.text}>Use a sync code from the web portal</text>
|
|
||||||
<text fg={theme.success}>[2]</text>
|
|
||||||
<text fg={theme.text}>Use email/password authentication</text>
|
|
||||||
<text fg={theme.success}>[3]</text>
|
|
||||||
<text fg={theme.text}>Use file-based sync (no account needed)</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "code" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "code" ? theme.primary : undefined}>
|
|
||||||
[C] Enter Sync Code
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "back" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "back" ? theme.warning : theme.textMuted}>
|
|
||||||
[Esc] Back to Login
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>Tab to navigate, Enter to select, Esc to go back</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -63,18 +63,12 @@ const SECTIONS: SettingsSectionDef[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
label: "Account",
|
|
||||||
description: "Account login & OAuth (not yet implemented).",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5,
|
|
||||||
label: "Downloads",
|
label: "Downloads",
|
||||||
description: "Manage downloaded episodes — delete by show or individually.",
|
description: "Manage downloaded episodes — delete by show or individually.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Resolve the items for a section id at render time. Section 4 (Account) has
|
/** Resolve the items for a section id at render time. */
|
||||||
* no items yet. */
|
|
||||||
function sectionItems(sectionId: number): SettingItem[] {
|
function sectionItems(sectionId: number): SettingItem[] {
|
||||||
switch (sectionId) {
|
switch (sectionId) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -85,7 +79,7 @@ function sectionItems(sectionId: number): SettingItem[] {
|
|||||||
return usePreferencesItems();
|
return usePreferencesItems();
|
||||||
case 3:
|
case 3:
|
||||||
return useVisualizerItems();
|
return useVisualizerItems();
|
||||||
case 5:
|
case 4:
|
||||||
return useDownloadItems();
|
return useDownloadItems();
|
||||||
default:
|
default:
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -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<FocusField>("sync");
|
|
||||||
const [lastSyncTime] = createSignal<Date | null>(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 (
|
|
||||||
<box flexDirection="column" border padding={2} gap={1} borderColor={theme.border}>
|
|
||||||
<text fg={theme.text}>
|
|
||||||
<strong>User Profile</strong>
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* User avatar and info */}
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
{/* ASCII avatar */}
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={1}
|
|
||||||
width={8}
|
|
||||||
height={4}
|
|
||||||
justifyContent="center"
|
|
||||||
alignItems="center"
|
|
||||||
>
|
|
||||||
<text fg={theme.primary}>{userInitials()}</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
{/* User details */}
|
|
||||||
<box flexDirection="column" gap={0}>
|
|
||||||
<text fg={theme.text}>{user()?.name || "Guest User"}</text>
|
|
||||||
<text fg={theme.textMuted}>{user()?.email || "No email"}</text>
|
|
||||||
<text fg={theme.textMuted}>Joined: {formatDate(user()?.createdAt)}</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Sync status section */}
|
|
||||||
<box border padding={1} flexDirection="column" gap={0} borderColor={theme.border}>
|
|
||||||
<text fg={theme.primary}>Sync Status</text>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={theme.textMuted}>Status:</text>
|
|
||||||
<text fg={user()?.syncEnabled ? theme.success : theme.warning}>
|
|
||||||
{user()?.syncEnabled ? "Enabled" : "Disabled"}
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={theme.textMuted}>Last Sync:</text>
|
|
||||||
<text fg={theme.text}>{formatDate(lastSyncTime())}</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box flexDirection="row" gap={1}>
|
|
||||||
<text fg={theme.textMuted}>Method:</text>
|
|
||||||
<text fg={theme.text}>File-based (JSON/XML)</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
<box flexDirection="row" gap={2}>
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "sync" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "sync" ? theme.primary : undefined}>
|
|
||||||
[S] Manage Sync
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "export" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "export" ? theme.primary : undefined}>
|
|
||||||
[E] Export Data
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box
|
|
||||||
border
|
|
||||||
padding={1}
|
|
||||||
backgroundColor={focusField() === "logout" ? theme.backgroundElement : undefined}
|
|
||||||
>
|
|
||||||
<text fg={focusField() === "logout" ? theme.error : theme.textMuted}>
|
|
||||||
[L] Logout
|
|
||||||
</text>
|
|
||||||
</box>
|
|
||||||
</box>
|
|
||||||
|
|
||||||
<box height={1} />
|
|
||||||
|
|
||||||
<text fg={theme.textMuted}>Tab to navigate, Enter to select</text>
|
|
||||||
</box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -55,7 +55,7 @@ export function createAppStore() {
|
|||||||
init();
|
init();
|
||||||
|
|
||||||
const saveState = (next: AppState) => {
|
const saveState = (next: AppState) => {
|
||||||
saveAppStateToFile(next).catch(() => {});
|
saveAppStateToFile(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateState = (next: AppState) => {
|
const updateState = (next: AppState) => {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export function createAudioNavStore() {
|
|||||||
|
|
||||||
/** Persist current navigation state to file (fire-and-forget) */
|
/** Persist current navigation state to file (fire-and-forget) */
|
||||||
function persist(): void {
|
function persist(): void {
|
||||||
saveAudioNavToFile(navState()).catch(() => {});
|
saveAudioNavToFile(navState());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load navigation state from file */
|
/** Load navigation state from file */
|
||||||
|
|||||||
@@ -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<AuthState>(loadAuthState())
|
|
||||||
const [authEnabled, setAuthEnabled] = createSignal(DEFAULT_AUTH_ENABLED)
|
|
||||||
const [currentScreen, setCurrentScreen] = createSignal<AuthScreen>("login")
|
|
||||||
|
|
||||||
/** Update state and persist */
|
|
||||||
const updateState = (updates: Partial<AuthState>) => {
|
|
||||||
setState((prev) => {
|
|
||||||
const next = { ...prev, ...updates }
|
|
||||||
saveAuthState(next)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Login with email/password (placeholder - no real backend) */
|
|
||||||
const login = async (credentials: LoginCredentials): Promise<boolean> => {
|
|
||||||
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<boolean> => {
|
|
||||||
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<typeof createAuthStore> | null = null
|
|
||||||
|
|
||||||
/** Get or create auth store */
|
|
||||||
export function useAuthStore() {
|
|
||||||
if (!authStoreInstance) {
|
|
||||||
authStoreInstance = createAuthStore()
|
|
||||||
}
|
|
||||||
return authStoreInstance
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,6 @@ import type { DownloadedEpisode } from "../types/episode";
|
|||||||
import type { Episode } from "../types/episode";
|
import type { Episode } from "../types/episode";
|
||||||
import { downloadEpisode } from "../utils/episode-downloader";
|
import { downloadEpisode } from "../utils/episode-downloader";
|
||||||
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir";
|
import { ensureConfigDir, getConfigFilePath } from "../utils/config-dir";
|
||||||
import { backupConfigFile } from "../utils/config-backup";
|
|
||||||
|
|
||||||
const DOWNLOADS_FILE = "downloads.json";
|
const DOWNLOADS_FILE = "downloads.json";
|
||||||
const MAX_CONCURRENT = 2;
|
const MAX_CONCURRENT = 2;
|
||||||
@@ -94,7 +93,6 @@ export function createDownloadStore() {
|
|||||||
async function saveDownloads(): Promise<void> {
|
async function saveDownloads(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await ensureConfigDir();
|
await ensureConfigDir();
|
||||||
await backupConfigFile(DOWNLOADS_FILE);
|
|
||||||
const map = downloads();
|
const map = downloads();
|
||||||
const records: DownloadRecord[] = [];
|
const records: DownloadRecord[] = [];
|
||||||
for (const [, dl] of map) {
|
for (const [, dl] of map) {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
} from "../utils/feeds-persistence";
|
} from "../utils/feeds-persistence";
|
||||||
import { useDownloadStore } from "./download";
|
import { useDownloadStore } from "./download";
|
||||||
import { DownloadStatus } from "../types/episode";
|
import { DownloadStatus } from "../types/episode";
|
||||||
import { useAuthStore } from "./auth";
|
|
||||||
|
|
||||||
/** Max episodes to load per page/chunk */
|
/** Max episodes to load per page/chunk */
|
||||||
const MAX_EPISODES_REFRESH = 50;
|
const MAX_EPISODES_REFRESH = 50;
|
||||||
@@ -35,12 +34,12 @@ const episodeLoadCount = new Map<string, number>();
|
|||||||
|
|
||||||
/** Save feeds to file (async, fire-and-forget) */
|
/** Save feeds to file (async, fire-and-forget) */
|
||||||
function saveFeeds(feeds: Feed[]): void {
|
function saveFeeds(feeds: Feed[]): void {
|
||||||
saveFeedsToFile(feeds).catch(() => {});
|
saveFeedsToFile(feeds);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save sources to file (async, fire-and-forget) */
|
/** Save sources to file (async, fire-and-forget) */
|
||||||
function saveSources(sources: PodcastSource[]): void {
|
function saveSources(sources: PodcastSource[]): void {
|
||||||
saveSourcesToFile(sources).catch(() => {});
|
saveSourcesToFile(sources);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create feed store */
|
/** Create feed store */
|
||||||
@@ -62,18 +61,10 @@ export function createFeedStore() {
|
|||||||
const getFilteredFeeds = (): Feed[] => {
|
const getFilteredFeeds = (): Feed[] => {
|
||||||
let result = [...feeds()];
|
let result = [...feeds()];
|
||||||
const f = filter();
|
const f = filter();
|
||||||
const authStore = useAuthStore();
|
|
||||||
|
|
||||||
// Filter by visibility
|
// Filter by visibility
|
||||||
if (f.visibility && f.visibility !== "all") {
|
if (f.visibility && f.visibility !== "all") {
|
||||||
result = result.filter((feed) => feed.visibility === f.visibility);
|
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
|
// Filter by source
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const [progressMap, setProgressMap] = createSignal<Record<string, Progress>>(
|
|||||||
|
|
||||||
/** Persist current progress map to file (fire-and-forget) */
|
/** Persist current progress map to file (fire-and-forget) */
|
||||||
function persist(): void {
|
function persist(): void {
|
||||||
saveProgressToFile(progressMap()).catch(() => {});
|
saveProgressToFile(progressMap());
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse raw progress entries from file, reviving Date objects */
|
/** Parse raw progress entries from file, reviving Date objects */
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* 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 { ensureConfigDir, getConfigFilePath } from "./config-dir";
|
||||||
import { backupConfigFile } from "./config-backup";
|
import { loadConfig, updateConfig } from "./config";
|
||||||
import type {
|
import type {
|
||||||
AppState,
|
AppState,
|
||||||
AppSettings,
|
AppSettings,
|
||||||
@@ -14,10 +17,6 @@ import type {
|
|||||||
} from "../types/settings";
|
} from "../types/settings";
|
||||||
import { DEFAULT_THEME } from "../constants/themes";
|
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 ---
|
// --- Defaults ---
|
||||||
|
|
||||||
const defaultVisualizerSettings: VisualizerSettings = {
|
const defaultVisualizerSettings: VisualizerSettings = {
|
||||||
@@ -47,41 +46,36 @@ const defaultState: AppState = {
|
|||||||
customTheme: DEFAULT_THEME,
|
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<AppState> {
|
export async function loadAppStateFromFile(): Promise<AppState> {
|
||||||
try {
|
try {
|
||||||
const filePath = getConfigFilePath(APP_STATE_FILE);
|
const cfg = await loadConfig();
|
||||||
const file = Bun.file(filePath);
|
if (!cfg || typeof cfg !== "object") return defaultState;
|
||||||
if (!(await file.exists())) return defaultState;
|
|
||||||
|
|
||||||
const raw = await file.json();
|
|
||||||
if (!raw || typeof raw !== "object") return defaultState;
|
|
||||||
|
|
||||||
const parsed = raw as Partial<AppState>;
|
|
||||||
return {
|
return {
|
||||||
settings: { ...defaultSettings, ...parsed.settings },
|
settings: { ...defaultSettings, ...cfg.settings },
|
||||||
preferences: { ...defaultPreferences, ...parsed.preferences },
|
preferences: { ...defaultPreferences, ...cfg.preferences },
|
||||||
customTheme: { ...DEFAULT_THEME, ...parsed.customTheme },
|
customTheme: { ...DEFAULT_THEME, ...cfg.customTheme },
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return defaultState;
|
return defaultState;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save app state to JSON file */
|
/** Save app state to config.json */
|
||||||
export async function saveAppStateToFile(state: AppState): Promise<void> {
|
export function saveAppStateToFile(state: AppState): void {
|
||||||
try {
|
updateConfig({
|
||||||
await ensureConfigDir();
|
settings: state.settings,
|
||||||
await backupConfigFile(APP_STATE_FILE);
|
preferences: state.preferences,
|
||||||
const filePath = getConfigFilePath(APP_STATE_FILE);
|
customTheme: state.customTheme,
|
||||||
await Bun.write(filePath, JSON.stringify(state, null, 2));
|
});
|
||||||
} catch {
|
|
||||||
// Silently ignore write errors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Playback Progress (separate file — changes on every seek) ───────────────
|
||||||
|
|
||||||
|
const PROGRESS_FILE = "progress.json";
|
||||||
|
|
||||||
interface ProgressEntry {
|
interface ProgressEntry {
|
||||||
episodeId: string;
|
episodeId: string;
|
||||||
position: number;
|
position: number;
|
||||||
@@ -107,32 +101,29 @@ export async function loadProgressFromFile(): Promise<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save progress map to JSON file */
|
/** Save progress map to JSON file (overwrite, no backup) */
|
||||||
export async function saveProgressToFile(
|
export function saveProgressToFile(data: Record<string, unknown>): void {
|
||||||
data: Record<string, unknown>,
|
(async () => {
|
||||||
): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
await ensureConfigDir();
|
await ensureConfigDir();
|
||||||
await backupConfigFile(PROGRESS_FILE);
|
await Bun.write(
|
||||||
const filePath = getConfigFilePath(PROGRESS_FILE);
|
getConfigFilePath(PROGRESS_FILE),
|
||||||
await Bun.write(filePath, JSON.stringify(data, null, 2));
|
JSON.stringify(data, null, 2),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore write errors
|
// Silently ignore write errors
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AudioNavEntry {
|
// ── Audio Nav State (separate file — changes on every track change) ──────────
|
||||||
source: string;
|
|
||||||
currentIndex: number;
|
const AUDIO_NAV_FILE = "audio-nav.json";
|
||||||
podcastId?: string;
|
|
||||||
lastUpdated: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load audio navigation state from JSON file */
|
/** Load audio navigation state from JSON file */
|
||||||
export async function loadAudioNavFromFile<T>(): Promise<T | null> {
|
export async function loadAudioNavFromFile<T>(): Promise<T | null> {
|
||||||
try {
|
try {
|
||||||
const filePath = getConfigFilePath(AUDIO_NAV_FILE);
|
const file = Bun.file(getConfigFilePath(AUDIO_NAV_FILE));
|
||||||
const file = Bun.file(filePath);
|
|
||||||
if (!(await file.exists())) return null;
|
if (!(await file.exists())) return null;
|
||||||
|
|
||||||
const raw = await file.json();
|
const raw = await file.json();
|
||||||
@@ -144,15 +135,17 @@ export async function loadAudioNavFromFile<T>(): Promise<T | null> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save audio navigation state to JSON file */
|
/** Save audio navigation state to JSON file (overwrite, no backup) */
|
||||||
export async function saveAudioNavToFile<T>(
|
export function saveAudioNavToFile<T>(data: T): void {
|
||||||
data: T,
|
(async () => {
|
||||||
): Promise<void> {
|
|
||||||
try {
|
try {
|
||||||
await ensureConfigDir();
|
await ensureConfigDir();
|
||||||
const filePath = getConfigFilePath(AUDIO_NAV_FILE);
|
await Bun.write(
|
||||||
await Bun.write(filePath, JSON.stringify(data, null, 2));
|
getConfigFilePath(AUDIO_NAV_FILE),
|
||||||
|
JSON.stringify(data, null, 2),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore write errors
|
// Silently ignore write errors
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<boolean> {
|
|
||||||
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<void> {
|
|
||||||
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<string[]> {
|
|
||||||
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 []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
187
src/utils/config.ts
Normal file
187
src/utils/config.ts
Normal file
@@ -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<PodTuiConfig> {
|
||||||
|
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<void> = Promise.resolve();
|
||||||
|
|
||||||
|
/** Update sections of config.json (read-modify-write, serialized, overwrite). */
|
||||||
|
export function updateConfig(patch: Partial<PodTuiConfig>): 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<void> {
|
||||||
|
await writeChain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Guards so migration runs exactly once per process. */
|
||||||
|
let migrationDone = false;
|
||||||
|
let migrationPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
/** Run legacy migration + backup cleanup once, before the first config read. */
|
||||||
|
async function migrateOnce(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,8 +105,6 @@ export type AppEvents = {
|
|||||||
"player.play": { episodeId: string };
|
"player.play": { episodeId: string };
|
||||||
"player.pause": { episodeId: string };
|
"player.pause": { episodeId: string };
|
||||||
"player.stop": {};
|
"player.stop": {};
|
||||||
"auth.login": { userId: string };
|
|
||||||
"auth.logout": {};
|
|
||||||
"toast.show": {
|
"toast.show": {
|
||||||
message: string;
|
message: string;
|
||||||
variant: "info" | "success" | "warning" | "error";
|
variant: "info" | "success" | "warning" | "error";
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Feeds persistence via JSON file in XDG_CONFIG_HOME
|
* Feeds & sources persistence — stored in the centralized `config.json`
|
||||||
*
|
* (see utils/config.ts). No backups; writes always overwrite.
|
||||||
* Reads and writes feeds to a JSON file instead of localStorage.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ensureConfigDir, getConfigFilePath } from "./config-dir";
|
import { loadConfig, updateConfig } from "./config";
|
||||||
import { backupConfigFile } from "./config-backup";
|
|
||||||
import type { Feed } from "../types/feed";
|
import type { Feed } from "../types/feed";
|
||||||
|
import type { PodcastSource } from "../types/source";
|
||||||
const FEEDS_FILE = "feeds.json";
|
|
||||||
const SOURCES_FILE = "sources.json";
|
|
||||||
|
|
||||||
/** Deserialize date strings back to Date objects in feed data */
|
/** Deserialize date strings back to Date objects in feed data */
|
||||||
function reviveDates(feed: Feed): Feed {
|
function reviveDates(feed: Feed): Feed {
|
||||||
@@ -27,56 +23,34 @@ function reviveDates(feed: Feed): Feed {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load feeds from JSON file */
|
/** Load feeds from config.json */
|
||||||
export async function loadFeedsFromFile(): Promise<Feed[]> {
|
export async function loadFeedsFromFile(): Promise<Feed[]> {
|
||||||
try {
|
try {
|
||||||
const filePath = getConfigFilePath(FEEDS_FILE);
|
const cfg = await loadConfig();
|
||||||
const file = Bun.file(filePath);
|
if (!Array.isArray(cfg.feeds)) return [];
|
||||||
if (!(await file.exists())) return [];
|
return cfg.feeds.map(reviveDates);
|
||||||
|
|
||||||
const raw = await file.json();
|
|
||||||
if (!Array.isArray(raw)) return [];
|
|
||||||
return raw.map(reviveDates);
|
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save feeds to JSON file */
|
/** Save feeds to config.json */
|
||||||
export async function saveFeedsToFile(feeds: Feed[]): Promise<void> {
|
export function saveFeedsToFile(feeds: Feed[]): void {
|
||||||
try {
|
updateConfig({ feeds });
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Load sources from JSON file */
|
/** Load sources from config.json */
|
||||||
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
export async function loadSourcesFromFile<T>(): Promise<T[] | null> {
|
||||||
try {
|
try {
|
||||||
const filePath = getConfigFilePath(SOURCES_FILE);
|
const cfg = await loadConfig();
|
||||||
const file = Bun.file(filePath);
|
if (!Array.isArray(cfg.sources)) return null;
|
||||||
if (!(await file.exists())) return null;
|
return cfg.sources as T[];
|
||||||
|
|
||||||
const raw = await file.json();
|
|
||||||
if (!Array.isArray(raw)) return null;
|
|
||||||
return raw as T[];
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Save sources to JSON file */
|
/** Save sources to config.json */
|
||||||
export async function saveSourcesToFile<T>(sources: T[]): Promise<void> {
|
export function saveSourcesToFile<T>(sources: T[]): void {
|
||||||
try {
|
updateConfig({ sources: sources as unknown as PodcastSource[] });
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user