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 { Providers } from '@/app/providers';
|
||||
import { AppMain, AppShell } from '@/components/layout';
|
||||
import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
|
@ -32,9 +33,9 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|||
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
|
||||
githubAuthEnabled={githubAuthEnabled}
|
||||
>
|
||||
<div className="app-shell h-dvh flex flex-col bg-background overflow-hidden">
|
||||
<main className="flex-1 min-h-0 flex flex-col">{children}</main>
|
||||
</div>
|
||||
<AppShell>
|
||||
<AppMain>{children}</AppMain>
|
||||
</AppShell>
|
||||
<Toaster
|
||||
toastOptions={{
|
||||
style: {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, Suspense } from 'react';
|
||||
import { Button, Input } from '@headlessui/react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { getAuthClient } from '@/lib/client/auth-client';
|
||||
|
|
@ -10,7 +9,7 @@ import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
|||
import { showPrivacyModal } from '@/components/PrivacyModal';
|
||||
import { GithubIcon } from '@/components/icons/Icons';
|
||||
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 }) {
|
||||
const searchParams = useSearchParams();
|
||||
|
|
@ -120,7 +119,7 @@ function SignInContent() {
|
|||
<SessionExpiredLoader setSessionExpired={setSessionExpired} />
|
||||
</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">
|
||||
{sessionExpired ? 'Session Expired' : 'Connect Account'}
|
||||
</h1>
|
||||
|
|
@ -141,36 +140,32 @@ function SignInContent() {
|
|||
|
||||
{error && (
|
||||
<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 className="mt-6 space-y-4">
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Email</label>
|
||||
<Field label="Email">
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => { setEmail(e.target.value); setError(null); }}
|
||||
placeholder="me@example.com"
|
||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-elev-1
|
||||
focus:outline-none focus:ring-2 focus:ring-accent-line"
|
||||
controlSize="lg"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Password</label>
|
||||
<Field label="Password">
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => { setPassword(e.target.value); setError(null); }}
|
||||
placeholder="Password"
|
||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-elev-1
|
||||
focus:outline-none focus:ring-2 focus:ring-accent-line"
|
||||
controlSize="lg"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Remember Me */}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
|
|
@ -188,7 +183,9 @@ function SignInContent() {
|
|||
type="submit"
|
||||
disabled={isAnyLoading}
|
||||
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'}
|
||||
</Button>
|
||||
|
|
@ -199,11 +196,9 @@ function SignInContent() {
|
|||
type="button"
|
||||
disabled={isAnyLoading}
|
||||
onClick={handleGithubSignIn}
|
||||
className={buttonClass({
|
||||
variant: 'outline',
|
||||
size: 'md',
|
||||
className: 'w-full flex items-center justify-center gap-2',
|
||||
})}
|
||||
variant="outline"
|
||||
size="md"
|
||||
className="w-full gap-2"
|
||||
>
|
||||
{loadingGithub ? (
|
||||
<LoadingSpinner className="w-4 h-4" />
|
||||
|
|
@ -222,7 +217,9 @@ function SignInContent() {
|
|||
type="button"
|
||||
disabled={isAnyLoading}
|
||||
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'}
|
||||
</Button>
|
||||
|
|
@ -249,7 +246,7 @@ function SignInContent() {
|
|||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button, Input } from '@headlessui/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { getAuthClient } from '@/lib/client/auth-client';
|
||||
|
|
@ -9,7 +8,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext
|
|||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||
import { showPrivacyModal } from '@/components/PrivacyModal';
|
||||
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';
|
||||
|
||||
export default function SignUpPage() {
|
||||
|
|
@ -105,7 +104,7 @@ export default function SignUpPage() {
|
|||
if (!enableUserSignups) {
|
||||
return (
|
||||
<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>
|
||||
<p className="text-sm text-soft mt-1">
|
||||
New account sign-ups are currently disabled by the site administrator.
|
||||
|
|
@ -118,7 +117,7 @@ export default function SignUpPage() {
|
|||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -129,53 +128,45 @@ export default function SignUpPage() {
|
|||
|
||||
return (
|
||||
<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>
|
||||
<p className="text-sm text-soft mt-1">Create your account to get started</p>
|
||||
|
||||
{error && (
|
||||
<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 className="mt-6 space-y-4">
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Email</label>
|
||||
<Field label="Email">
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="me@example.com"
|
||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-elev-1
|
||||
focus:outline-none focus:ring-2 focus:ring-accent-line"
|
||||
controlSize="lg"
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Password</label>
|
||||
<Field label="Password">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Password"
|
||||
className="w-full rounded-lg bg-background py-2 px-3 pr-10 text-foreground shadow-elev-1
|
||||
focus:outline-none focus:ring-2 focus:ring-accent-line"
|
||||
controlSize="lg"
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className={buttonClass({
|
||||
variant: 'ghost',
|
||||
size: 'icon',
|
||||
className: 'absolute right-2 top-1/2 -translate-y-1/2 h-7 w-7 text-soft',
|
||||
})}
|
||||
className="absolute right-2 top-1/2 h-7 w-7 -translate-y-1/2"
|
||||
>
|
||||
{showPassword ? '👁️' : '👁️🗨️'}
|
||||
</button>
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{/* Password Strength */}
|
||||
|
|
@ -209,32 +200,32 @@ export default function SignUpPage() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Confirm Password */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">Confirm Password</label>
|
||||
<Field label="Confirm Password">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={passwordConfirmation}
|
||||
onChange={(e) => setPasswordConfirmation(e.target.value)}
|
||||
placeholder="Confirm Password"
|
||||
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-elev-1
|
||||
focus:outline-none focus:ring-2 focus:ring-accent-line"
|
||||
controlSize="lg"
|
||||
/>
|
||||
{passwordConfirmation && password && (
|
||||
<p className={`text-xs mt-1 ${password === passwordConfirmation ? 'text-accent' : 'text-danger'}`}>
|
||||
{password === passwordConfirmation ? '✓ Passwords match' : '✗ Passwords do not match'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Sign Up Button */}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
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'}
|
||||
</Button>
|
||||
|
|
@ -258,7 +249,7 @@ export default function SignUpPage() {
|
|||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Fragment, KeyboardEvent } from 'react';
|
||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
||||
import { buttonClass } from '@/components/ui/buttonPrimitives';
|
||||
import { Button, dialogPanelStyles } from '@/components/ui';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -62,7 +62,7 @@ export function ConfirmDialog({
|
|||
leaveFrom="opacity-100 scale-100"
|
||||
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
|
||||
as="h3"
|
||||
className="text-lg font-semibold leading-6 text-foreground"
|
||||
|
|
@ -74,27 +74,17 @@ export function ConfirmDialog({
|
|||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass({
|
||||
variant: 'outline',
|
||||
size: 'sm',
|
||||
})}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass({
|
||||
variant: isDangerous ? 'danger' : 'primary',
|
||||
size: 'sm',
|
||||
className: 'text-wrap',
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isDangerous ? 'danger' : 'primary'}
|
||||
size="sm"
|
||||
className="text-wrap"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { AppHeader } from '@/components/layout';
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export function Header({
|
||||
|
|
@ -9,19 +10,5 @@ export function Header({
|
|||
title?: ReactNode;
|
||||
right?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
return <AppHeader left={left} title={title} right={right} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ import {
|
|||
DialogTitle,
|
||||
Transition,
|
||||
TransitionChild,
|
||||
Button,
|
||||
} from '@headlessui/react';
|
||||
import { updateAppConfig } from '@/lib/client/dexie';
|
||||
import { Button, dialogPanelStyles } from '@/components/ui';
|
||||
|
||||
interface PrivacyModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -102,7 +102,7 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
|
|||
leaveFrom="opacity-100 scale-100"
|
||||
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
|
||||
as="h3"
|
||||
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">
|
||||
<Button
|
||||
data-testid="privacy-continue-button"
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
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}
|
||||
>
|
||||
Continue
|
||||
|
|
@ -216,7 +212,7 @@ export function showPrivacyModal(): void {
|
|||
leaveFrom="opacity-100 scale-100"
|
||||
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 leading-6 text-foreground"
|
||||
|
|
@ -228,11 +224,8 @@ export function showPrivacyModal(): void {
|
|||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
className="inline-flex justify-center rounded-lg bg-accent px-4 py-2 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"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
onClick={handleClose}
|
||||
>
|
||||
Close
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext
|
|||
import { useAuthSession } from '@/hooks/useAuthSession';
|
||||
import { getAuthClient } from '@/lib/client/auth-client';
|
||||
import { LoadingSpinner } from '@/components/Spinner';
|
||||
import { buttonClass } from '@/components/ui/buttonPrimitives';
|
||||
import { Button } from '@/components/ui';
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
|
@ -257,17 +257,17 @@ export function AuthLoader({ children }: { children: ReactNode }) {
|
|||
{bootstrapError ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<p className="text-sm text-soft text-center">{bootstrapError}</p>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
onClick={() => {
|
||||
attemptedForNullSessionRef.current = false;
|
||||
setBootstrapError(null);
|
||||
setRetryNonce((v) => v + 1);
|
||||
}}
|
||||
className={buttonClass({ variant: 'primary', size: 'sm' })}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-soft animate-pulse">
|
||||
|
|
|
|||
|
|
@ -7,10 +7,10 @@ import {
|
|||
DialogTitle,
|
||||
Transition,
|
||||
TransitionChild,
|
||||
Button,
|
||||
} from '@headlessui/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import toast from 'react-hot-toast';
|
||||
import { Button, dialogPanelStyles } from '@/components/ui';
|
||||
|
||||
export type ClaimableCounts = {
|
||||
documents: number;
|
||||
|
|
@ -99,7 +99,7 @@ export default function ClaimDataModal({
|
|||
leaveFrom="opacity-100 scale-100"
|
||||
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
|
||||
as="h3"
|
||||
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">
|
||||
<Button
|
||||
data-testid="claim-dismiss-button"
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDismiss}
|
||||
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
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="claim-submit-button"
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleClaim}
|
||||
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'}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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 {
|
||||
isOpen: boolean;
|
||||
|
|
@ -42,7 +43,7 @@ export function CreateFolderDialog({
|
|||
leaveFrom="opacity-100 scale-100"
|
||||
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">
|
||||
Create New Folder
|
||||
</DialogTitle>
|
||||
|
|
@ -53,7 +54,7 @@ export function CreateFolderDialog({
|
|||
onChange={(e) => onFolderNameChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
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
|
||||
/>
|
||||
<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>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type { DocumentListDocument } from '@/types/documents';
|
|||
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
|
||||
import { DocumentPreview } from '@/components/doclist/DocumentPreview';
|
||||
import { formatDocumentSize } from '@/components/doclist/formatSize';
|
||||
import { buttonClass } from '@/components/ui/buttonPrimitives';
|
||||
import { buttonClass } from '@/components/ui';
|
||||
import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
|
||||
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 { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons';
|
||||
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
|
||||
import { IconButton, MenuItemClass, Sidebar as SidebarShell } from '@/components/ui';
|
||||
|
||||
interface FinderSidebarProps {
|
||||
filter: SidebarFilter;
|
||||
|
|
@ -204,9 +205,9 @@ export function FinderSidebar({
|
|||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
<SidebarShell
|
||||
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="p-2 flex flex-col gap-0.5">
|
||||
|
|
@ -272,7 +273,9 @@ export function FinderSidebar({
|
|||
rightSlot={(
|
||||
<Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
|
||||
<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"
|
||||
aria-label="Folder actions"
|
||||
>
|
||||
|
|
@ -299,7 +302,7 @@ export function FinderSidebar({
|
|||
onNewFolder();
|
||||
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" />
|
||||
New Folder
|
||||
|
|
@ -315,7 +318,7 @@ export function FinderSidebar({
|
|||
onRowAction?.();
|
||||
}}
|
||||
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">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
|
|
@ -372,6 +375,6 @@ export function FinderSidebar({
|
|||
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"
|
||||
/>
|
||||
</aside>
|
||||
</SidebarShell>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
HamburgerIcon,
|
||||
} from './finderIcons';
|
||||
import { ChevronUpDownIcon } from '@/components/icons/Icons';
|
||||
import { Toolbar, ToolbarButton, ToolbarGroup, ToolbarSegment, toolbarButtonStyles } from '@/components/ui';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface FinderToolbarProps {
|
||||
|
|
@ -52,22 +53,6 @@ const ICON_SIZES: Array<{ value: IconSize; label: string }> = [
|
|||
{ 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({
|
||||
viewMode,
|
||||
onViewModeChange,
|
||||
|
|
@ -89,26 +74,25 @@ export function FinderToolbar({
|
|||
const directionLabel = sortDirection === 'asc' ? currentSort.asc : currentSort.desc;
|
||||
|
||||
return (
|
||||
<div className="sticky top-0 z-40 w-full border-b border-line-soft bg-surface">
|
||||
<div className="px-2 sm:px-3 py-1 min-h-10 flex items-center gap-1.5 sm:gap-2">
|
||||
<Toolbar>
|
||||
{leftSlot && (
|
||||
<div className="shrink-0 flex items-center gap-2 pr-1 sm:pr-2 sm:border-r sm:border-line">
|
||||
{leftSlot}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
<ToolbarButton
|
||||
onClick={onToggleSidebar}
|
||||
className={`${TOOLBAR_BTN} ${isSidebarOpen ? TOOLBAR_BTN_ACTIVE : TOOLBAR_BTN_INACTIVE} shrink-0`}
|
||||
active={isSidebarOpen}
|
||||
className="shrink-0"
|
||||
aria-pressed={isSidebarOpen}
|
||||
aria-label="Toggle sidebar"
|
||||
title="Toggle sidebar"
|
||||
>
|
||||
<HamburgerIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</ToolbarButton>
|
||||
|
||||
<div className={PILL}>
|
||||
<ToolbarGroup>
|
||||
{VIEW_BUTTONS.map(({ value, label, Icon }) => {
|
||||
const active = viewMode === value;
|
||||
const isIconsToggle = value === 'icons';
|
||||
|
|
@ -117,65 +101,52 @@ export function FinderToolbar({
|
|||
key={value}
|
||||
className={isIconsToggle ? 'relative group/icons inline-flex items-center' : 'inline-flex items-center'}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
<ToolbarSegment
|
||||
onClick={() => onViewModeChange(value)}
|
||||
active={active}
|
||||
aria-pressed={active}
|
||||
aria-label={`${label} view`}
|
||||
title={`${label} view`}
|
||||
className={
|
||||
PILL_SEGMENT +
|
||||
' h-6 w-7 ' +
|
||||
(active ? PILL_SEGMENT_ACTIVE : PILL_SEGMENT_INACTIVE)
|
||||
}
|
||||
className="w-7"
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
</button>
|
||||
</ToolbarSegment>
|
||||
{isIconsToggle && viewMode === 'icons' && (
|
||||
<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"
|
||||
>
|
||||
<div className={`${PILL} shadow-elev-2`}>
|
||||
<ToolbarGroup className="shadow-elev-2">
|
||||
{ICON_SIZES.map(({ value: sizeValue, label: sizeLabel }) => {
|
||||
const sizeActive = iconSize === sizeValue;
|
||||
return (
|
||||
<button
|
||||
<ToolbarSegment
|
||||
key={sizeValue}
|
||||
type="button"
|
||||
onClick={() => onIconSizeChange(sizeValue)}
|
||||
active={sizeActive}
|
||||
aria-pressed={sizeActive}
|
||||
aria-label={`Icon size ${sizeLabel}`}
|
||||
className={
|
||||
PILL_SEGMENT +
|
||||
' h-6 min-w-[26px] px-1.5 font-semibold tracking-wide ' +
|
||||
(sizeActive ? PILL_SEGMENT_ACTIVE : PILL_SEGMENT_INACTIVE)
|
||||
}
|
||||
className="min-w-[26px] px-1.5 font-semibold tracking-wide"
|
||||
>
|
||||
{sizeLabel}
|
||||
</button>
|
||||
</ToolbarSegment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ToolbarGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ToolbarGroup>
|
||||
|
||||
{showSortControls && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSortDirectionToggle}
|
||||
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} whitespace-nowrap`}
|
||||
title="Toggle sort direction"
|
||||
>
|
||||
<ToolbarButton onClick={onSortDirectionToggle} className="whitespace-nowrap" title="Toggle sort direction">
|
||||
{directionLabel}
|
||||
</button>
|
||||
</ToolbarButton>
|
||||
<Listbox value={sortBy} onChange={onSortByChange}>
|
||||
<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>
|
||||
<ChevronUpDownIcon className="h-3 w-3 opacity-60" />
|
||||
|
|
@ -220,7 +191,6 @@ export function FinderToolbar({
|
|||
{rightSlot}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react';
|
||||
import { Fragment, useEffect, useState, type ReactNode } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { LibraryFrame, useIsNarrow } from '@/components/layout';
|
||||
|
||||
interface FinderWindowProps {
|
||||
toolbar: ReactNode;
|
||||
|
|
@ -14,22 +14,7 @@ interface FinderWindowProps {
|
|||
onRequestSidebarClose?: () => void;
|
||||
}
|
||||
|
||||
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 { useIsNarrow };
|
||||
|
||||
/**
|
||||
* Finder-style file pane: sidebar + toolbar + content + status bar.
|
||||
|
|
@ -43,60 +28,15 @@ export function FinderWindow({
|
|||
sidebarOpen,
|
||||
onRequestSidebarClose,
|
||||
}: FinderWindowProps) {
|
||||
const isNarrow = useIsNarrow();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full bg-surface-sunken overflow-hidden">
|
||||
{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}
|
||||
|
||||
{/* 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>
|
||||
<LibraryFrame
|
||||
toolbar={toolbar}
|
||||
sidebar={sidebar}
|
||||
statusBar={statusBar}
|
||||
sidebarOpen={sidebarOpen}
|
||||
onRequestSidebarClose={onRequestSidebarClose}
|
||||
>
|
||||
{children}
|
||||
</LibraryFrame>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
'use client';
|
||||
|
||||
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 { listDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents';
|
||||
import { useDocuments } from '@/contexts/DocumentContext';
|
||||
import type { BaseDocument } from '@/types/documents';
|
||||
import { cacheStoredDocumentFromBytes } from '@/lib/client/cache/documents';
|
||||
import { Button, dialogPanelStyles } from '@/components/ui';
|
||||
|
||||
type DexieMigrationModalProps = {
|
||||
isOpen: boolean;
|
||||
|
|
@ -161,7 +162,7 @@ export function DexieMigrationModal({
|
|||
leaveFrom="opacity-100 scale-100"
|
||||
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">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
|
|
@ -187,24 +188,18 @@ export function DexieMigrationModal({
|
|||
<div className="flex justify-end gap-3 mt-6">
|
||||
<Button
|
||||
data-testid="migration-skip-button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSkip}
|
||||
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
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleUpload}
|
||||
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'}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
||||
import { Fragment, useEffect, useState } from 'react';
|
||||
import { BaseDocument } from '@/types/documents';
|
||||
import { buttonClass } from '@/components/ui/buttonPrimitives';
|
||||
import { Button, dialogPanelStyles } from '@/components/ui';
|
||||
|
||||
interface DocumentSelectionModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -139,7 +139,7 @@ export function DocumentSelectionModal({
|
|||
leaveFrom="opacity-100 scale-100"
|
||||
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
|
||||
as="h3"
|
||||
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 className="mt-4 flex justify-end gap-3 flex-shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass({ variant: 'outline', size: 'md' })}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Button variant="outline" size="md" onClick={onClose}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={buttonClass({ variant: 'primary', size: 'md' })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
onClick={handleConfirmClick}
|
||||
disabled={isLoading || selectedCount === 0 || isProcessing}
|
||||
>
|
||||
{isProcessing ? 'Processing...' : `${confirmLabel} ${selectedCount > 0 ? `(${selectedCount})` : ''}`}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { UploadIcon } from '@/components/icons/Icons';
|
|||
import { useDocuments } from '@/contexts/DocumentContext';
|
||||
import { uploadDocxAsPdf } from '@/lib/client/api/documents';
|
||||
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
|
||||
import { dropzoneSurfaceClass } from '@/components/ui';
|
||||
|
||||
interface DocumentUploaderProps {
|
||||
className?: string;
|
||||
|
|
@ -155,24 +156,7 @@ export function DocumentUploader({
|
|||
noKeyboard: variant === 'overlay'
|
||||
});
|
||||
|
||||
const containerBase = `group w-full rounded transition duration-base ease-standard ${
|
||||
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';
|
||||
const isDisabled = isUploading || isConverting;
|
||||
|
||||
if (variant === 'overlay') {
|
||||
const rootProps = getRootProps();
|
||||
|
|
@ -183,7 +167,7 @@ export function DocumentUploader({
|
|||
{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="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">
|
||||
Drop files here to upload
|
||||
</p>
|
||||
|
|
@ -212,7 +196,12 @@ export function DocumentUploader({
|
|||
return (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`${containerBase} ${borderBgClass} ${paddingClass}`}
|
||||
className={dropzoneSurfaceClass({
|
||||
variant: variant === 'compact' ? 'compact' : 'default',
|
||||
active: isDragActive,
|
||||
disabled: isDisabled,
|
||||
className,
|
||||
})}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
{variant === 'compact' ? (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { buttonClass } from '@/components/ui/buttonPrimitives';
|
||||
import { buttonClass } from '@/components/ui';
|
||||
|
||||
export function ZoomControl({
|
||||
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';
|
||||
|
||||
import { Button, Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
||||
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { IconButton, Input, cn, popoverPanelClass, popoverTriggerClass } from '@/components/ui';
|
||||
|
||||
export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
||||
currentPage: number;
|
||||
|
|
@ -53,36 +54,37 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
|||
return (
|
||||
<div className="flex items-center space-x-1">
|
||||
{/* Page back */}
|
||||
<Button
|
||||
<IconButton
|
||||
onClick={() => skipToLocation(currentPage - 1, true)}
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</Button>
|
||||
</IconButton>
|
||||
|
||||
{/* Page number popup */}
|
||||
<Popover className="relative mb-1">
|
||||
<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}
|
||||
>
|
||||
<p className="text-xs whitespace-nowrap">
|
||||
{currentPage} / {numPages || 1}
|
||||
</p>
|
||||
</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="text-xs font-medium text-foreground">Go to page</div>
|
||||
<input
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
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}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputConfirm}
|
||||
|
|
@ -96,16 +98,16 @@ export const Navigator = ({ currentPage, numPages, skipToLocation }: {
|
|||
</Popover>
|
||||
|
||||
{/* Page forward */}
|
||||
<Button
|
||||
<IconButton
|
||||
onClick={() => skipToLocation(currentPage + 1, true)}
|
||||
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"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
</Button>
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
'use client';
|
||||
|
||||
import { Button } from '@headlessui/react';
|
||||
import { PauseIcon } from '@/components/icons/Icons';
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { IconButton } from '@/components/ui';
|
||||
|
||||
export function RateLimitPauseButton() {
|
||||
const { isPlaying, togglePlay } = useTTS();
|
||||
|
|
@ -12,14 +12,13 @@ export function RateLimitPauseButton() {
|
|||
if (!isPlaying) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
<PauseIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
'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 { useConfig } from '@/contexts/ConfigContext';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
|
||||
import { popoverPanelClass, popoverTriggerClass, rangeInputClass } from '@/components/ui';
|
||||
|
||||
export const SpeedControl = ({
|
||||
setSpeedAndRestart,
|
||||
|
|
@ -88,13 +89,13 @@ export const SpeedControl = ({
|
|||
|
||||
return (
|
||||
<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" />
|
||||
<span className="sm:hidden">{compactTriggerLabel}</span>
|
||||
<span className="hidden sm:inline">{triggerLabel}</span>
|
||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||
</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">
|
||||
{!nativeSpeedSupported && (
|
||||
<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 className="text-xs">{max.toFixed(1)}x</span>
|
||||
</div>
|
||||
<Input
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
|
|
@ -122,7 +123,7 @@ export const SpeedControl = ({
|
|||
onMouseUp={handleVoiceSpeedChangeComplete}
|
||||
onKeyUp={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>
|
||||
)}
|
||||
|
|
@ -136,7 +137,7 @@ export const SpeedControl = ({
|
|||
</span>
|
||||
<span className="text-xs">{max.toFixed(1)}x</span>
|
||||
</div>
|
||||
<Input
|
||||
<input
|
||||
type="range"
|
||||
min={min}
|
||||
max={max}
|
||||
|
|
@ -146,7 +147,7 @@ export const SpeedControl = ({
|
|||
onMouseUp={handleAudioSpeedChangeComplete}
|
||||
onKeyUp={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>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useTTS } from '@/contexts/TTSContext';
|
||||
import { Button } from '@headlessui/react';
|
||||
import {
|
||||
PlayIcon,
|
||||
PauseIcon,
|
||||
|
|
@ -12,6 +11,7 @@ import { LoadingSpinner } from '@/components/Spinner';
|
|||
import { VoicesControl } from '@/components/player/VoicesControl';
|
||||
import { SpeedControl } from '@/components/player/SpeedControl';
|
||||
import { Navigator } from '@/components/player/Navigator';
|
||||
import { IconButton } from '@/components/ui';
|
||||
|
||||
export default function TTSPlayer({ currentPage, numPages }: {
|
||||
currentPage?: number;
|
||||
|
|
@ -49,32 +49,29 @@ export default function TTSPlayer({ currentPage, numPages }: {
|
|||
)}
|
||||
|
||||
{/* Playback Controls */}
|
||||
<Button
|
||||
<IconButton
|
||||
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"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? <LoadingSpinner /> : <SkipBackwardIcon className="w-5 h-5" />}
|
||||
</Button>
|
||||
</IconButton>
|
||||
|
||||
<Button
|
||||
<IconButton
|
||||
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'}
|
||||
disabled={isProcessing && !isPlaying}
|
||||
>
|
||||
{isPlaying ? <PauseIcon className="w-5 h-5" /> : <PlayIcon className="w-5 h-5" />}
|
||||
</Button>
|
||||
</IconButton>
|
||||
|
||||
<Button
|
||||
<IconButton
|
||||
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"
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isProcessing ? <LoadingSpinner /> : <SkipForwardIcon className="w-5 h-5" />}
|
||||
</Button>
|
||||
</IconButton>
|
||||
|
||||
{/* Voice control */}
|
||||
<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 { variants } from './variants';
|
||||
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,
|
||||
children,
|
||||
tone = 'ghost',
|
||||
size = 'md',
|
||||
type = 'button',
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
children: ReactNode;
|
||||
tone?: IconButtonTone;
|
||||
size?: IconButtonSize;
|
||||
}) {
|
||||
}, ref) {
|
||||
return (
|
||||
<button type={type} className={iconButtonStyles({ tone, size, className })} {...props}>
|
||||
<button ref={ref} type={type} className={iconButtonStyles({ tone, size, className })} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@ export * from './button';
|
|||
export * from './cn';
|
||||
export * from './dialog';
|
||||
export * from './divider';
|
||||
export * from './dropzone';
|
||||
export * from './field';
|
||||
export * from './icon-button';
|
||||
export * from './input';
|
||||
export * from './menu';
|
||||
export * from './popover';
|
||||
export * from './range';
|
||||
export * from './section';
|
||||
export * from './select';
|
||||
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 { variants } from './variants';
|
||||
import { motionColors } from './tokens';
|
||||
|
|
@ -21,13 +21,13 @@ export const inputStyles = variants({
|
|||
|
||||
export const inputClass = inputStyles();
|
||||
|
||||
export function Input({
|
||||
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement> & { controlSize?: InputControlSize }>(function Input({
|
||||
className,
|
||||
controlSize = 'md',
|
||||
...props
|
||||
}: InputHTMLAttributes<HTMLInputElement> & { controlSize?: InputControlSize }) {
|
||||
return <input className={inputStyles({ size: controlSize, className })} {...props} />;
|
||||
}
|
||||
}, ref) {
|
||||
return <input ref={ref} className={inputStyles({ size: controlSize, className })} {...props} />;
|
||||
});
|
||||
|
||||
export function Textarea({
|
||||
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 { variants } from './variants';
|
||||
import { motionColors } from './tokens';
|
||||
|
|
@ -34,7 +34,7 @@ export function ToolbarButton({
|
|||
children,
|
||||
type = 'button',
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
active?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
|
|
@ -63,7 +63,7 @@ export function ToolbarSegment({
|
|||
children,
|
||||
type = 'button',
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
active?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue