phase 7: refactor app surfaces to ui layer
This commit is contained in:
parent
c56c3fa902
commit
b4f30cf072
32 changed files with 403 additions and 352 deletions
|
|
@ -3,6 +3,7 @@ import type { ReactNode } from 'react';
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
|
||||||
import { Providers } from '@/app/providers';
|
import { Providers } from '@/app/providers';
|
||||||
|
import { AppMain, AppShell } from '@/components/layout';
|
||||||
import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config';
|
import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
@ -32,9 +33,9 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||||
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
|
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
|
||||||
githubAuthEnabled={githubAuthEnabled}
|
githubAuthEnabled={githubAuthEnabled}
|
||||||
>
|
>
|
||||||
<div className="app-shell h-dvh flex flex-col bg-background overflow-hidden">
|
<AppShell>
|
||||||
<main className="flex-1 min-h-0 flex flex-col">{children}</main>
|
<AppMain>{children}</AppMain>
|
||||||
</div>
|
</AppShell>
|
||||||
<Toaster
|
<Toaster
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
style: {
|
style: {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, Suspense } from 'react';
|
import { useState, useEffect, Suspense } from 'react';
|
||||||
import { Button, Input } from '@headlessui/react';
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { getAuthClient } from '@/lib/client/auth-client';
|
import { getAuthClient } from '@/lib/client/auth-client';
|
||||||
|
|
@ -10,7 +9,7 @@ import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||||
import { showPrivacyModal } from '@/components/PrivacyModal';
|
import { showPrivacyModal } from '@/components/PrivacyModal';
|
||||||
import { GithubIcon } from '@/components/icons/Icons';
|
import { GithubIcon } from '@/components/icons/Icons';
|
||||||
import { LoadingSpinner } from '@/components/Spinner';
|
import { LoadingSpinner } from '@/components/Spinner';
|
||||||
import { buttonClass } from '@/components/ui/buttonPrimitives';
|
import { Button, Field, Input, Surface } from '@/components/ui';
|
||||||
|
|
||||||
function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) {
|
function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
@ -120,7 +119,7 @@ function SignInContent() {
|
||||||
<SessionExpiredLoader setSessionExpired={setSessionExpired} />
|
<SessionExpiredLoader setSessionExpired={setSessionExpired} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<div className="w-full max-w-md bg-surface rounded-lg shadow-elev-3 p-6">
|
<Surface elevation="3" className="w-full max-w-md p-6">
|
||||||
<h1 className="text-xl font-semibold text-foreground">
|
<h1 className="text-xl font-semibold text-foreground">
|
||||||
{sessionExpired ? 'Session Expired' : 'Connect Account'}
|
{sessionExpired ? 'Session Expired' : 'Connect Account'}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
@ -141,36 +140,32 @@ function SignInContent() {
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mt-4 p-3 bg-danger-wash border border-danger rounded-lg">
|
<div className="mt-4 p-3 bg-danger-wash border border-danger rounded-lg">
|
||||||
<p className="text-sm text-danger dark:text-danger">{error}</p>
|
<p className="text-sm text-danger">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-6 space-y-4">
|
<div className="mt-6 space-y-4">
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
<div>
|
<Field label="Email">
|
||||||
<label className="block text-sm font-medium text-foreground mb-1">Email</label>
|
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => { setEmail(e.target.value); setError(null); }}
|
onChange={(e) => { setEmail(e.target.value); setError(null); }}
|
||||||
placeholder="me@example.com"
|
placeholder="me@example.com"
|
||||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-elev-1
|
controlSize="lg"
|
||||||
focus:outline-none focus:ring-2 focus:ring-accent-line"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
{/* Password */}
|
{/* Password */}
|
||||||
<div>
|
<Field label="Password">
|
||||||
<label className="block text-sm font-medium text-foreground mb-1">Password</label>
|
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => { setPassword(e.target.value); setError(null); }}
|
onChange={(e) => { setPassword(e.target.value); setError(null); }}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-elev-1
|
controlSize="lg"
|
||||||
focus:outline-none focus:ring-2 focus:ring-accent-line"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
{/* Remember Me */}
|
{/* Remember Me */}
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
|
@ -188,7 +183,9 @@ function SignInContent() {
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isAnyLoading}
|
disabled={isAnyLoading}
|
||||||
onClick={handleSignIn}
|
onClick={handleSignIn}
|
||||||
className={buttonClass({ variant: 'primary', size: 'md', className: 'w-full' })}
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
className="w-full"
|
||||||
>
|
>
|
||||||
{loadingEmail ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Connect'}
|
{loadingEmail ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Connect'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -199,11 +196,9 @@ function SignInContent() {
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isAnyLoading}
|
disabled={isAnyLoading}
|
||||||
onClick={handleGithubSignIn}
|
onClick={handleGithubSignIn}
|
||||||
className={buttonClass({
|
variant="outline"
|
||||||
variant: 'outline',
|
size="md"
|
||||||
size: 'md',
|
className="w-full gap-2"
|
||||||
className: 'w-full flex items-center justify-center gap-2',
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
{loadingGithub ? (
|
{loadingGithub ? (
|
||||||
<LoadingSpinner className="w-4 h-4" />
|
<LoadingSpinner className="w-4 h-4" />
|
||||||
|
|
@ -222,7 +217,9 @@ function SignInContent() {
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isAnyLoading}
|
disabled={isAnyLoading}
|
||||||
onClick={handleAnonymousContinue}
|
onClick={handleAnonymousContinue}
|
||||||
className={buttonClass({ variant: 'outline', size: 'md', className: 'w-full' })}
|
variant="outline"
|
||||||
|
size="md"
|
||||||
|
className="w-full"
|
||||||
>
|
>
|
||||||
{loadingAnonymous ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Continue anonymously'}
|
{loadingAnonymous ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Continue anonymously'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -249,7 +246,7 @@ function SignInContent() {
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Surface>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Input } from '@headlessui/react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { getAuthClient } from '@/lib/client/auth-client';
|
import { getAuthClient } from '@/lib/client/auth-client';
|
||||||
|
|
@ -9,7 +8,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext
|
||||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||||
import { showPrivacyModal } from '@/components/PrivacyModal';
|
import { showPrivacyModal } from '@/components/PrivacyModal';
|
||||||
import { LoadingSpinner } from '@/components/Spinner';
|
import { LoadingSpinner } from '@/components/Spinner';
|
||||||
import { buttonClass } from '@/components/ui/buttonPrimitives';
|
import { Button, Field, IconButton, Input, Surface } from '@/components/ui';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
export default function SignUpPage() {
|
export default function SignUpPage() {
|
||||||
|
|
@ -105,7 +104,7 @@ export default function SignUpPage() {
|
||||||
if (!enableUserSignups) {
|
if (!enableUserSignups) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
||||||
<div className="w-full max-w-md bg-surface rounded-lg shadow-elev-3 p-6">
|
<Surface elevation="3" className="w-full max-w-md p-6">
|
||||||
<h1 className="text-xl font-semibold text-foreground">Sign-ups unavailable</h1>
|
<h1 className="text-xl font-semibold text-foreground">Sign-ups unavailable</h1>
|
||||||
<p className="text-sm text-soft mt-1">
|
<p className="text-sm text-soft mt-1">
|
||||||
New account sign-ups are currently disabled by the site administrator.
|
New account sign-ups are currently disabled by the site administrator.
|
||||||
|
|
@ -118,7 +117,7 @@ export default function SignUpPage() {
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Surface>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -129,53 +128,45 @@ export default function SignUpPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
<div className="min-h-screen flex items-center justify-center p-4 bg-background">
|
||||||
<div className="w-full max-w-md bg-surface rounded-lg shadow-elev-3 p-6">
|
<Surface elevation="3" className="w-full max-w-md p-6">
|
||||||
<h1 className="text-xl font-semibold text-foreground">Sign Up</h1>
|
<h1 className="text-xl font-semibold text-foreground">Sign Up</h1>
|
||||||
<p className="text-sm text-soft mt-1">Create your account to get started</p>
|
<p className="text-sm text-soft mt-1">Create your account to get started</p>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mt-4 p-3 bg-danger-wash border border-danger rounded-lg">
|
<div className="mt-4 p-3 bg-danger-wash border border-danger rounded-lg">
|
||||||
<p className="text-sm text-danger dark:text-danger">{error}</p>
|
<p className="text-sm text-danger">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-6 space-y-4">
|
<div className="mt-6 space-y-4">
|
||||||
{/* Email */}
|
{/* Email */}
|
||||||
<div>
|
<Field label="Email">
|
||||||
<label className="block text-sm font-medium text-foreground mb-1">Email</label>
|
|
||||||
<Input
|
<Input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="me@example.com"
|
placeholder="me@example.com"
|
||||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-elev-1
|
controlSize="lg"
|
||||||
focus:outline-none focus:ring-2 focus:ring-accent-line"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
{/* Password */}
|
{/* Password */}
|
||||||
<div>
|
<Field label="Password">
|
||||||
<label className="block text-sm font-medium text-foreground mb-1">Password</label>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
className="w-full rounded-lg bg-background py-2 px-3 pr-10 text-foreground shadow-elev-1
|
controlSize="lg"
|
||||||
focus:outline-none focus:ring-2 focus:ring-accent-line"
|
className="pr-10"
|
||||||
/>
|
/>
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
className={buttonClass({
|
className="absolute right-2 top-1/2 h-7 w-7 -translate-y-1/2"
|
||||||
variant: 'ghost',
|
|
||||||
size: 'icon',
|
|
||||||
className: 'absolute right-2 top-1/2 -translate-y-1/2 h-7 w-7 text-soft',
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
{showPassword ? '👁️' : '👁️🗨️'}
|
{showPassword ? '👁️' : '👁️🗨️'}
|
||||||
</button>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Password Strength */}
|
{/* Password Strength */}
|
||||||
|
|
@ -209,32 +200,32 @@ export default function SignUpPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
{/* Confirm Password */}
|
{/* Confirm Password */}
|
||||||
<div>
|
<Field label="Confirm Password">
|
||||||
<label className="block text-sm font-medium text-foreground mb-1">Confirm Password</label>
|
|
||||||
<Input
|
<Input
|
||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={passwordConfirmation}
|
value={passwordConfirmation}
|
||||||
onChange={(e) => setPasswordConfirmation(e.target.value)}
|
onChange={(e) => setPasswordConfirmation(e.target.value)}
|
||||||
placeholder="Confirm Password"
|
placeholder="Confirm Password"
|
||||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-elev-1
|
controlSize="lg"
|
||||||
focus:outline-none focus:ring-2 focus:ring-accent-line"
|
|
||||||
/>
|
/>
|
||||||
{passwordConfirmation && password && (
|
{passwordConfirmation && password && (
|
||||||
<p className={`text-xs mt-1 ${password === passwordConfirmation ? 'text-accent' : 'text-danger'}`}>
|
<p className={`text-xs mt-1 ${password === passwordConfirmation ? 'text-accent' : 'text-danger'}`}>
|
||||||
{password === passwordConfirmation ? '✓ Passwords match' : '✗ Passwords do not match'}
|
{password === passwordConfirmation ? '✓ Passwords match' : '✗ Passwords do not match'}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
{/* Sign Up Button */}
|
{/* Sign Up Button */}
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onClick={handleSignUp}
|
onClick={handleSignUp}
|
||||||
className={buttonClass({ variant: 'primary', size: 'md', className: 'w-full' })}
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
className="w-full"
|
||||||
>
|
>
|
||||||
{loading ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Create Account'}
|
{loading ? <LoadingSpinner className="w-4 h-4 mx-auto" /> : 'Create Account'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -258,7 +249,7 @@ export default function SignUpPage() {
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Surface>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Fragment, KeyboardEvent } from 'react';
|
import { Fragment, KeyboardEvent } from 'react';
|
||||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
||||||
import { buttonClass } from '@/components/ui/buttonPrimitives';
|
import { Button, dialogPanelStyles } from '@/components/ui';
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
interface ConfirmDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -62,7 +62,7 @@ export function ConfirmDialog({
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<DialogPanel role='dialog' className="w-full max-w-md transform rounded-lg bg-surface p-6 text-left align-middle shadow-elev-3 transition">
|
<DialogPanel role='dialog' className={dialogPanelStyles({ size: 'md' })}>
|
||||||
<DialogTitle
|
<DialogTitle
|
||||||
as="h3"
|
as="h3"
|
||||||
className="text-lg font-semibold leading-6 text-foreground"
|
className="text-lg font-semibold leading-6 text-foreground"
|
||||||
|
|
@ -74,27 +74,17 @@ export function ConfirmDialog({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end space-x-3">
|
<div className="mt-6 flex justify-end space-x-3">
|
||||||
<button
|
<Button variant="outline" size="sm" onClick={onClose}>
|
||||||
type="button"
|
|
||||||
className={buttonClass({
|
|
||||||
variant: 'outline',
|
|
||||||
size: 'sm',
|
|
||||||
})}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
{cancelText}
|
{cancelText}
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant={isDangerous ? 'danger' : 'primary'}
|
||||||
className={buttonClass({
|
size="sm"
|
||||||
variant: isDangerous ? 'danger' : 'primary',
|
className="text-wrap"
|
||||||
size: 'sm',
|
|
||||||
className: 'text-wrap',
|
|
||||||
})}
|
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
>
|
>
|
||||||
{confirmText}
|
{confirmText}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogPanel>
|
</DialogPanel>
|
||||||
</TransitionChild>
|
</TransitionChild>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { AppHeader } from '@/components/layout';
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
export function Header({
|
export function Header({
|
||||||
|
|
@ -9,19 +10,5 @@ export function Header({
|
||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
right?: ReactNode;
|
right?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return <AppHeader left={left} title={title} right={right} />;
|
||||||
<div className="sticky top-0 z-40 w-full border-b border-line-soft bg-surface" data-app-header>
|
|
||||||
<div className="px-2 sm:px-3 py-1 flex items-center justify-between gap-2 min-h-10">
|
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
|
||||||
{left}
|
|
||||||
{typeof title === 'string' ? (
|
|
||||||
<h1 className="text-xs md:text-sm font-semibold truncate text-foreground tracking-tight">{title}</h1>
|
|
||||||
) : (
|
|
||||||
title
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 min-w-0 justify-end">{right}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
Transition,
|
Transition,
|
||||||
TransitionChild,
|
TransitionChild,
|
||||||
Button,
|
|
||||||
} from '@headlessui/react';
|
} from '@headlessui/react';
|
||||||
import { updateAppConfig } from '@/lib/client/dexie';
|
import { updateAppConfig } from '@/lib/client/dexie';
|
||||||
|
import { Button, dialogPanelStyles } from '@/components/ui';
|
||||||
|
|
||||||
interface PrivacyModalProps {
|
interface PrivacyModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -102,7 +102,7 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<DialogPanel data-testid="privacy-modal" className="w-full max-w-md transform rounded-lg bg-surface p-6 text-left align-middle shadow-elev-3 transition">
|
<DialogPanel data-testid="privacy-modal" className={dialogPanelStyles({ size: 'md' })}>
|
||||||
<DialogTitle
|
<DialogTitle
|
||||||
as="h3"
|
as="h3"
|
||||||
className="text-lg font-semibold leading-6 text-foreground"
|
className="text-lg font-semibold leading-6 text-foreground"
|
||||||
|
|
@ -137,13 +137,9 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
data-testid="privacy-continue-button"
|
data-testid="privacy-continue-button"
|
||||||
type="button"
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
disabled={!agreed}
|
disabled={!agreed}
|
||||||
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
|
|
||||||
font-medium text-background hover:bg-secondary-accent
|
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
|
||||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
|
||||||
transform transition-transform duration-base ease-standard"
|
|
||||||
onClick={handleAccept}
|
onClick={handleAccept}
|
||||||
>
|
>
|
||||||
Continue
|
Continue
|
||||||
|
|
@ -216,7 +212,7 @@ export function showPrivacyModal(): void {
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<DialogPanel className="w-full max-w-md transform rounded-lg bg-surface p-6 text-left align-middle shadow-elev-3 transition">
|
<DialogPanel className={dialogPanelStyles({ size: 'md' })}>
|
||||||
<DialogTitle
|
<DialogTitle
|
||||||
as="h3"
|
as="h3"
|
||||||
className="text-lg font-semibold leading-6 text-foreground"
|
className="text-lg font-semibold leading-6 text-foreground"
|
||||||
|
|
@ -228,11 +224,8 @@ export function showPrivacyModal(): void {
|
||||||
|
|
||||||
<div className="mt-6 flex justify-end">
|
<div className="mt-6 flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
variant="primary"
|
||||||
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 text-sm
|
size="lg"
|
||||||
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
|
||||||
transform transition-transform duration-base ease-standard"
|
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext
|
||||||
import { useAuthSession } from '@/hooks/useAuthSession';
|
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||||
import { getAuthClient } from '@/lib/client/auth-client';
|
import { getAuthClient } from '@/lib/client/auth-client';
|
||||||
import { LoadingSpinner } from '@/components/Spinner';
|
import { LoadingSpinner } from '@/components/Spinner';
|
||||||
import { buttonClass } from '@/components/ui/buttonPrimitives';
|
import { Button } from '@/components/ui';
|
||||||
|
|
||||||
function sleep(ms: number) {
|
function sleep(ms: number) {
|
||||||
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
@ -257,17 +257,17 @@ export function AuthLoader({ children }: { children: ReactNode }) {
|
||||||
{bootstrapError ? (
|
{bootstrapError ? (
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-3">
|
||||||
<p className="text-sm text-soft text-center">{bootstrapError}</p>
|
<p className="text-sm text-soft text-center">{bootstrapError}</p>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
attemptedForNullSessionRef.current = false;
|
attemptedForNullSessionRef.current = false;
|
||||||
setBootstrapError(null);
|
setBootstrapError(null);
|
||||||
setRetryNonce((v) => v + 1);
|
setRetryNonce((v) => v + 1);
|
||||||
}}
|
}}
|
||||||
className={buttonClass({ variant: 'primary', size: 'sm' })}
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
Retry
|
Retry
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-soft animate-pulse">
|
<p className="text-sm text-soft animate-pulse">
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,10 @@ import {
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
Transition,
|
Transition,
|
||||||
TransitionChild,
|
TransitionChild,
|
||||||
Button,
|
|
||||||
} from '@headlessui/react';
|
} from '@headlessui/react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { Button, dialogPanelStyles } from '@/components/ui';
|
||||||
|
|
||||||
export type ClaimableCounts = {
|
export type ClaimableCounts = {
|
||||||
documents: number;
|
documents: number;
|
||||||
|
|
@ -99,7 +99,7 @@ export default function ClaimDataModal({
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<DialogPanel data-testid="claim-modal" className="w-full max-w-md transform rounded-lg bg-surface p-6 text-left align-middle shadow-elev-3 transition">
|
<DialogPanel data-testid="claim-modal" className={dialogPanelStyles({ size: 'md' })}>
|
||||||
<DialogTitle
|
<DialogTitle
|
||||||
as="h3"
|
as="h3"
|
||||||
className="text-lg font-semibold leading-6 text-foreground mb-4"
|
className="text-lg font-semibold leading-6 text-foreground mb-4"
|
||||||
|
|
@ -129,27 +129,19 @@ export default function ClaimDataModal({
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Button
|
<Button
|
||||||
data-testid="claim-dismiss-button"
|
data-testid="claim-dismiss-button"
|
||||||
type="button"
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
disabled={isClaiming}
|
disabled={isClaiming}
|
||||||
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
|
||||||
font-medium text-foreground hover:bg-accent-wash focus:outline-none
|
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
|
||||||
transform transition-transform duration-base ease-standard hover:text-accent
|
|
||||||
disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
Dismiss
|
Dismiss
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
data-testid="claim-submit-button"
|
data-testid="claim-submit-button"
|
||||||
type="button"
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
onClick={handleClaim}
|
onClick={handleClaim}
|
||||||
disabled={isClaiming}
|
disabled={isClaiming}
|
||||||
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
|
|
||||||
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
|
||||||
transform transition-transform duration-base ease-standard hover:text-background
|
|
||||||
disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
{isClaiming ? 'Claiming...' : 'Claim Data'}
|
{isClaiming ? 'Claiming...' : 'Claim Data'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Fragment, KeyboardEvent } from 'react';
|
import { Fragment, KeyboardEvent } from 'react';
|
||||||
import { Dialog, DialogPanel, DialogTitle, Input, Transition, TransitionChild } from '@headlessui/react';
|
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
||||||
|
import { dialogPanelStyles, Input } from '@/components/ui';
|
||||||
|
|
||||||
interface CreateFolderDialogProps {
|
interface CreateFolderDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -42,7 +43,7 @@ export function CreateFolderDialog({
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<DialogPanel className="w-full max-w-md transform rounded-lg bg-surface p-6 text-left align-middle shadow-elev-3 transition">
|
<DialogPanel className={dialogPanelStyles({ size: 'md' })}>
|
||||||
<DialogTitle as="h3" className="text-lg font-semibold text-foreground">
|
<DialogTitle as="h3" className="text-lg font-semibold text-foreground">
|
||||||
Create New Folder
|
Create New Folder
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
@ -53,7 +54,7 @@ export function CreateFolderDialog({
|
||||||
onChange={(e) => onFolderNameChange(e.target.value)}
|
onChange={(e) => onFolderNameChange(e.target.value)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
placeholder="Enter folder name"
|
placeholder="Enter folder name"
|
||||||
className="w-full rounded-lg bg-surface-sunken py-2 px-3 text-foreground shadow-elev-1 focus:outline-none focus:ring-1 focus:ring-accent-line"
|
controlSize="lg"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<p className="mt-2 text-xs text-soft">Press Enter to create or Escape to cancel</p>
|
<p className="mt-2 text-xs text-soft">Press Enter to create or Escape to cancel</p>
|
||||||
|
|
@ -65,4 +66,4 @@ export function CreateFolderDialog({
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Transition>
|
</Transition>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import type { DocumentListDocument } from '@/types/documents';
|
||||||
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||||
import { DocumentPreview } from '@/components/doclist/DocumentPreview';
|
import { DocumentPreview } from '@/components/doclist/DocumentPreview';
|
||||||
import { formatDocumentSize } from '@/components/doclist/formatSize';
|
import { formatDocumentSize } from '@/components/doclist/formatSize';
|
||||||
import { buttonClass } from '@/components/ui/buttonPrimitives';
|
import { buttonClass } from '@/components/ui';
|
||||||
import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
|
import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
|
||||||
import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes';
|
import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { Folder, SidebarFilter } from '@/types/documents';
|
||||||
import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons';
|
import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons';
|
||||||
import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons';
|
import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons';
|
||||||
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
|
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
|
||||||
|
import { IconButton, MenuItemClass, Sidebar as SidebarShell } from '@/components/ui';
|
||||||
|
|
||||||
interface FinderSidebarProps {
|
interface FinderSidebarProps {
|
||||||
filter: SidebarFilter;
|
filter: SidebarFilter;
|
||||||
|
|
@ -204,9 +205,9 @@ export function FinderSidebar({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<SidebarShell
|
||||||
style={{ '--sidebar-width': `${width}px` } as CSSProperties}
|
style={{ '--sidebar-width': `${width}px` } as CSSProperties}
|
||||||
className="relative h-full w-full md:[width:var(--sidebar-width)] bg-surface border-r border-line-soft shrink-0 flex flex-col"
|
className="relative h-full w-full md:[width:var(--sidebar-width)] rounded-none border-y-0 border-l-0 border-r border-line-soft shadow-none shrink-0 flex flex-col"
|
||||||
>
|
>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<div className="p-2 flex flex-col gap-0.5">
|
<div className="p-2 flex flex-col gap-0.5">
|
||||||
|
|
@ -272,7 +273,9 @@ export function FinderSidebar({
|
||||||
rightSlot={(
|
rightSlot={(
|
||||||
<Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
|
<Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
|
||||||
<MenuButton
|
<MenuButton
|
||||||
className="inline-flex items-center justify-center h-3.5 w-5 rounded-sm text-soft hover:text-accent transition-colors duration-base ease-standard focus:outline-none"
|
as={IconButton}
|
||||||
|
size="xs"
|
||||||
|
className="h-3.5 w-5"
|
||||||
title="Folder actions"
|
title="Folder actions"
|
||||||
aria-label="Folder actions"
|
aria-label="Folder actions"
|
||||||
>
|
>
|
||||||
|
|
@ -299,7 +302,7 @@ export function FinderSidebar({
|
||||||
onNewFolder();
|
onNewFolder();
|
||||||
onRowAction?.();
|
onRowAction?.();
|
||||||
}}
|
}}
|
||||||
className={`${active ? 'bg-surface-sunken text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
className={MenuItemClass(active)}
|
||||||
>
|
>
|
||||||
<FolderPlusIcon className="h-4 w-4" />
|
<FolderPlusIcon className="h-4 w-4" />
|
||||||
New Folder
|
New Folder
|
||||||
|
|
@ -315,7 +318,7 @@ export function FinderSidebar({
|
||||||
onRowAction?.();
|
onRowAction?.();
|
||||||
}}
|
}}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={`${disabled ? 'text-faint cursor-not-allowed' : active ? 'bg-surface-sunken text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
|
className={disabled ? 'flex w-full cursor-not-allowed items-center gap-2 rounded-md px-2 py-2 text-xs text-faint' : MenuItemClass(active)}
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
|
@ -372,6 +375,6 @@ export function FinderSidebar({
|
||||||
onPointerCancel={onResizeEnd}
|
onPointerCancel={onResizeEnd}
|
||||||
className="hidden md:block absolute top-0 right-0 h-full w-1 cursor-col-resize hover:bg-accent-wash active:bg-accent transition-colors duration-base ease-standard"
|
className="hidden md:block absolute top-0 right-0 h-full w-1 cursor-col-resize hover:bg-accent-wash active:bg-accent transition-colors duration-base ease-standard"
|
||||||
/>
|
/>
|
||||||
</aside>
|
</SidebarShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
HamburgerIcon,
|
HamburgerIcon,
|
||||||
} from './finderIcons';
|
} from './finderIcons';
|
||||||
import { ChevronUpDownIcon } from '@/components/icons/Icons';
|
import { ChevronUpDownIcon } from '@/components/icons/Icons';
|
||||||
|
import { Toolbar, ToolbarButton, ToolbarGroup, ToolbarSegment, toolbarButtonStyles } from '@/components/ui';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
interface FinderToolbarProps {
|
interface FinderToolbarProps {
|
||||||
|
|
@ -52,22 +53,6 @@ const ICON_SIZES: Array<{ value: IconSize; label: string }> = [
|
||||||
{ value: 'xl', label: 'XL' },
|
{ value: 'xl', label: 'XL' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Match SettingsModal / UserMenu trigger sizing exactly so all bar buttons share one rhythm.
|
|
||||||
const TOOLBAR_BTN =
|
|
||||||
'inline-flex items-center py-1 px-2 rounded-md border bg-surface text-xs transition duration-base ease-standard';
|
|
||||||
const TOOLBAR_BTN_INACTIVE =
|
|
||||||
'border-line text-foreground hover:text-accent hover:border-accent-line hover:bg-accent-wash';
|
|
||||||
const TOOLBAR_BTN_ACTIVE = 'border-accent-line bg-surface-sunken text-accent';
|
|
||||||
|
|
||||||
// Pill-grouped segmented control. Outer pill carries the border; inner segments are
|
|
||||||
// borderless and rely on bg/text color to show active/hover. Sized so the whole pill
|
|
||||||
// matches the height of a standalone TOOLBAR_BTN.
|
|
||||||
const PILL = 'inline-flex items-center rounded-md border border-line bg-surface p-0.5 gap-0.5 shrink-0';
|
|
||||||
const PILL_SEGMENT =
|
|
||||||
'inline-flex items-center justify-center rounded-sm text-xs transition-colors duration-base ease-standard';
|
|
||||||
const PILL_SEGMENT_INACTIVE = 'text-soft hover:bg-accent-wash hover:text-accent';
|
|
||||||
const PILL_SEGMENT_ACTIVE = 'bg-surface-sunken text-accent';
|
|
||||||
|
|
||||||
export function FinderToolbar({
|
export function FinderToolbar({
|
||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
|
|
@ -89,26 +74,25 @@ export function FinderToolbar({
|
||||||
const directionLabel = sortDirection === 'asc' ? currentSort.asc : currentSort.desc;
|
const directionLabel = sortDirection === 'asc' ? currentSort.asc : currentSort.desc;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sticky top-0 z-40 w-full border-b border-line-soft bg-surface">
|
<Toolbar>
|
||||||
<div className="px-2 sm:px-3 py-1 min-h-10 flex items-center gap-1.5 sm:gap-2">
|
|
||||||
{leftSlot && (
|
{leftSlot && (
|
||||||
<div className="shrink-0 flex items-center gap-2 pr-1 sm:pr-2 sm:border-r sm:border-line">
|
<div className="shrink-0 flex items-center gap-2 pr-1 sm:pr-2 sm:border-r sm:border-line">
|
||||||
{leftSlot}
|
{leftSlot}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<ToolbarButton
|
||||||
type="button"
|
|
||||||
onClick={onToggleSidebar}
|
onClick={onToggleSidebar}
|
||||||
className={`${TOOLBAR_BTN} ${isSidebarOpen ? TOOLBAR_BTN_ACTIVE : TOOLBAR_BTN_INACTIVE} shrink-0`}
|
active={isSidebarOpen}
|
||||||
|
className="shrink-0"
|
||||||
aria-pressed={isSidebarOpen}
|
aria-pressed={isSidebarOpen}
|
||||||
aria-label="Toggle sidebar"
|
aria-label="Toggle sidebar"
|
||||||
title="Toggle sidebar"
|
title="Toggle sidebar"
|
||||||
>
|
>
|
||||||
<HamburgerIcon className="w-4 h-4" />
|
<HamburgerIcon className="w-4 h-4" />
|
||||||
</button>
|
</ToolbarButton>
|
||||||
|
|
||||||
<div className={PILL}>
|
<ToolbarGroup>
|
||||||
{VIEW_BUTTONS.map(({ value, label, Icon }) => {
|
{VIEW_BUTTONS.map(({ value, label, Icon }) => {
|
||||||
const active = viewMode === value;
|
const active = viewMode === value;
|
||||||
const isIconsToggle = value === 'icons';
|
const isIconsToggle = value === 'icons';
|
||||||
|
|
@ -117,65 +101,52 @@ export function FinderToolbar({
|
||||||
key={value}
|
key={value}
|
||||||
className={isIconsToggle ? 'relative group/icons inline-flex items-center' : 'inline-flex items-center'}
|
className={isIconsToggle ? 'relative group/icons inline-flex items-center' : 'inline-flex items-center'}
|
||||||
>
|
>
|
||||||
<button
|
<ToolbarSegment
|
||||||
type="button"
|
|
||||||
onClick={() => onViewModeChange(value)}
|
onClick={() => onViewModeChange(value)}
|
||||||
|
active={active}
|
||||||
aria-pressed={active}
|
aria-pressed={active}
|
||||||
aria-label={`${label} view`}
|
aria-label={`${label} view`}
|
||||||
title={`${label} view`}
|
title={`${label} view`}
|
||||||
className={
|
className="w-7"
|
||||||
PILL_SEGMENT +
|
|
||||||
' h-6 w-7 ' +
|
|
||||||
(active ? PILL_SEGMENT_ACTIVE : PILL_SEGMENT_INACTIVE)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<Icon className="w-4 h-4" />
|
||||||
</button>
|
</ToolbarSegment>
|
||||||
{isIconsToggle && viewMode === 'icons' && (
|
{isIconsToggle && viewMode === 'icons' && (
|
||||||
<div
|
<div
|
||||||
className="absolute top-full left-1/2 z-30 -translate-x-1/2 pt-1 opacity-0 pointer-events-none transition-opacity duration-fast group-hover/icons:opacity-100 group-hover/icons:pointer-events-auto group-focus-within/icons:opacity-100 group-focus-within/icons:pointer-events-auto"
|
className="absolute top-full left-1/2 z-30 -translate-x-1/2 pt-1 opacity-0 pointer-events-none transition-opacity duration-fast group-hover/icons:opacity-100 group-hover/icons:pointer-events-auto group-focus-within/icons:opacity-100 group-focus-within/icons:pointer-events-auto"
|
||||||
>
|
>
|
||||||
<div className={`${PILL} shadow-elev-2`}>
|
<ToolbarGroup className="shadow-elev-2">
|
||||||
{ICON_SIZES.map(({ value: sizeValue, label: sizeLabel }) => {
|
{ICON_SIZES.map(({ value: sizeValue, label: sizeLabel }) => {
|
||||||
const sizeActive = iconSize === sizeValue;
|
const sizeActive = iconSize === sizeValue;
|
||||||
return (
|
return (
|
||||||
<button
|
<ToolbarSegment
|
||||||
key={sizeValue}
|
key={sizeValue}
|
||||||
type="button"
|
|
||||||
onClick={() => onIconSizeChange(sizeValue)}
|
onClick={() => onIconSizeChange(sizeValue)}
|
||||||
|
active={sizeActive}
|
||||||
aria-pressed={sizeActive}
|
aria-pressed={sizeActive}
|
||||||
aria-label={`Icon size ${sizeLabel}`}
|
aria-label={`Icon size ${sizeLabel}`}
|
||||||
className={
|
className="min-w-[26px] px-1.5 font-semibold tracking-wide"
|
||||||
PILL_SEGMENT +
|
|
||||||
' h-6 min-w-[26px] px-1.5 font-semibold tracking-wide ' +
|
|
||||||
(sizeActive ? PILL_SEGMENT_ACTIVE : PILL_SEGMENT_INACTIVE)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{sizeLabel}
|
{sizeLabel}
|
||||||
</button>
|
</ToolbarSegment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</ToolbarGroup>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</ToolbarGroup>
|
||||||
|
|
||||||
{showSortControls && (
|
{showSortControls && (
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<button
|
<ToolbarButton onClick={onSortDirectionToggle} className="whitespace-nowrap" title="Toggle sort direction">
|
||||||
type="button"
|
|
||||||
onClick={onSortDirectionToggle}
|
|
||||||
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} whitespace-nowrap`}
|
|
||||||
title="Toggle sort direction"
|
|
||||||
>
|
|
||||||
{directionLabel}
|
{directionLabel}
|
||||||
</button>
|
</ToolbarButton>
|
||||||
<Listbox value={sortBy} onChange={onSortByChange}>
|
<Listbox value={sortBy} onChange={onSortByChange}>
|
||||||
<ListboxButton
|
<ListboxButton
|
||||||
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} gap-1 min-w-[90px] justify-between`}
|
className={toolbarButtonStyles({ className: 'gap-1 min-w-[90px] justify-between' })}
|
||||||
>
|
>
|
||||||
<span>{currentSort.label}</span>
|
<span>{currentSort.label}</span>
|
||||||
<ChevronUpDownIcon className="h-3 w-3 opacity-60" />
|
<ChevronUpDownIcon className="h-3 w-3 opacity-60" />
|
||||||
|
|
@ -220,7 +191,6 @@ export function FinderToolbar({
|
||||||
{rightSlot}
|
{rightSlot}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Toolbar>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react';
|
import type { ReactNode } from 'react';
|
||||||
import { Fragment, useEffect, useState, type ReactNode } from 'react';
|
import { LibraryFrame, useIsNarrow } from '@/components/layout';
|
||||||
|
|
||||||
interface FinderWindowProps {
|
interface FinderWindowProps {
|
||||||
toolbar: ReactNode;
|
toolbar: ReactNode;
|
||||||
|
|
@ -14,22 +14,7 @@ interface FinderWindowProps {
|
||||||
onRequestSidebarClose?: () => void;
|
onRequestSidebarClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NARROW_QUERY = '(max-width: 767px)';
|
export { useIsNarrow };
|
||||||
|
|
||||||
export function useIsNarrow(): boolean {
|
|
||||||
const [isNarrow, setIsNarrow] = useState(() =>
|
|
||||||
typeof window !== 'undefined' ? window.matchMedia(NARROW_QUERY).matches : false,
|
|
||||||
);
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === 'undefined') return;
|
|
||||||
const mq = window.matchMedia(NARROW_QUERY);
|
|
||||||
const update = () => setIsNarrow(mq.matches);
|
|
||||||
update();
|
|
||||||
mq.addEventListener('change', update);
|
|
||||||
return () => mq.removeEventListener('change', update);
|
|
||||||
}, []);
|
|
||||||
return isNarrow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finder-style file pane: sidebar + toolbar + content + status bar.
|
* Finder-style file pane: sidebar + toolbar + content + status bar.
|
||||||
|
|
@ -43,60 +28,15 @@ export function FinderWindow({
|
||||||
sidebarOpen,
|
sidebarOpen,
|
||||||
onRequestSidebarClose,
|
onRequestSidebarClose,
|
||||||
}: FinderWindowProps) {
|
}: FinderWindowProps) {
|
||||||
const isNarrow = useIsNarrow();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full w-full bg-surface-sunken overflow-hidden">
|
<LibraryFrame
|
||||||
{toolbar}
|
toolbar={toolbar}
|
||||||
|
sidebar={sidebar}
|
||||||
<div className="flex flex-1 min-h-0">
|
statusBar={statusBar}
|
||||||
{!isNarrow && sidebarOpen && (
|
sidebarOpen={sidebarOpen}
|
||||||
<div className="h-full">{sidebar}</div>
|
onRequestSidebarClose={onRequestSidebarClose}
|
||||||
)}
|
>
|
||||||
|
{children}
|
||||||
<div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">
|
</LibraryFrame>
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{statusBar}
|
|
||||||
|
|
||||||
{/* Mobile drawer */}
|
|
||||||
<Transition show={isNarrow && sidebarOpen} as={Fragment}>
|
|
||||||
<Dialog
|
|
||||||
onClose={onRequestSidebarClose ?? (() => undefined)}
|
|
||||||
className="relative z-40 md:hidden"
|
|
||||||
>
|
|
||||||
<TransitionChild
|
|
||||||
as={Fragment}
|
|
||||||
enter="transition-opacity duration-fast"
|
|
||||||
enterFrom="opacity-0"
|
|
||||||
enterTo="opacity-100"
|
|
||||||
leave="transition-opacity duration-fast"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
|
|
||||||
</TransitionChild>
|
|
||||||
<div className="fixed inset-y-0 left-0 flex max-w-full">
|
|
||||||
<TransitionChild
|
|
||||||
as={Fragment}
|
|
||||||
enter="transition-transform duration-base ease-standard"
|
|
||||||
enterFrom="-translate-x-full"
|
|
||||||
enterTo="translate-x-0"
|
|
||||||
leave="transition-transform duration-fast ease-standard"
|
|
||||||
leaveFrom="translate-x-0"
|
|
||||||
leaveTo="-translate-x-full"
|
|
||||||
>
|
|
||||||
<DialogPanel
|
|
||||||
className="w-[80vw] max-w-[280px] h-full bg-surface shadow-elev-3"
|
|
||||||
>
|
|
||||||
{sidebar}
|
|
||||||
</DialogPanel>
|
|
||||||
</TransitionChild>
|
|
||||||
</div>
|
|
||||||
</Dialog>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Fragment, useCallback, useEffect, useState } from 'react';
|
import { Fragment, useCallback, useEffect, useState } from 'react';
|
||||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Button } from '@headlessui/react';
|
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
||||||
import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/client/dexie';
|
import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, updateAppConfig } from '@/lib/client/dexie';
|
||||||
import { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
|
import { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
|
||||||
import { useDocuments } from '@/contexts/DocumentContext';
|
import { useDocuments } from '@/contexts/DocumentContext';
|
||||||
import type { BaseDocument } from '@/types/documents';
|
import type { BaseDocument } from '@/types/documents';
|
||||||
import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents';
|
import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents';
|
||||||
|
import { Button, dialogPanelStyles } from '@/components/ui';
|
||||||
|
|
||||||
type DexieMigrationModalProps = {
|
type DexieMigrationModalProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -161,7 +162,7 @@ export function DexieMigrationModal({
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<DialogPanel data-testid="migration-modal" className="w-full max-w-md transform rounded-lg bg-surface p-6 text-left align-middle shadow-elev-3 transition">
|
<DialogPanel data-testid="migration-modal" className={dialogPanelStyles({ size: 'md' })}>
|
||||||
<DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground mb-4">
|
<DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground mb-4">
|
||||||
{title}
|
{title}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
|
|
@ -187,24 +188,18 @@ export function DexieMigrationModal({
|
||||||
<div className="flex justify-end gap-3 mt-6">
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
<Button
|
<Button
|
||||||
data-testid="migration-skip-button"
|
data-testid="migration-skip-button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={handleSkip}
|
onClick={handleSkip}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm
|
|
||||||
font-medium text-foreground hover:bg-accent-wash focus:outline-none
|
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
|
||||||
transform transition-transform duration-base ease-standard hover:text-accent
|
|
||||||
disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
Skip
|
Skip
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
onClick={handleUpload}
|
onClick={handleUpload}
|
||||||
disabled={isUploading}
|
disabled={isUploading}
|
||||||
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm
|
|
||||||
font-medium text-background hover:bg-secondary-accent focus:outline-none
|
|
||||||
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
|
|
||||||
transform transition-transform duration-base ease-standard hover:text-background
|
|
||||||
disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
{isUploading ? 'Uploading…' : 'Upload'}
|
{isUploading ? 'Uploading…' : 'Upload'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
||||||
import { Fragment, useEffect, useState } from 'react';
|
import { Fragment, useEffect, useState } from 'react';
|
||||||
import { BaseDocument } from '@/types/documents';
|
import { BaseDocument } from '@/types/documents';
|
||||||
import { buttonClass } from '@/components/ui/buttonPrimitives';
|
import { Button, dialogPanelStyles } from '@/components/ui';
|
||||||
|
|
||||||
interface DocumentSelectionModalProps {
|
interface DocumentSelectionModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -139,7 +139,7 @@ export function DocumentSelectionModal({
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95"
|
leaveTo="opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<DialogPanel className="w-full max-w-2xl transform rounded-lg bg-surface p-6 text-left align-middle shadow-elev-3 transition flex flex-col h-[80vh]">
|
<DialogPanel className={dialogPanelStyles({ size: 'lg', className: 'flex h-[80vh] flex-col' })}>
|
||||||
<DialogTitle
|
<DialogTitle
|
||||||
as="h3"
|
as="h3"
|
||||||
className="text-lg font-semibold leading-6 text-foreground mb-4 flex-shrink-0 flex justify-between items-center"
|
className="text-lg font-semibold leading-6 text-foreground mb-4 flex-shrink-0 flex justify-between items-center"
|
||||||
|
|
@ -206,21 +206,17 @@ export function DocumentSelectionModal({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex justify-end gap-3 flex-shrink-0">
|
<div className="mt-4 flex justify-end gap-3 flex-shrink-0">
|
||||||
<button
|
<Button variant="outline" size="md" onClick={onClose}>
|
||||||
type="button"
|
|
||||||
className={buttonClass({ variant: 'outline', size: 'md' })}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="primary"
|
||||||
className={buttonClass({ variant: 'primary', size: 'md' })}
|
size="md"
|
||||||
onClick={handleConfirmClick}
|
onClick={handleConfirmClick}
|
||||||
disabled={isLoading || selectedCount === 0 || isProcessing}
|
disabled={isLoading || selectedCount === 0 || isProcessing}
|
||||||
>
|
>
|
||||||
{isProcessing ? 'Processing...' : `${confirmLabel} ${selectedCount > 0 ? `(${selectedCount})` : ''}`}
|
{isProcessing ? 'Processing...' : `${confirmLabel} ${selectedCount > 0 ? `(${selectedCount})` : ''}`}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogPanel>
|
</DialogPanel>
|
||||||
</TransitionChild>
|
</TransitionChild>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { UploadIcon } from '@/components/icons/Icons';
|
||||||
import { useDocuments } from '@/contexts/DocumentContext';
|
import { useDocuments } from '@/contexts/DocumentContext';
|
||||||
import { uploadDocxAsPdf } from '@/lib/client/api/documents';
|
import { uploadDocxAsPdf } from '@/lib/client/api/documents';
|
||||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||||
|
import { dropzoneSurfaceClass } from '@/components/ui';
|
||||||
|
|
||||||
interface DocumentUploaderProps {
|
interface DocumentUploaderProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
@ -155,24 +156,7 @@ export function DocumentUploader({
|
||||||
noKeyboard: variant === 'overlay'
|
noKeyboard: variant === 'overlay'
|
||||||
});
|
});
|
||||||
|
|
||||||
const containerBase = `group w-full rounded transition duration-base ease-standard ${
|
const isDisabled = isUploading || isConverting;
|
||||||
isUploading || isConverting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
|
|
||||||
} ${className}`;
|
|
||||||
|
|
||||||
const borderBgClass =
|
|
||||||
variant === 'compact'
|
|
||||||
? `${
|
|
||||||
isDragActive
|
|
||||||
? 'border border-accent bg-surface-sunken text-accent'
|
|
||||||
: 'border border-dashed border-line text-foreground hover:border-accent hover:bg-accent-wash hover:text-accent'
|
|
||||||
}`
|
|
||||||
: `${
|
|
||||||
isDragActive
|
|
||||||
? 'border-2 border-dashed border-accent bg-surface text-foreground'
|
|
||||||
: 'border-2 border-dashed border-muted bg-transparent text-foreground hover:border-accent hover:bg-surface'
|
|
||||||
}`;
|
|
||||||
|
|
||||||
const paddingClass = variant === 'compact' ? 'py-1 px-2 rounded-md' : 'py-5 px-3 rounded-lg';
|
|
||||||
|
|
||||||
if (variant === 'overlay') {
|
if (variant === 'overlay') {
|
||||||
const rootProps = getRootProps();
|
const rootProps = getRootProps();
|
||||||
|
|
@ -183,7 +167,7 @@ export function DocumentUploader({
|
||||||
{isDragActive && (
|
{isDragActive && (
|
||||||
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-surface backdrop-blur-md pointer-events-none p-6">
|
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-surface backdrop-blur-md pointer-events-none p-6">
|
||||||
<div className="w-full h-full border-2 border-dashed border-accent rounded-lg flex flex-col items-center justify-center bg-surface-solid text-center p-4">
|
<div className="w-full h-full border-2 border-dashed border-accent rounded-lg flex flex-col items-center justify-center bg-surface-solid text-center p-4">
|
||||||
<UploadIcon className="w-14 h-14 text-accent mb-4 animate-bounce" />
|
<UploadIcon className="w-14 h-14 text-accent mb-4" />
|
||||||
<p className="text-xl font-bold text-foreground mb-1.5">
|
<p className="text-xl font-bold text-foreground mb-1.5">
|
||||||
Drop files here to upload
|
Drop files here to upload
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -212,7 +196,12 @@ export function DocumentUploader({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...getRootProps()}
|
{...getRootProps()}
|
||||||
className={`${containerBase} ${borderBgClass} ${paddingClass}`}
|
className={dropzoneSurfaceClass({
|
||||||
|
variant: variant === 'compact' ? 'compact' : 'default',
|
||||||
|
active: isDragActive,
|
||||||
|
disabled: isDisabled,
|
||||||
|
className,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<input {...getInputProps()} />
|
<input {...getInputProps()} />
|
||||||
{variant === 'compact' ? (
|
{variant === 'compact' ? (
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { buttonClass } from '@/components/ui/buttonPrimitives';
|
import { buttonClass } from '@/components/ui';
|
||||||
|
|
||||||
export function ZoomControl({
|
export function ZoomControl({
|
||||||
value,
|
value,
|
||||||
|
|
|
||||||
30
src/components/layout/AppHeader.tsx
Normal file
30
src/components/layout/AppHeader.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { cn } from '@/components/ui/cn';
|
||||||
|
|
||||||
|
export function AppHeader({
|
||||||
|
left,
|
||||||
|
title,
|
||||||
|
right,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
left?: ReactNode;
|
||||||
|
title?: ReactNode;
|
||||||
|
right?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn('sticky top-0 z-40 w-full border-b border-line-soft bg-surface', className)} data-app-header>
|
||||||
|
<div className="px-2 sm:px-3 py-1 flex items-center justify-between gap-2 min-h-10">
|
||||||
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
|
{left}
|
||||||
|
{typeof title === 'string' ? (
|
||||||
|
<h1 className="text-xs md:text-sm font-semibold truncate text-foreground tracking-tight">{title}</h1>
|
||||||
|
) : (
|
||||||
|
title
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 min-w-0 justify-end">{right}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
src/components/layout/AppShell.tsx
Normal file
14
src/components/layout/AppShell.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { cn } from '@/components/ui/cn';
|
||||||
|
|
||||||
|
export function AppShell({ children, className }: { children: ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={cn('app-shell h-dvh flex flex-col bg-background overflow-hidden', className)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppMain({ children, className }: { children: ReactNode; className?: string }) {
|
||||||
|
return <main className={cn('flex-1 min-h-0 flex flex-col', className)}>{children}</main>;
|
||||||
|
}
|
||||||
99
src/components/layout/LibraryFrame.tsx
Normal file
99
src/components/layout/LibraryFrame.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react';
|
||||||
|
import { Fragment, useEffect, useState, type ReactNode } from 'react';
|
||||||
|
import { cn } from '@/components/ui/cn';
|
||||||
|
|
||||||
|
const NARROW_QUERY = '(max-width: 767px)';
|
||||||
|
|
||||||
|
export function useIsNarrow(): boolean {
|
||||||
|
const [isNarrow, setIsNarrow] = useState(() =>
|
||||||
|
typeof window !== 'undefined' ? window.matchMedia(NARROW_QUERY).matches : false,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
const mq = window.matchMedia(NARROW_QUERY);
|
||||||
|
const update = () => setIsNarrow(mq.matches);
|
||||||
|
update();
|
||||||
|
mq.addEventListener('change', update);
|
||||||
|
return () => mq.removeEventListener('change', update);
|
||||||
|
}, []);
|
||||||
|
return isNarrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibraryFrame({
|
||||||
|
toolbar,
|
||||||
|
sidebar,
|
||||||
|
statusBar,
|
||||||
|
children,
|
||||||
|
sidebarOpen,
|
||||||
|
onRequestSidebarClose,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
toolbar: ReactNode;
|
||||||
|
sidebar: ReactNode;
|
||||||
|
statusBar: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
sidebarOpen: boolean;
|
||||||
|
onRequestSidebarClose?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const isNarrow = useIsNarrow();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex h-full w-full flex-col overflow-hidden bg-surface-sunken', className)}>
|
||||||
|
{toolbar}
|
||||||
|
<div className="flex flex-1 min-h-0">
|
||||||
|
{!isNarrow && sidebarOpen && <div className="h-full">{sidebar}</div>}
|
||||||
|
<div className="flex-1 min-w-0 min-h-0 flex flex-col overflow-hidden">{children}</div>
|
||||||
|
</div>
|
||||||
|
{statusBar}
|
||||||
|
<LibrarySidebarDrawer open={isNarrow && sidebarOpen} onClose={onRequestSidebarClose}>
|
||||||
|
{sidebar}
|
||||||
|
</LibrarySidebarDrawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LibrarySidebarDrawer({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Transition show={open} as={Fragment}>
|
||||||
|
<Dialog onClose={onClose ?? (() => undefined)} className="relative z-40 md:hidden">
|
||||||
|
<TransitionChild
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition-opacity duration-fast"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="transition-opacity duration-fast"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
|
||||||
|
</TransitionChild>
|
||||||
|
<div className="fixed inset-y-0 left-0 flex max-w-full">
|
||||||
|
<TransitionChild
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition-transform duration-base ease-standard"
|
||||||
|
enterFrom="-translate-x-full"
|
||||||
|
enterTo="translate-x-0"
|
||||||
|
leave="transition-transform duration-fast ease-standard"
|
||||||
|
leaveFrom="translate-x-0"
|
||||||
|
leaveTo="-translate-x-full"
|
||||||
|
>
|
||||||
|
<DialogPanel className="h-full w-[80vw] max-w-[280px] bg-surface shadow-elev-3">
|
||||||
|
{children}
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
3
src/components/layout/index.ts
Normal file
3
src/components/layout/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './AppHeader';
|
||||||
|
export * from './AppShell';
|
||||||
|
export * from './LibraryFrame';
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Button, Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { IconButton, Input, cn, popoverPanelClass, popoverTriggerClass } from '@/components/ui';
|
||||||
|
|
||||||
export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
|
|
@ -53,36 +54,37 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
{/* Page back */}
|
{/* Page back */}
|
||||||
<Button
|
<IconButton
|
||||||
onClick={() => skipToLocation(currentPage - 1, true)}
|
onClick={() => skipToLocation(currentPage - 1, true)}
|
||||||
disabled={currentPage <= 1}
|
disabled={currentPage <= 1}
|
||||||
className="relative p-2 rounded-full text-foreground hover:bg-accent-wash data-[hover]:bg-accent-wash data-[active]:bg-accent-wash transition duration-base focus:outline-none disabled:opacity-50 transform ease-standard hover:text-accent"
|
className="rounded-full"
|
||||||
aria-label="Previous page"
|
aria-label="Previous page"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 19l-7-7m0 0l7-7m-7 7h18" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</IconButton>
|
||||||
|
|
||||||
{/* Page number popup */}
|
{/* Page number popup */}
|
||||||
<Popover className="relative mb-1">
|
<Popover className="relative mb-1">
|
||||||
<PopoverButton
|
<PopoverButton
|
||||||
className="bg-surface-sunken px-2 py-0.5 rounded-full focus:outline-none cursor-pointer hover:bg-accent-wash transform transition-transform duration-base ease-standard hover:text-accent"
|
className={cn(popoverTriggerClass, 'rounded-full bg-surface-sunken px-2 py-0.5 text-xs')}
|
||||||
onClick={handlePopoverOpen}
|
onClick={handlePopoverOpen}
|
||||||
>
|
>
|
||||||
<p className="text-xs whitespace-nowrap">
|
<p className="text-xs whitespace-nowrap">
|
||||||
{currentPage} / {numPages || 1}
|
{currentPage} / {numPages || 1}
|
||||||
</p>
|
</p>
|
||||||
</PopoverButton>
|
</PopoverButton>
|
||||||
<PopoverPanel anchor="top" className="absolute z-50 bg-surface p-3 rounded-md shadow-elev-2 border border-line">
|
<PopoverPanel anchor="top" className={popoverPanelClass}>
|
||||||
<div className="flex flex-col space-y-2">
|
<div className="flex flex-col space-y-2">
|
||||||
<div className="text-xs font-medium text-foreground">Go to page</div>
|
<div className="text-xs font-medium text-foreground">Go to page</div>
|
||||||
<input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
pattern="[0-9]*"
|
pattern="[0-9]*"
|
||||||
className="w-20 px-2 py-1 text-xs text-accent bg-surface-sunken rounded border-none outline-none appearance-none text-center"
|
controlSize="sm"
|
||||||
|
className="w-20 appearance-none border-none text-center text-accent"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onBlur={handleInputConfirm}
|
onBlur={handleInputConfirm}
|
||||||
|
|
@ -96,16 +98,16 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
{/* Page forward */}
|
{/* Page forward */}
|
||||||
<Button
|
<IconButton
|
||||||
onClick={() => skipToLocation(currentPage + 1, true)}
|
onClick={() => skipToLocation(currentPage + 1, true)}
|
||||||
disabled={currentPage >= (numPages || 1)}
|
disabled={currentPage >= (numPages || 1)}
|
||||||
className="relative p-2 rounded-full text-foreground hover:bg-accent-wash data-[hover]:bg-accent-wash data-[active]:bg-accent-wash transition duration-base focus:outline-none disabled:opacity-50 transform ease-standard hover:text-accent"
|
className="rounded-full"
|
||||||
aria-label="Next page"
|
aria-label="Next page"
|
||||||
>
|
>
|
||||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Button } from '@headlessui/react';
|
|
||||||
import { PauseIcon } from '@/components/icons/Icons';
|
import { PauseIcon } from '@/components/icons/Icons';
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
|
import { IconButton } from '@/components/ui';
|
||||||
|
|
||||||
export function RateLimitPauseButton() {
|
export function RateLimitPauseButton() {
|
||||||
const { isPlaying, togglePlay } = useTTS();
|
const { isPlaying, togglePlay } = useTTS();
|
||||||
|
|
@ -12,14 +12,13 @@ export function RateLimitPauseButton() {
|
||||||
if (!isPlaying) return null;
|
if (!isPlaying) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<IconButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isPlaying) togglePlay();
|
if (isPlaying) togglePlay();
|
||||||
}}
|
}}
|
||||||
className="relative p-1.5 rounded-md text-foreground hover:bg-accent-wash transition duration-base focus:outline-none h-8 w-8 flex items-center justify-center transform ease-standard hover:text-accent"
|
|
||||||
aria-label="Pause"
|
aria-label="Pause"
|
||||||
>
|
>
|
||||||
<PauseIcon className="w-5 h-5" />
|
<PauseIcon className="w-5 h-5" />
|
||||||
</Button>
|
</IconButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Input, Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
||||||
import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons';
|
import { ChevronUpDownIcon, SpeedometerIcon } from '@/components/icons/Icons';
|
||||||
import { useConfig } from '@/contexts/ConfigContext';
|
import { useConfig } from '@/contexts/ConfigContext';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||||
|
import { popoverPanelClass, popoverTriggerClass, rangeInputClass } from '@/components/ui';
|
||||||
|
|
||||||
export const SpeedControl = ({
|
export const SpeedControl = ({
|
||||||
setSpeedAndRestart,
|
setSpeedAndRestart,
|
||||||
|
|
@ -88,13 +89,13 @@ export const SpeedControl = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover className="relative">
|
<Popover className="relative">
|
||||||
<PopoverButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-accent-wash rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1 transform transition-transform duration-base ease-standard hover:text-accent">
|
<PopoverButton className={`${popoverTriggerClass} space-x-0.5 px-1.5 py-0.5 text-xs sm:space-x-1 sm:px-2 sm:py-1 sm:text-sm`}>
|
||||||
<SpeedometerIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
<SpeedometerIcon className="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||||
<span className="sm:hidden">{compactTriggerLabel}</span>
|
<span className="sm:hidden">{compactTriggerLabel}</span>
|
||||||
<span className="hidden sm:inline">{triggerLabel}</span>
|
<span className="hidden sm:inline">{triggerLabel}</span>
|
||||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||||
</PopoverButton>
|
</PopoverButton>
|
||||||
<PopoverPanel anchor="top" className="absolute z-50 bg-surface p-3 rounded-md shadow-elev-2 border border-line">
|
<PopoverPanel anchor="top" className={popoverPanelClass}>
|
||||||
<div className="flex flex-col space-y-4">
|
<div className="flex flex-col space-y-4">
|
||||||
{!nativeSpeedSupported && (
|
{!nativeSpeedSupported && (
|
||||||
<div className="rounded-md border border-line bg-background px-2 py-1.5 text-[11px] text-soft">
|
<div className="rounded-md border border-line bg-background px-2 py-1.5 text-[11px] text-soft">
|
||||||
|
|
@ -112,7 +113,7 @@ export const SpeedControl = ({
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs">{max.toFixed(1)}x</span>
|
<span className="text-xs">{max.toFixed(1)}x</span>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
|
|
@ -122,7 +123,7 @@ export const SpeedControl = ({
|
||||||
onMouseUp={handleVoiceSpeedChangeComplete}
|
onMouseUp={handleVoiceSpeedChangeComplete}
|
||||||
onKeyUp={handleVoiceSpeedChangeComplete}
|
onKeyUp={handleVoiceSpeedChangeComplete}
|
||||||
onTouchEnd={handleVoiceSpeedChangeComplete}
|
onTouchEnd={handleVoiceSpeedChangeComplete}
|
||||||
className="w-full bg-surface-sunken rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-surface-sunken [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-surface-sunken [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
|
className={rangeInputClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -136,7 +137,7 @@ export const SpeedControl = ({
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs">{max.toFixed(1)}x</span>
|
<span className="text-xs">{max.toFixed(1)}x</span>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={min}
|
min={min}
|
||||||
max={max}
|
max={max}
|
||||||
|
|
@ -146,7 +147,7 @@ export const SpeedControl = ({
|
||||||
onMouseUp={handleAudioSpeedChangeComplete}
|
onMouseUp={handleAudioSpeedChangeComplete}
|
||||||
onKeyUp={handleAudioSpeedChangeComplete}
|
onKeyUp={handleAudioSpeedChangeComplete}
|
||||||
onTouchEnd={handleAudioSpeedChangeComplete}
|
onTouchEnd={handleAudioSpeedChangeComplete}
|
||||||
className="w-full bg-surface-sunken rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-surface-sunken [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-surface-sunken [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
|
className={rangeInputClass}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTTS } from '@/contexts/TTSContext';
|
import { useTTS } from '@/contexts/TTSContext';
|
||||||
import { Button } from '@headlessui/react';
|
|
||||||
import {
|
import {
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
PauseIcon,
|
PauseIcon,
|
||||||
|
|
@ -12,6 +11,7 @@ import { LoadingSpinner } from '@/components/Spinner';
|
||||||
import { VoicesControl } from '@/components/player/VoicesControl';
|
import { VoicesControl } from '@/components/player/VoicesControl';
|
||||||
import { SpeedControl } from '@/components/player/SpeedControl';
|
import { SpeedControl } from '@/components/player/SpeedControl';
|
||||||
import { Navigator } from '@/components/player/Navigator';
|
import { Navigator } from '@/components/player/Navigator';
|
||||||
|
import { IconButton } from '@/components/ui';
|
||||||
|
|
||||||
export default function TTSPlayer({ currentPage, numPages }: {
|
export default function TTSPlayer({ currentPage, numPages }: {
|
||||||
currentPage?: number;
|
currentPage?: number;
|
||||||
|
|
@ -49,32 +49,29 @@ export default function TTSPlayer({ currentPage, numPages }: {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Playback Controls */}
|
{/* Playback Controls */}
|
||||||
<Button
|
<IconButton
|
||||||
onClick={skipBackward}
|
onClick={skipBackward}
|
||||||
className="relative p-1.5 rounded-md text-foreground hover:bg-accent-wash transition duration-base focus:outline-none disabled:opacity-50 h-8 w-8 flex items-center justify-center transform ease-standard hover:text-accent"
|
|
||||||
aria-label="Skip backward"
|
aria-label="Skip backward"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
>
|
>
|
||||||
{isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon className="w-5 h-5" />}
|
{isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon className="w-5 h-5" />}
|
||||||
</Button>
|
</IconButton>
|
||||||
|
|
||||||
<Button
|
<IconButton
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
className="relative p-1.5 rounded-md text-foreground hover:bg-accent-wash transition duration-base focus:outline-none h-8 w-8 flex items-center justify-center transform ease-standard hover:text-accent"
|
|
||||||
aria-label={isPlaying ? 'Pause' : 'Play'}
|
aria-label={isPlaying ? 'Pause' : 'Play'}
|
||||||
disabled={isProcessing && !isPlaying}
|
disabled={isProcessing && !isPlaying}
|
||||||
>
|
>
|
||||||
{isPlaying ? <PauseIcon className="w-5 h-5" /> : <PlayIcon className="w-5 h-5" />}
|
{isPlaying ? <PauseIcon className="w-5 h-5" /> : <PlayIcon className="w-5 h-5" />}
|
||||||
</Button>
|
</IconButton>
|
||||||
|
|
||||||
<Button
|
<IconButton
|
||||||
onClick={skipForward}
|
onClick={skipForward}
|
||||||
className="relative p-1.5 rounded-md text-foreground hover:bg-accent-wash transition duration-base focus:outline-none disabled:opacity-50 h-8 w-8 flex items-center justify-center transform ease-standard hover:text-accent"
|
|
||||||
aria-label="Skip forward"
|
aria-label="Skip forward"
|
||||||
disabled={isProcessing}
|
disabled={isProcessing}
|
||||||
>
|
>
|
||||||
{isProcessing ? <LoadingSpinner /> : <SkipForwardIcon className="w-5 h-5" />}
|
{isProcessing ? <LoadingSpinner /> : <SkipForwardIcon className="w-5 h-5" />}
|
||||||
</Button>
|
</IconButton>
|
||||||
|
|
||||||
{/* Voice control */}
|
{/* Voice control */}
|
||||||
<VoicesControl availableVoices={availableVoices} setVoiceAndRestart={setVoiceAndRestart} />
|
<VoicesControl availableVoices={availableVoices} setVoiceAndRestart={setVoiceAndRestart} />
|
||||||
|
|
|
||||||
40
src/components/ui/dropzone.ts
Normal file
40
src/components/ui/dropzone.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { cn } from './cn';
|
||||||
|
import { variants } from './variants';
|
||||||
|
|
||||||
|
export type DropzoneVariant = 'default' | 'compact';
|
||||||
|
|
||||||
|
export const dropzoneStyles = variants({
|
||||||
|
base: 'group w-full cursor-pointer border-dashed text-foreground transition-colors duration-base ease-standard disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'rounded-lg border-2 px-3 py-5',
|
||||||
|
compact: 'rounded-md border px-2 py-1',
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
true: 'border-accent bg-surface text-accent',
|
||||||
|
false: 'border-line bg-transparent hover:border-accent hover:bg-accent-wash hover:text-accent',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
variant: 'default',
|
||||||
|
active: 'false',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function dropzoneSurfaceClass({
|
||||||
|
variant = 'default',
|
||||||
|
active = false,
|
||||||
|
disabled = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
variant?: DropzoneVariant;
|
||||||
|
active?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return dropzoneStyles({
|
||||||
|
variant,
|
||||||
|
active: active ? 'true' : 'false',
|
||||||
|
className: cn(disabled && 'cursor-not-allowed opacity-50', className),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
|
||||||
import { cn } from './cn';
|
import { cn } from './cn';
|
||||||
import { variants } from './variants';
|
import { variants } from './variants';
|
||||||
import { focusRing, motionColors } from './tokens';
|
import { focusRing, motionColors } from './tokens';
|
||||||
|
|
@ -27,21 +27,21 @@ export const iconButtonStyles = variants({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function IconButton({
|
export const IconButton = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
children: ReactNode;
|
||||||
|
tone?: IconButtonTone;
|
||||||
|
size?: IconButtonSize;
|
||||||
|
}>(function IconButton({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
tone = 'ghost',
|
tone = 'ghost',
|
||||||
size = 'md',
|
size = 'md',
|
||||||
type = 'button',
|
type = 'button',
|
||||||
...props
|
...props
|
||||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
}, ref) {
|
||||||
children: ReactNode;
|
|
||||||
tone?: IconButtonTone;
|
|
||||||
size?: IconButtonSize;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<button type={type} className={iconButtonStyles({ tone, size, className })} {...props}>
|
<button ref={ref} type={type} className={iconButtonStyles({ tone, size, className })} {...props}>
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,13 @@ export * from './button';
|
||||||
export * from './cn';
|
export * from './cn';
|
||||||
export * from './dialog';
|
export * from './dialog';
|
||||||
export * from './divider';
|
export * from './divider';
|
||||||
|
export * from './dropzone';
|
||||||
export * from './field';
|
export * from './field';
|
||||||
export * from './icon-button';
|
export * from './icon-button';
|
||||||
export * from './input';
|
export * from './input';
|
||||||
export * from './menu';
|
export * from './menu';
|
||||||
|
export * from './popover';
|
||||||
|
export * from './range';
|
||||||
export * from './section';
|
export * from './section';
|
||||||
export * from './select';
|
export * from './select';
|
||||||
export * from './sidebar';
|
export * from './sidebar';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { InputHTMLAttributes, TextareaHTMLAttributes } from 'react';
|
import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react';
|
||||||
import { cn } from './cn';
|
import { cn } from './cn';
|
||||||
import { variants } from './variants';
|
import { variants } from './variants';
|
||||||
import { motionColors } from './tokens';
|
import { motionColors } from './tokens';
|
||||||
|
|
@ -21,13 +21,13 @@ export const inputStyles = variants({
|
||||||
|
|
||||||
export const inputClass = inputStyles();
|
export const inputClass = inputStyles();
|
||||||
|
|
||||||
export function Input({
|
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement> & { controlSize?: InputControlSize }>(function Input({
|
||||||
className,
|
className,
|
||||||
controlSize = 'md',
|
controlSize = 'md',
|
||||||
...props
|
...props
|
||||||
}: InputHTMLAttributes<HTMLInputElement> & { controlSize?: InputControlSize }) {
|
}, ref) {
|
||||||
return <input className={inputStyles({ size: controlSize, className })} {...props} />;
|
return <input ref={ref} className={inputStyles({ size: controlSize, className })} {...props} />;
|
||||||
}
|
});
|
||||||
|
|
||||||
export function Textarea({
|
export function Textarea({
|
||||||
className,
|
className,
|
||||||
|
|
|
||||||
9
src/components/ui/popover.ts
Normal file
9
src/components/ui/popover.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { cn } from './cn';
|
||||||
|
|
||||||
|
export const popoverPanelClass = cn(
|
||||||
|
'z-50 rounded-md border border-line bg-surface p-3 shadow-elev-2 focus:outline-none',
|
||||||
|
);
|
||||||
|
|
||||||
|
export const popoverTriggerClass = cn(
|
||||||
|
'inline-flex items-center rounded-md text-foreground hover:bg-accent-wash hover:text-accent focus:outline-none transition-colors duration-fast ease-standard',
|
||||||
|
);
|
||||||
9
src/components/ui/range.ts
Normal file
9
src/components/ui/range.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { cn } from './cn';
|
||||||
|
|
||||||
|
export const rangeInputClass = cn(
|
||||||
|
'w-full cursor-pointer appearance-none rounded-lg bg-surface-sunken accent-accent',
|
||||||
|
'[&::-webkit-slider-runnable-track]:h-1.5 [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-runnable-track]:bg-surface-sunken',
|
||||||
|
'[&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent',
|
||||||
|
'[&::-moz-range-track]:h-1.5 [&::-moz-range-track]:rounded-lg [&::-moz-range-track]:bg-surface-sunken',
|
||||||
|
'[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-accent',
|
||||||
|
);
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import type { HTMLAttributes, ReactNode } from 'react';
|
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react';
|
||||||
import { cn } from './cn';
|
import { cn } from './cn';
|
||||||
import { variants } from './variants';
|
import { variants } from './variants';
|
||||||
import { motionColors } from './tokens';
|
import { motionColors } from './tokens';
|
||||||
|
|
@ -34,7 +34,7 @@ export function ToolbarButton({
|
||||||
children,
|
children,
|
||||||
type = 'button',
|
type = 'button',
|
||||||
...props
|
...props
|
||||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -63,7 +63,7 @@ export function ToolbarSegment({
|
||||||
children,
|
children,
|
||||||
type = 'button',
|
type = 'button',
|
||||||
...props
|
...props
|
||||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue