4, partial 5

This commit is contained in:
2026-02-04 01:00:57 -05:00
parent 7b5c256e07
commit d5ce8452e4
20 changed files with 2215 additions and 69 deletions

View File

@@ -0,0 +1,202 @@
/**
* Code validation component for PodTUI
* 8-character alphanumeric code input for sync authentication
*/
import { createSignal } from "solid-js"
import { useAuthStore } from "../stores/auth"
import { AUTH_CONFIG } from "../config/auth"
interface CodeValidationProps {
focused?: boolean
onBack?: () => void
}
type FocusField = "code" | "submit" | "back"
export function CodeValidation(props: CodeValidationProps) {
const auth = useAuthStore()
const [code, setCode] = createSignal("")
const [focusField, setFocusField] = createSignal<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 === "enter") {
if (focusField() === "submit") {
handleSubmit()
} else if (focusField() === "back" && props.onBack) {
props.onBack()
}
} else if (key.name === "escape" && props.onBack) {
props.onBack()
}
}
const codeProgress = () => {
const len = code().length
const max = AUTH_CONFIG.codeValidation.codeLength
return `${len}/${max}`
}
const codeDisplay = () => {
const current = code()
const max = AUTH_CONFIG.codeValidation.codeLength
const filled = current.split("")
const empty = Array(max - filled.length).fill("_")
return [...filled, ...empty].join(" ")
}
return (
<box
flexDirection="column"
border
padding={2}
gap={1}
onKeyPress={props.focused ? handleKeyPress : undefined}
>
<text>
<strong>Enter Sync Code</strong>
</text>
<box height={1} />
<text>
<span fg="gray">
Enter your 8-character sync code to link your account.
</span>
</text>
<text>
<span fg="gray">
You can get this code from the web portal.
</span>
</text>
<box height={1} />
{/* Code display */}
<box flexDirection="column" gap={0}>
<text>
<span fg={focusField() === "code" ? "cyan" : undefined}>
Code ({codeProgress()}):
</span>
</text>
<box border padding={1}>
<text>
<span
fg={code().length === AUTH_CONFIG.codeValidation.codeLength ? "green" : "yellow"}
>
{codeDisplay()}
</span>
</text>
</box>
{/* Hidden input for actual typing */}
<input
value={code()}
onInput={handleCodeInput}
placeholder=""
focused={props.focused && focusField() === "code"}
width={30}
/>
{codeError() && (
<text>
<span fg="red">{codeError()}</span>
</text>
)}
</box>
<box height={1} />
{/* Action buttons */}
<box flexDirection="row" gap={2}>
<box
border
padding={1}
backgroundColor={focusField() === "submit" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "submit" ? "cyan" : undefined}>
{auth.isLoading ? "Validating..." : "[Enter] Validate Code"}
</span>
</text>
</box>
<box
border
padding={1}
backgroundColor={focusField() === "back" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "back" ? "yellow" : "gray"}>
[Esc] Back to Login
</span>
</text>
</box>
</box>
{/* Auth error message */}
{auth.error && (
<text>
<span fg="red">{auth.error.message}</span>
</text>
)}
<box height={1} />
<text>
<span fg="gray">Tab to navigate, Enter to select, Esc to go back</span>
</text>
</box>
)
}

View File

