Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
12 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions frontend/src/components/AddAccountModal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { CopyIcon } from '@/components/motion/CopyIcon'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import gsap from 'gsap'
import { duration, ease, gsap } from '@/lib/motion'
import { Button, Input, Modal, NumberField, Skeleton, TextArea } from '@heroui/react'
import { ArrowSquareOut, CaretLeft, CaretRight, Check, CheckCircle, Copy, FileCode, Key, ShieldCheck, X } from '@phosphor-icons/react'
import { ArrowSquareOut, CaretLeft, CaretRight, CheckCircle, FileCode, Key, ShieldCheck, X } from '@phosphor-icons/react'
import { copyText } from '@/lib/clipboard'
import { BrandMark } from '@/components/BrandMark'
import { ProviderMark } from '@/components/ProviderMark'
Expand Down Expand Up @@ -155,6 +156,8 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
const [callbackUrl, setCallbackUrl] = useState('')
const [codexLoginMethod, setCodexLoginMethod] = useState<'cli' | 'chatgpt'>('cli')
const stepBody = useRef<HTMLDivElement>(null)
const stepDesc = useRef<HTMLParagraphElement>(null)
const prevStep = useRef<Step | null>(null)
const createdId = useRef<string>('')
const pollTimer = useRef<number | null>(null)
const fileInput = useRef<HTMLInputElement>(null)
Expand Down Expand Up @@ -278,20 +281,34 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {

useLayoutEffect(() => {
const body = stepBody.current
if (!isOpen) prevStep.current = null
if (!body || !isOpen) return
const context = gsap.context(() => {
const media = gsap.matchMedia()
media.add('(prefers-reduced-motion: reduce)', () => {
gsap.set(body, { autoAlpha: 1, y: 0 })
gsap.set(body, { autoAlpha: 1, x: 0, y: 0 })
})
media.add('(prefers-reduced-motion: no-preference)', () => {
// Steps slide in from the side they come from (method → login enters
// from the right, back enters from the left); the header line rises in
// with them, after arc's dialog copy swap.
const previous = prevStep.current
const direction = previous && previous !== step ? (step === 'login' ? 1 : -1) : 0
gsap.fromTo(
body,
{ autoAlpha: 0, y: 12 },
{ autoAlpha: 1, y: 0, duration: 0.34, ease: 'power3.out', overwrite: true },
{ autoAlpha: 0, x: 20 * direction, y: direction ? 0 : 12 },
{ autoAlpha: 1, x: 0, y: 0, duration: duration.slow, ease: ease.out, overwrite: true },
)
if (direction && stepDesc.current) {
gsap.fromTo(
stepDesc.current,
{ autoAlpha: 0, y: 6 },
{ autoAlpha: 1, y: 0, duration: duration.base, ease: ease.out, overwrite: true },
)
}
})
}, body)
prevStep.current = step
return () => context.revert()
}, [isOpen, step])

Expand Down Expand Up @@ -518,7 +535,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
<Modal.Header className="relative items-center justify-center px-12 pt-5 text-center">
<div className="min-w-0">
<Modal.Heading className="text-lg font-semibold tracking-[-0.01em]">{t('addAccountTitle')}</Modal.Heading>
<p className="mt-1 text-xs font-normal leading-5 text-muted">
<p ref={stepDesc} className="mt-1 text-xs font-normal leading-5 text-muted">
{step === 'method' ? t('addAccountDesc') : (hint || t('addAccountDesc'))}
</p>
</div>
Expand Down Expand Up @@ -734,7 +751,7 @@ export function AddAccountModal({ isOpen, onClose, onAdded }: Props) {
})
}}
>
{authCopied ? <Check size={14} className="text-success" /> : <Copy size={14} />}
<CopyIcon copied={authCopied} />
{authCopied ? t('copied') : t('copy')}
</Button>
</div>
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/components/EndpointList.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { CopyIcon } from '@/components/motion/CopyIcon'
import { copyText } from '@/lib/clipboard'
import { useMemo, useState } from 'react'
import { Button, Card, Chip } from '@heroui/react'
import { ArrowSquareOut, BracketsCurly, Check, Copy, Heartbeat, ListBullets, PaperPlaneTilt } from '@phosphor-icons/react'
import { ArrowSquareOut, BracketsCurly, Heartbeat, ListBullets, PaperPlaneTilt } from '@phosphor-icons/react'
import type { Overview } from '@/api/types'
import { useI18n } from '@/hooks/useI18n'
import { absUrl } from '@/lib/url'
Expand Down Expand Up @@ -52,7 +53,7 @@ export function EndpointList({ access }: EndpointListProps) {
<span className="text-[10px] font-medium text-muted">{item.method === 'BASE' ? t('endpointBaseLabel') : t('endpointAuthLabel')}</span>
<div className="flex gap-1">
<Button isIconOnly size="sm" variant="ghost" aria-label={t('copy')} onPress={() => { void copyText(item.url); setCopiedEndpoint(item.name); window.setTimeout(() => setCopiedEndpoint(''), 1100) }}>
{copiedEndpoint === item.name ? <Check size={14} className="text-success" /> : <Copy size={14} />}
<CopyIcon copied={copiedEndpoint === item.name} />
</Button>
<Button isIconOnly size="sm" variant="ghost" aria-label={t('open')} onPress={() => window.open(item.url, '_blank', 'noopener,noreferrer')}>
<ArrowSquareOut size={14} />
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/account/AccountCard.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { CopyIcon } from '@/components/motion/CopyIcon'
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Button, Card, Chip, Dropdown, Input, Label, TextArea, TextField, Tooltip } from '@heroui/react'
import {
ArrowClockwise,
ArrowSquareOut,
CalendarCheck,
Check,
Copy,
Cube,
DotsThreeVertical,
Expand Down Expand Up @@ -345,7 +345,7 @@ export function AccountCard({
})
}}
>
{authCopied ? <Check size={14} className="text-success" /> : <Copy size={14} />}
<CopyIcon copied={authCopied} />
{authCopied ? t('copied') : t('copy')}
</Button>
</div>
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/components/account/QuotaMeter.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useRef } from 'react'
import { useFillReveal } from '@/hooks/useFillReveal'
import { Meter, Tooltip } from '@heroui/react'
import type { AccountQuota, AccountQuotaWindow } from '@/api/types'
import {
Expand Down Expand Up @@ -105,11 +107,13 @@ export function QuotaMeter({ quota, t, label, usedLabel, remainingLabel, addOnLa
const ratio = quotaUsedRatio(quota)
const tone = quotaTone(quota)
const color = tone === 'danger' ? 'danger' : tone === 'warn' ? 'warning' : 'success'
const rootRef = useRef<HTMLDivElement>(null)
useFillReveal(rootRef)
const valueLabel = `${usedLabel} ${used} ${unit} · ${remainingLabel} ${remaining} ${unit}${extra.length ? ` · ${extra.join(' · ')}` : ''}`

if (windows.length > 0) {
return (
<div className="account-quota-windows flex w-full min-w-0 flex-col gap-3">
<div ref={rootRef} className="account-quota-windows flex w-full min-w-0 flex-col gap-3">
{plan ? (
<div className="flex items-center justify-between gap-3 text-[11px]">
<span className="text-muted">{t('quotaPlan')}</span>
Expand All @@ -132,7 +136,7 @@ export function QuotaMeter({ quota, t, label, usedLabel, remainingLabel, addOnLa
return (
<Tooltip>
<Tooltip.Trigger>
<div className="w-full cursor-help">
<div ref={rootRef} className="w-full cursor-help">
<Meter
className="account-meter w-full"
color={color}
Expand Down
8 changes: 4 additions & 4 deletions frontend/src/components/account/RuntimeMeter.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useLayoutEffect, useRef } from 'react'
import gsap from 'gsap'
import { duration, ease, gsap } from '@/lib/motion'
import { runtimeSegments, runtimeTone, type AccountState } from '@/lib/account'

type Props = {
Expand Down Expand Up @@ -33,12 +33,12 @@ export function RuntimeMeter({ state, stateCopy, t }: Props) {

media.add('(prefers-reduced-motion: no-preference)', () => {
gsap.set(segments, { transformOrigin: 'center bottom' })
gsap.to(empty, { scaleY: 0.32, autoAlpha: 0.45, duration: 0.18, ease: 'power2.out', overwrite: true })
gsap.to(empty, { scaleY: 0.32, autoAlpha: 0.45, duration: duration.fast, ease: ease.out, overwrite: true })
gsap.to(filled, {
scaleY: 1,
autoAlpha: 1,
duration: 0.22,
ease: 'power2.out',
duration: duration.base,
ease: ease.out,
stagger: { amount: 0.08, from: 'start' },
overwrite: true,
})
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { AppHeader } from './AppHeader'
import { AppSidebar } from './AppSidebar'
import { useGsapReveal } from '@/hooks/useGsapReveal'
import { useI18n } from '@/hooks/useI18n'
import { Toaster } from '@/components/motion/Toaster'

const routeKey: Record<string, string> = {
'/': 'home',
Expand Down Expand Up @@ -44,6 +45,7 @@ export function AppLayout() {
</main>
</div>
</div>
<Toaster />
</div>
)
}
34 changes: 34 additions & 0 deletions frontend/src/components/motion/CopyIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useLayoutEffect, useRef } from 'react'
import { Check, Copy } from '@phosphor-icons/react'
import { duration, ease, gsap, prefersReducedMotion } from '@/lib/motion'

// Copy → check feedback after arc's copy-button: the old glyph shrinks into a
// blur while the new one grows out of it, and the same motion plays back.
export function CopyIcon({ copied, size = 14, className }: { copied: boolean; size?: number; className?: string }) {
const copyRef = useRef<HTMLSpanElement>(null)
const checkRef = useRef<HTMLSpanElement>(null)
const first = useRef(true)

useLayoutEffect(() => {
const show = copied ? checkRef.current : copyRef.current
const hide = copied ? copyRef.current : checkRef.current
if (!show || !hide) return
const shown = { autoAlpha: 1, scale: 1, filter: 'blur(0px)' }
const hidden = { autoAlpha: 0, scale: 0.5, filter: 'blur(4px)' }
if (first.current || prefersReducedMotion()) {
first.current = false
gsap.set(show, shown)
gsap.set(hide, hidden)
return
}
gsap.to(hide, { ...hidden, duration: duration.fast, ease: ease.out, overwrite: true })
gsap.fromTo(show, hidden, { ...shown, duration: duration.base, ease: ease.soft, delay: duration.instant / 2, overwrite: true })
}, [copied])

return (
<span className={`relative inline-grid place-items-center ${className ?? ''}`} style={{ width: size, height: size }}>
<span ref={copyRef} className="col-start-1 row-start-1 inline-flex"><Copy size={size} /></span>
<span ref={checkRef} className="col-start-1 row-start-1 inline-flex text-success"><Check size={size} /></span>
</span>
)
}
76 changes: 76 additions & 0 deletions frontend/src/components/motion/HoldToConfirm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useEffect, useRef, type ReactNode } from 'react'
import { Button, Tooltip } from '@heroui/react'
import { duration, ease, gsap, prefersReducedMotion } from '@/lib/motion'

const HOLD_SECONDS = 1.1

// Icon button that commits only after it is held, after arc's hold-to-confirm:
// a fill tracks the hold linearly, letting go early rewinds it, finishing fires
// onConfirm. Pointer and keyboard (Space / Enter) holds both work via HeroUI's
// press events. Use for single, on-screen items only; batch or irreversible
// actions keep a confirmation dialog.
export function HoldToConfirm({
label,
onConfirm,
isPending,
children,
}: {
label: string
onConfirm: () => void
isPending?: boolean
children: ReactNode
}) {
const fillRef = useRef<HTMLSpanElement>(null)
const tweenRef = useRef<gsap.core.Tween | null>(null)

useEffect(() => () => { tweenRef.current?.kill() }, [])

const start = () => {
const fill = fillRef.current
if (!fill || isPending) return
tweenRef.current?.kill()
tweenRef.current = gsap.to(fill, {
scaleX: 1,
duration: HOLD_SECONDS * (1 - Number(gsap.getProperty(fill, 'scaleX'))),
ease: ease.linear,
onComplete: () => {
tweenRef.current = null
gsap.to(fill, { scaleX: 0, duration: prefersReducedMotion() ? 0 : duration.base, ease: ease.out, delay: duration.fast })
onConfirm()
},
})
}

const cancel = () => {
const fill = fillRef.current
if (!fill || !tweenRef.current) return
tweenRef.current.kill()
tweenRef.current = null
gsap.to(fill, { scaleX: 0, duration: prefersReducedMotion() ? 0 : duration.base, ease: ease.soft })
}

return (
<Tooltip delay={300}>
<Tooltip.Trigger>
<Button
isIconOnly
size="sm"
variant="ghost"
aria-label={label}
isPending={isPending}
className="relative overflow-hidden"
onPressStart={start}
onPressEnd={cancel}
>
<span
ref={fillRef}
aria-hidden
className="pointer-events-none absolute inset-0 origin-left scale-x-0 bg-danger/20"
/>
<span className="relative inline-flex">{children}</span>
</Button>
</Tooltip.Trigger>
<Tooltip.Content>{label}</Tooltip.Content>
</Tooltip>
)
}
86 changes: 86 additions & 0 deletions frontend/src/components/motion/Toaster.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { useLayoutEffect, useRef, useSyncExternalStore } from 'react'
import { CheckCircle, Info, Spinner, WarningCircle, X } from '@phosphor-icons/react'
import { dismissToast, getToasts, removeToast, subscribeToasts, type ToastItem } from '@/lib/toast'
import { duration, ease, gsap, prefersReducedMotion } from '@/lib/motion'

const icon = {
loading: <Spinner size={16} className="animate-spin text-muted motion-reduce:animate-none" />,
success: <CheckCircle size={16} weight="fill" className="text-success" />,
error: <WarningCircle size={16} weight="fill" className="text-danger" />,
info: <Info size={16} weight="fill" className="text-accent" />,
}

function Toast({ item }: { item: ToastItem }) {
const ref = useRef<HTMLDivElement>(null)
const bodyRef = useRef<HTMLDivElement>(null)
const firstVersion = useRef(item.version)

// Enter: rise in from below.
useLayoutEffect(() => {
const el = ref.current
if (!el || prefersReducedMotion()) return
const tween = gsap.from(el, { autoAlpha: 0, y: 16, scale: 0.96, duration: duration.slow, ease: ease.out, clearProps: 'transform,opacity,visibility' })
return () => { tween.kill() }
}, [])

// In-place update (same id): crossfade the copy instead of stacking a new toast.
useLayoutEffect(() => {
const body = bodyRef.current
if (!body || item.version === firstVersion.current || prefersReducedMotion()) return
const tween = gsap.fromTo(body, { autoAlpha: 0, y: 4 }, { autoAlpha: 1, y: 0, duration: duration.base, ease: ease.out })
return () => { tween.kill() }
}, [item.version])

// Exit: collapse and fade, then drop from the store.
useLayoutEffect(() => {
const el = ref.current
if (!item.leaving || !el) return
if (prefersReducedMotion()) {
removeToast(item.id)
return
}
const tween = gsap.to(el, {
autoAlpha: 0,
x: 24,
height: 0,
marginTop: 0,
paddingTop: 0,
paddingBottom: 0,
duration: duration.base,
ease: ease.inOut,
onComplete: () => removeToast(item.id),
})
return () => { tween.kill() }
}, [item.leaving, item.id])

return (
<div
ref={ref}
role={item.type === 'error' ? 'alert' : 'status'}
className="pointer-events-auto mt-2 flex w-full items-start gap-2.5 overflow-hidden rounded-2xl border border-separator bg-overlay px-3.5 py-3 text-sm text-foreground shadow-lg"
>
<span className="mt-0.5 inline-flex shrink-0">{icon[item.type]}</span>
<div ref={bodyRef} className="min-w-0 flex-1">
<div className="font-medium leading-5">{item.title}</div>
{item.description ? <div className="mt-0.5 break-words text-xs leading-5 text-muted">{item.description}</div> : null}
</div>
<button
type="button"
aria-label="Close"
onClick={() => dismissToast(item.id)}
className="grid size-6 shrink-0 place-items-center rounded-md text-muted transition-colors hover:bg-surface-secondary hover:text-foreground"
>
<X size={12} />
</button>
</div>
)
}

export function Toaster() {
const items = useSyncExternalStore(subscribeToasts, getToasts, getToasts)
return (
<div aria-live="polite" className="pointer-events-none fixed bottom-4 right-4 z-[100] flex w-[min(360px,calc(100vw-2rem))] flex-col">
{items.map((item) => <Toast key={item.id} item={item} />)}
</div>
)
}
Loading
Loading