@@ -0,0 +1,177 @@
/**
* Feed filter component for PodTUI
* Toggle and filter options for feed list
*/
import { createSignal } from "solid-js"
import type { FeedFilter, FeedVisibility, FeedSortField } from "../types/feed"
interface FeedFilterProps {
filter: FeedFilter
focused?: boolean
onFilterChange: (filter: FeedFilter) => void
}
type FilterField = "visibility" | "sort" | "pinned" | "search"
export function FeedFilterComponent(props: FeedFilterProps) {
const [focusField, setFocusField] = createSignal<FilterField>("visibility")
const [searchValue, setSearchValue] = createSignal(props.filter.searchQuery || "")
const fields: FilterField[] = ["visibility", "sort", "pinned", "search"]
const handleKeyPress = (key: { name: string; shift?: boolean }) => {
if (key.name === "tab") {
const currentIndex = fields.indexOf(focusField())
const nextIndex = key.shift
? (currentIndex - 1 + fields.length) % fields.length
: (currentIndex + 1) % fields.length
setFocusField(fields[nextIndex])
} else if (key.name === "return" || key.name === "enter") {
if (focusField() === "visibility") {
cycleVisibility()
} else if (focusField() === "sort") {
cycleSort()
} else if (focusField() === "pinned") {
togglePinned()
}
} else if (key.name === "space") {
if (focusField() === "pinned") {
togglePinned()
}
}
}
const cycleVisibility = () => {
const current = props.filter.visibility
let next: FeedVisibility | "all"
if (current === "all") next = "public"
else if (current === "public") next = "private"
else next = "all"
props.onFilterChange({ ...props.filter, visibility: next })
}
const cycleSort = () => {
const sortOptions: FeedSortField[] = ["updated", "title", "episodeCount", "latestEpisode"]
const currentIndex = sortOptions.indexOf(props.filter.sortBy as FeedSortField)
const nextIndex = (currentIndex + 1) % sortOptions.length
props.onFilterChange({ ...props.filter, sortBy: sortOptions[nextIndex] })
}
const togglePinned = () => {
props.onFilterChange({
...props.filter,
pinnedOnly: !props.filter.pinnedOnly,
})
}
const handleSearchInput = (value: string) => {
setSearchValue(value)
props.onFilterChange({ ...props.filter, searchQuery: value })
}
const visibilityLabel = () => {
const vis = props.filter.visibility
if (vis === "all") return "All"
if (vis === "public") return "Public"
return "Private"
}
const visibilityColor = () => {
const vis = props.filter.visibility
if (vis === "public") return "green"
if (vis === "private") return "yellow"
return "white"
}
const sortLabel = () => {
const sort = props.filter.sortBy
switch (sort) {
case "title":
return "Title"
case "episodeCount":
return "Episodes"
case "latestEpisode":
return "Latest"
case "updated":
default:
return "Updated"
}
}
return (
<box
flexDirection="column"
border
padding={1}
gap={1}
onKeyPress={props.focused ? handleKeyPress : undefined}
>
<text>
<strong>Filter Feeds</strong>
</text>
<box flexDirection="row" gap={2} flexWrap="wrap">
{/* Visibility filter */}
<box
border
padding={0}
backgroundColor={focusField() === "visibility" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "visibility" ? "cyan" : "gray"}>
Show:{" "}
</span>
<span fg={visibilityColor()}>{visibilityLabel()}</span>
</text>
</box>
{/* Sort filter */}
<box
border
padding={0}
backgroundColor={focusField() === "sort" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "sort" ? "cyan" : "gray"}>Sort: </span>
<span fg="white">{sortLabel()}</span>
</text>
</box>
{/* Pinned filter */}
<box
border
padding={0}
backgroundColor={focusField() === "pinned" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "pinned" ? "cyan" : "gray"}>
Pinned:{" "}
</span>
<span fg={props.filter.pinnedOnly ? "yellow" : "gray"}>
{props.filter.pinnedOnly ? "Yes" : "No"}
</span>
</text>
</box>
</box>
{/* Search box */}
<box flexDirection="row" gap={1}>
<text>
<span fg={focusField() === "search" ? "cyan" : "gray"}>Search:</span>
</text>
<input
value={searchValue()}
onInput={handleSearchInput}
placeholder="Filter by name..."
focused={props.focused && focusField() === "search"}
width={25}
/>
</box>
<text>
<span fg="gray">Tab to navigate, Enter/Space to toggle</span>
</text>
</box>
)
}

133
src/components/FeedItem.tsx Normal file
View File

@@ -0,0 +1,133 @@
/**
* Feed item component for PodTUI
* Displays a single feed/podcast in the list
*/
import type { Feed, FeedVisibility } from "../types/feed"
import { format } from "date-fns"
interface FeedItemProps {
feed: Feed
isSelected: boolean
showEpisodeCount?: boolean
showLastUpdated?: boolean
compact?: boolean
}
export function FeedItem(props: FeedItemProps) {
const formatDate = (date: Date): string => {
return format(date, "MMM d")
}
const episodeCount = () => props.feed.episodes.length
const unplayedCount = () => {
// This would be calculated based on episode status
return props.feed.episodes.length
}
const visibilityIcon = () => {
return props.feed.visibility === "public" ? "[P]" : "[*]"
}
const visibilityColor = () => {
return props.feed.visibility === "public" ? "green" : "yellow"
}
const pinnedIndicator = () => {
return props.feed.isPinned ? "*" : " "
}
if (props.compact) {
// Compact single-line view
return (
<box
flexDirection="row"
gap={1}
backgroundColor={props.isSelected ? "#333" : undefined}
paddingLeft={1}
paddingRight={1}
>
<text>
<span fg={props.isSelected ? "cyan" : "gray"}>
{props.isSelected ? ">" : " "}
</span>
</text>
<text>
<span fg={visibilityColor()}>{visibilityIcon()}</span>
</text>
<text>
<span fg={props.isSelected ? "white" : undefined}>
{props.feed.customName || props.feed.podcast.title}
</span>
</text>
{props.showEpisodeCount && (
<text>
<span fg="gray">({episodeCount()})</span>
</text>
)}
</box>
)
}
// Full view with details
return (
<box
flexDirection="column"
gap={0}
border={props.isSelected}
borderColor={props.isSelected ? "cyan" : undefined}
backgroundColor={props.isSelected ? "#222" : undefined}
padding={1}
>
{/* Title row */}
<box flexDirection="row" gap={1}>
<text>
<span fg={props.isSelected ? "cyan" : "gray"}>
{props.isSelected ? ">" : " "}
</span>
</text>
<text>
<span fg={visibilityColor()}>{visibilityIcon()}</span>
</text>
<text>
<span fg="yellow">{pinnedIndicator()}</span>
</text>
<text>
<span fg={props.isSelected ? "white" : undefined}>
<strong>{props.feed.customName || props.feed.podcast.title}</strong>
</span>
</text>
</box>
{/* Details row */}
<box flexDirection="row" gap={2} paddingLeft={4}>
{props.showEpisodeCount && (
<text>
<span fg="gray">
{episodeCount()} episodes ({unplayedCount()} new)
</span>
</text>
)}
{props.showLastUpdated && (
<text>
<span fg="gray">
Updated: {formatDate(props.feed.lastUpdated)}
</span>
</text>
)}
</box>
{/* Description (truncated) */}
{props.feed.podcast.description && (
<box paddingLeft={4} paddingTop={0}>
<text>
<span fg="gray">
{props.feed.podcast.description.slice(0, 60)}
{props.feed.podcast.description.length > 60 ? "..." : ""}
</span>
</text>
</box>
)}
</box>
)
}

200
src/components/FeedList.tsx Normal file
View File

@@ -0,0 +1,200 @@
/**
* Feed list component for PodTUI
* Scrollable list of feeds with keyboard navigation
*/
import { createSignal, For, Show } from "solid-js"
import { FeedItem } from "./FeedItem"
import type { Feed, FeedFilter, FeedVisibility, FeedSortField } from "../types/feed"
import { format } from "date-fns"
interface FeedListProps {
feeds: Feed[]
focused?: boolean
compact?: boolean
showEpisodeCount?: boolean
showLastUpdated?: boolean
onSelectFeed?: (feed: Feed) => void
onOpenFeed?: (feed: Feed) => void
}
export function FeedList(props: FeedListProps) {
const [selectedIndex, setSelectedIndex] = createSignal(0)
const [filter, setFilter] = createSignal<FeedFilter>({
visibility: "all",
sortBy: "updated" as FeedSortField,
sortDirection: "desc",
})
/** Get filtered and sorted feeds */
const filteredFeeds = (): Feed[] => {
let result = [...props.feeds]
// Filter by visibility
const vis = filter().visibility
if (vis && vis !== "all") {
result = result.filter((f) => f.visibility === vis)
}
// Filter by pinned only
if (filter().pinnedOnly) {
result = result.filter((f) => f.isPinned)
}
// Filter by search query
const query = filter().searchQuery?.toLowerCase()
if (query) {
result = result.filter(
(f) =>
f.podcast.title.toLowerCase().includes(query) ||
f.customName?.toLowerCase().includes(query) ||
f.podcast.description?.toLowerCase().includes(query)
)
}
// Sort feeds
const sortField = filter().sortBy
const sortDir = filter().sortDirection === "asc" ? 1 : -1
result.sort((a, b) => {
switch (sortField) {
case "title":
return (
sortDir *
(a.customName || a.podcast.title).localeCompare(
b.customName || b.podcast.title
)
)
case "episodeCount":
return sortDir * (a.episodes.length - b.episodes.length)
case "latestEpisode":
const aLatest = a.episodes[0]?.pubDate?.getTime() || 0
const bLatest = b.episodes[0]?.pubDate?.getTime() || 0
return sortDir * (aLatest - bLatest)
case "updated":
default:
return sortDir * (a.lastUpdated.getTime() - b.lastUpdated.getTime())
}
})
// Pinned feeds always first
result.sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1
if (!a.isPinned && b.isPinned) return 1
return 0
})
return result
}
const handleKeyPress = (key: { name: string }) => {
const feeds = filteredFeeds()
if (key.name === "up" || key.name === "k") {
setSelectedIndex((i) => Math.max(0, i - 1))
} else if (key.name === "down" || key.name === "j") {
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 1))
} else if (key.name === "return" || key.name === "enter") {
const feed = feeds[selectedIndex()]
if (feed && props.onOpenFeed) {
props.onOpenFeed(feed)
}
} else if (key.name === "home") {
setSelectedIndex(0)
} else if (key.name === "end") {
setSelectedIndex(feeds.length - 1)
} else if (key.name === "pageup") {
setSelectedIndex((i) => Math.max(0, i - 5))
} else if (key.name === "pagedown") {
setSelectedIndex((i) => Math.min(feeds.length - 1, i + 5))
}
// Notify selection change
const selectedFeed = feeds[selectedIndex()]
if (selectedFeed && props.onSelectFeed) {
props.onSelectFeed(selectedFeed)
}
}
const toggleVisibilityFilter = () => {
setFilter((f) => {
const current = f.visibility
let next: FeedVisibility | "all"
if (current === "all") next = "public"
else if (current === "public") next = "private"
else next = "all"
return { ...f, visibility: next }
})
}
const visibilityLabel = () => {
const vis = filter().visibility
if (vis === "all") return "All"
if (vis === "public") return "Public"
return "Private"
}
return (
<box
flexDirection="column"
gap={1}
onKeyPress={props.focused ? handleKeyPress : undefined}
>
{/* Header with filter */}
<box flexDirection="row" justifyContent="space-between" paddingBottom={1}>
<text>
<strong>My Feeds</strong>
<span fg="gray"> ({filteredFeeds().length} feeds)</span>
</text>
<box flexDirection="row" gap={2}>
<box border padding={0} onMouseDown={toggleVisibilityFilter}>
<text>
<span fg="cyan">[F] {visibilityLabel()}</span>
</text>
</box>
</box>
</box>
{/* Feed list in scrollbox */}
<Show
when={filteredFeeds().length > 0}
fallback={
<box border padding={2}>
<text>
<span fg="gray">
No feeds found. Add podcasts from the Discover or Search tabs.
</span>
</text>
</box>
}
>
<scrollbox
height={15}
focused={props.focused}
selectedIndex={selectedIndex()}
>
<For each={filteredFeeds()}>
{(feed, index) => (
<FeedItem
feed={feed}
isSelected={index() === selectedIndex()}
compact={props.compact}
showEpisodeCount={props.showEpisodeCount ?? true}
showLastUpdated={props.showLastUpdated ?? true}
/>
)}
</For>
</scrollbox>
</Show>
{/* Navigation help */}
<box paddingTop={1}>
<text>
<span fg="gray">
j/k or arrows to navigate, Enter to open, F to filter
</span>
</text>
</box>
</box>
)
}

View File

@@ -0,0 +1,203 @@
/**
* Login screen component for PodTUI
* Email/password login with links to code validation and OAuth
*/
import { createSignal } from "solid-js"
import { useAuthStore } from "../stores/auth"
import { AUTH_CONFIG } from "../config/auth"
interface LoginScreenProps {
focused?: boolean
onNavigateToCode?: () => void
onNavigateToOAuth?: () => void
}
type FocusField = "email" | "password" | "submit" | "code" | "oauth"
export function LoginScreen(props: LoginScreenProps) {
const auth = useAuthStore()
const [email, setEmail] = createSignal("")
const [password, setPassword] = createSignal("")
const [focusField, setFocusField] = createSignal<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" || key.name === "enter") {
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
padding={2}
gap={1}
onKeyPress={props.focused ? handleKeyPress : undefined}
>
<text>
<strong>Sign In</strong>
</text>
<box height={1} />
{/* Email field */}
<box flexDirection="column" gap={0}>
<text>
<span fg={focusField() === "email" ? "cyan" : undefined}>
Email:
</span>
</text>
<input
value={email()}
onInput={setEmail}
placeholder="your@email.com"
focused={props.focused && focusField() === "email"}
width={30}
/>
{emailError() && (
<text>
<span fg="red">{emailError()}</span>
</text>
)}
</box>
{/* Password field */}
<box flexDirection="column" gap={0}>
<text>
<span fg={focusField() === "password" ? "cyan" : undefined}>
Password:
</span>
</text>
<input
value={password()}
onInput={setPassword}
placeholder="********"
focused={props.focused && focusField() === "password"}
width={30}
/>
{passwordError() && (
<text>
<span fg="red">{passwordError()}</span>
</text>
)}
</box>
<box height={1} />
{/* Submit button */}
<box flexDirection="row" gap={2}>
<box
border
padding={1}
backgroundColor={focusField() === "submit" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "submit" ? "cyan" : undefined}>
{auth.isLoading ? "Signing in..." : "[Enter] Sign In"}
</span>
</text>
</box>
</box>
{/* Auth error message */}
{auth.error && (
<text>
<span fg="red">{auth.error.message}</span>
</text>
)}
<box height={1} />
{/* Alternative auth options */}
<text>
<span fg="gray">Or authenticate with:</span>
</text>
<box flexDirection="row" gap={2}>
<box
border
padding={1}
backgroundColor={focusField() === "code" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "code" ? "yellow" : "gray"}>
[C] Sync Code
</span>
</text>
</box>
<box
border
padding={1}
backgroundColor={focusField() === "oauth" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "oauth" ? "yellow" : "gray"}>
[O] OAuth Info
</span>
</text>
</box>
</box>
<box height={1} />
<text>
<span fg="gray">Tab to navigate, Enter to select</span>
</text>
</box>
)
}

View File

@@ -0,0 +1,145 @@
/**
* OAuth placeholder component for PodTUI
* Displays OAuth limitations and alternative authentication methods
*/
import { createSignal } from "solid-js"
import { OAUTH_PROVIDERS, OAUTH_LIMITATION_MESSAGE } from "../config/auth"
interface OAuthPlaceholderProps {
focused?: boolean
onBack?: () => void
onNavigateToCode?: () => void
}
type FocusField = "code" | "back"
export function OAuthPlaceholder(props: OAuthPlaceholderProps) {
const [focusField, setFocusField] = createSignal<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" || key.name === "enter") {
if (focusField() === "code" && props.onNavigateToCode) {
props.onNavigateToCode()
} else if (focusField() === "back" && props.onBack) {
props.onBack()
}
} else if (key.name === "escape" && props.onBack) {
props.onBack()
}
}
return (
<box
flexDirection="column"
border
padding={2}
gap={1}
onKeyPress={props.focused ? handleKeyPress : undefined}
>
<text>
<strong>OAuth Authentication</strong>
</text>
<box height={1} />
{/* OAuth providers list */}
<text>
<span fg="cyan">Available OAuth Providers:</span>
</text>
<box flexDirection="column" gap={0} paddingLeft={2}>
{OAUTH_PROVIDERS.map((provider) => (
<text>
<span fg={provider.enabled ? "green" : "gray"}>
{provider.enabled ? "[+]" : "[-]"} {provider.name}
</span>
<span fg="gray"> - {provider.description}</span>
</text>
))}
</box>
<box height={1} />
{/* Limitation message */}
<box border padding={1} borderColor="yellow">
<text>
<span fg="yellow">Terminal Limitations</span>
</text>
</box>
<box paddingLeft={1}>
{OAUTH_LIMITATION_MESSAGE.split("\n").map((line) => (
<text>
<span fg="gray">{line}</span>
</text>
))}
</box>
<box height={1} />
{/* Alternative options */}
<text>
<span fg="cyan">Recommended Alternatives:</span>
</text>
<box flexDirection="column" gap={0} paddingLeft={2}>
<text>
<span fg="green">[1]</span>
<span fg="white"> Use a sync code from the web portal</span>
</text>
<text>
<span fg="green">[2]</span>
<span fg="white"> Use email/password authentication</span>
</text>
<text>
<span fg="green">[3]</span>
<span fg="white"> Use file-based sync (no account needed)</span>
</text>
</box>
<box height={1} />
{/* Action buttons */}
<box flexDirection="row" gap={2}>
<box
border
padding={1}
backgroundColor={focusField() === "code" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "code" ? "cyan" : undefined}>
[C] Enter Sync Code
</span>
</text>
</box>
<box
border
padding={1}
backgroundColor={focusField() === "back" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "back" ? "yellow" : "gray"}>
[Esc] Back to Login
</span>
</text>
</box>
</box>
<box height={1} />
<text>
<span fg="gray">Tab to navigate, Enter to select, Esc to go back</span>
</text>
</box>
)
}

View File

@@ -0,0 +1,186 @@
/**
* Sync profile component for PodTUI
* Displays user profile information and sync status
*/
import { createSignal } from "solid-js"
import { useAuthStore } from "../stores/auth"
import { format } from "date-fns"
interface SyncProfileProps {
focused?: boolean
onLogout?: () => void
onManageSync?: () => void
}
type FocusField = "sync" | "export" | "logout"
export function SyncProfile(props: SyncProfileProps) {
const auth = useAuthStore()
const [focusField, setFocusField] = createSignal<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" || key.name === "enter") {
if (focusField() === "sync" && props.onManageSync) {
props.onManageSync()
} else if (focusField() === "logout" && props.onLogout) {
handleLogout()
}
}
}
const handleLogout = () => {
auth.logout()
if (props.onLogout) {
props.onLogout()
}
}
const formatDate = (date: Date | null | undefined): string => {
if (!date) return "Never"
return format(date, "MMM d, yyyy HH:mm")
}
const user = () => auth.state().user
// Get user initials for avatar
const userInitials = () => {
const name = user()?.name || "?"
return name.slice(0, 2).toUpperCase()
}
return (
<box
flexDirection="column"
border
padding={2}
gap={1}
onKeyPress={props.focused ? handleKeyPress : undefined}
>
<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>
<span fg="cyan">{userInitials()}</span>
</text>
</box>
{/* User details */}
<box flexDirection="column" gap={0}>
<text>
<span fg="white">{user()?.name || "Guest User"}</span>
</text>
<text>
<span fg="gray">{user()?.email || "No email"}</span>
</text>
<text>
<span fg="gray">
Joined: {formatDate(user()?.createdAt)}
</span>
</text>
</box>
</box>
<box height={1} />
{/* Sync status section */}
<box border padding={1} flexDirection="column" gap={0}>
<text>
<span fg="cyan">Sync Status</span>
</text>
<box flexDirection="row" gap={1}>
<text>
<span fg="gray">Status:</span>
</text>
<text>
<span fg={user()?.syncEnabled ? "green" : "yellow"}>
{user()?.syncEnabled ? "Enabled" : "Disabled"}
</span>
</text>
</box>
<box flexDirection="row" gap={1}>
<text>
<span fg="gray">Last Sync:</span>
</text>
<text>
<span fg="white">{formatDate(lastSyncTime())}</span>
</text>
</box>
<box flexDirection="row" gap={1}>
<text>
<span fg="gray">Method:</span>
</text>
<text>
<span fg="white">File-based (JSON/XML)</span>
</text>
</box>
</box>
<box height={1} />
{/* Action buttons */}
<box flexDirection="row" gap={2}>
<box
border
padding={1}
backgroundColor={focusField() === "sync" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "sync" ? "cyan" : undefined}>
[S] Manage Sync
</span>
</text>
</box>
<box
border
padding={1}
backgroundColor={focusField() === "export" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "export" ? "cyan" : undefined}>
[E] Export Data
</span>
</text>
</box>
<box
border
padding={1}
backgroundColor={focusField() === "logout" ? "#333" : undefined}
>
<text>
<span fg={focusField() === "logout" ? "red" : "gray"}>
[L] Logout
</span>
</text>
</box>
</box>
<box height={1} />
<text>
<span fg="gray">Tab to navigate, Enter to select</span>
</text>
</box>
)
}