migrate web to next 16 template and redesign landing page (#526)
* migrate web to next-template stack (next 16, react 19, tailwind 4) replace all infrastructure with next-template conventions: - next.js 14 -> 16 (turbopack default, typed routes, react compiler) - react 18 -> 19 - tailwind css 3 -> 4 (css-first config, oklch theme system) - eslint 8 (.eslintrc) -> 9 (flat config with stricter rules) - add shadcn/ui, base-ui, cva component primitives - upgrade theme toggle to light/dark/system dropdown - migrate hardcoded neutral classes to semantic oklch tokens - drop unused @tailwindcss/forms, autoprefixer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * add no-misused-promises config for async JSX handlers, warn on array index keys Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * base-vega * sync frosty web with next-template runtime and claude tooling for cloudflare parity * gitignore .dev.vars per cloudflare best practice Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * add observability configuration to wrangler.jsonc * remove marquee emote background from hero section Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * improve CLAUDE.md and AGENTS.md files across project Remove redundant root CLAUDE.md (kept in .claude/ instead), add Commands and Source Structure sections to Flutter AGENTS.md, clarify global vs feature store placement, and fill gaps in web AGENTS.md (shadcn base-vega style, constants.ts, no-array-index-key rule, commit conventions). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * switch shadcn theme from neutral to violet, replace twitch-purple with primary token Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * initial redesign * auto-infer nativeButton in Button wrapper when render prop is used Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * replace skeleton carousel with real screenshots and video, add responsive phone scaling and mobile menu - Replace PhoneSkeleton with PhoneFrame/PhoneMedia using actual app screenshots and video - Fluid phone sizing via aspect-ratio with responsive max-h/max-w caps - Non-circular carousel with swipe gestures, dot indicator, and hover opacity boost - Video plays only when focused, pauses when off-screen - Collapse header actions into dropdown menu on mobile, add Contact link - Viewport-height layout with no scrolling on any screen size - Remove footer, move contact to header Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * improve header consistency, carousel layout, and responsive centering - add icons to all header buttons (Contact, Donate, GitHub) - switch theme toggle to ghost variant for consistency - bump header collapse breakpoint from md to lg - move carousel arrows to overlay positions with animated enter/exit - hide arrows at first/last slide - fix vertical centering of phone+description group across all screen sizes - add smooth blur+slide transition for description text Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * add contact dropdown with copy email toast - replace direct mailto link with dropdown: send email or copy email - install sonner for toast notifications, show top-center on copy - extract emailAddress constant for clipboard use - add copy option to mobile hamburger menu too Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * replace mobile hamburger menu with icon buttons Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * redesign hero copy, badge-style download buttons, and mobile layout Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * add consistent padding and object-contain to carousel media Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * add emote physics background to hero section Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * move container styling to hero panel, refresh emote set with 2025 trending picks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * add staggered entrance animations and remove grid gap Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * add 30-day cache headers for video and logo assets Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * add emote collisions and aspect-ratio tracking, refine hero layout Emote physics: add particle-particle elastic collisions, per-particle width measurement for correct aspect ratios, new emotes (aga, WW, o7, FLASHBANG, LULW, Deadass), bump count to 30. Layout: absolute-position header with z-20, increase mobile carousel ratio to 2fr/4fr, reserve min-h-10 for description text, remove panel rounding. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix carousel lint warnings: useSyncExternalStore, ref naming, stable keys Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * add seo metadata, sitemap, and robots.txt Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * add privacy link to header and footer credit Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * update privacy policy content and restyle with mono layout Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * reorder header nav and add mobile hamburger menu Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refresh web dependencies and smooth footer credit hover transition --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9119a69b17
commit
f19da4151c
46 changed files with 10379 additions and 2175 deletions
|
|
@ -1,3 +1,128 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
@import 'shadcn/tailwind.css';
|
||||
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-sans: var(--font-sans);
|
||||
--font-mono: var(--font-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.541 0.281 293.009);
|
||||
--primary-foreground: oklch(0.969 0.016 293.756);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.58 0.22 27);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.811 0.111 293.571);
|
||||
--chart-2: oklch(0.606 0.25 292.717);
|
||||
--chart-3: oklch(0.541 0.281 293.009);
|
||||
--chart-4: oklch(0.491 0.27 292.581);
|
||||
--chart-5: oklch(0.432 0.232 292.759);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.541 0.281 293.009);
|
||||
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.606 0.25 292.717);
|
||||
--primary-foreground: oklch(0.969 0.016 293.756);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.371 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.811 0.111 293.571);
|
||||
--chart-2: oklch(0.606 0.25 292.717);
|
||||
--chart-3: oklch(0.541 0.281 293.009);
|
||||
--chart-4: oklch(0.491 0.27 292.581);
|
||||
--chart-5: oklch(0.432 0.232 292.759);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.606 0.25 292.717);
|
||||
--sidebar-primary-foreground: oklch(0.969 0.016 293.756);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,62 @@
|
|||
import { Footer } from '@/components/Footer';
|
||||
import { Header } from '@/components/Header';
|
||||
import { Providers } from '@/components/Providers';
|
||||
import { type Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import { twJoin } from 'tailwind-merge';
|
||||
import './globals.css';
|
||||
import { Providers } from '@/components/Providers'
|
||||
import { type Metadata } from 'next'
|
||||
import { Inter, JetBrains_Mono } from 'next/font/google'
|
||||
import { twJoin } from 'tailwind-merge'
|
||||
import './globals.css'
|
||||
|
||||
const fontSans = Inter({ subsets: ['latin'], variable: '--font-sans' });
|
||||
const fontSans = Inter({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-sans',
|
||||
axes: ['opsz'],
|
||||
})
|
||||
const fontMono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Frosty for Twitch',
|
||||
description: 'Frosty lets you watch Twitch with 7TV, BTTV, and FFZ emotes',
|
||||
metadataBase: new URL('https://frostyapp.io'),
|
||||
};
|
||||
title: {
|
||||
default: 'Frosty for Twitch',
|
||||
template: '%s — Frosty',
|
||||
},
|
||||
description:
|
||||
'A fast, open-source Twitch client for iOS and Android with native 7TV, BTTV, and FFZ emote support.',
|
||||
openGraph: {
|
||||
siteName: 'Frosty',
|
||||
locale: 'en_US',
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
},
|
||||
alternates: {
|
||||
canonical: '/',
|
||||
},
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang='en' suppressHydrationWarning>
|
||||
<html
|
||||
lang='en'
|
||||
style={{ colorScheme: 'light dark' }}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<body
|
||||
className={twJoin(
|
||||
'mx-auto min-h-screen max-w-screen-lg border-neutral-300 bg-neutral-100 font-sans text-neutral-950 dark:border-neutral-900 dark:bg-black dark:text-neutral-100 lg:border-x',
|
||||
'selection:bg-primary selection:text-primary-foreground min-h-dvh font-sans underline-offset-4 antialiased',
|
||||
fontSans.variable,
|
||||
fontMono.variable,
|
||||
)}
|
||||
>
|
||||
<Providers>
|
||||
<Header />
|
||||
<main className='grow'>{children}</main>
|
||||
<Footer />
|
||||
<main>{children}</main>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,222 +1,402 @@
|
|||
import screenshotCategories from '@/assets/screenshot-categories.png';
|
||||
import screenshotChannel from '@/assets/screenshot-channel.png';
|
||||
import screenshotFollowing from '@/assets/screenshot-following.png';
|
||||
import screenshotSettings from '@/assets/screenshot-settings.png';
|
||||
import { FeatureCard } from '@/components/FeatureCard';
|
||||
'use client'
|
||||
|
||||
import screenshotCategories from '@/assets/screenshot-categories.png'
|
||||
import screenshotFollowing from '@/assets/screenshot-following.png'
|
||||
import screenshotSettings from '@/assets/screenshot-settings.png'
|
||||
import { EmotePhysicsBackground } from '@/components/EmotePhysicsBackground'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { Header } from '@/components/Header'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
appStoreLink,
|
||||
bttvLink,
|
||||
emailLink,
|
||||
ffzLink,
|
||||
githubLink,
|
||||
playStoreLink,
|
||||
sevenTvLink,
|
||||
twitchLink,
|
||||
} from '@/lib/constants';
|
||||
import Image from 'next/image';
|
||||
import Marquee from 'react-fast-marquee';
|
||||
import { SiApple, SiGoogleplay } from 'react-icons/si';
|
||||
} from '@/lib/constants'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import Image, { type StaticImageData } from 'next/image'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
import { SiApple, SiGoogleplay } from 'react-icons/si'
|
||||
|
||||
const NUM_ROWS = 10;
|
||||
const NUM_COLUMNS = 40;
|
||||
const features = [
|
||||
{
|
||||
title: 'Native emotes',
|
||||
description: 'Watch and chat with 7TV, BetterTTV, and FrankerFaceZ emotes.',
|
||||
media: { type: 'video' as const, src: '/video.webm' },
|
||||
},
|
||||
{
|
||||
title: 'Followed channels',
|
||||
description:
|
||||
'See who is live, pin favorites, and browse your followed list.',
|
||||
media: { type: 'image' as const, src: screenshotFollowing },
|
||||
},
|
||||
{
|
||||
title: 'Explore categories',
|
||||
description: 'Browse top streams and trending categories across Twitch.',
|
||||
media: { type: 'image' as const, src: screenshotCategories },
|
||||
},
|
||||
{
|
||||
title: 'Deeply customizable',
|
||||
description:
|
||||
'Themes, autocomplete, sleep timers, and local message history.',
|
||||
media: { type: 'image' as const, src: screenshotSettings },
|
||||
},
|
||||
]
|
||||
|
||||
const coreFeatures = [
|
||||
{
|
||||
caption: 'See and pin followed channels',
|
||||
screenshot: screenshotFollowing,
|
||||
// ─── Entrance animation variants ─────────────────────────────────────────────
|
||||
const item = {
|
||||
hidden: { opacity: 0, y: 20, filter: 'blur(4px)' },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: 'blur(0px)',
|
||||
transition: { duration: 0.5, ease: 'easeOut' as const },
|
||||
},
|
||||
{
|
||||
caption: 'Explore top streams and categories',
|
||||
screenshot: screenshotCategories,
|
||||
},
|
||||
{
|
||||
caption: 'Watch and chat with 7TV, BTTV, and FFZ emotes',
|
||||
screenshot: screenshotChannel,
|
||||
},
|
||||
{
|
||||
caption: 'Customize a variety of settings',
|
||||
screenshot: screenshotSettings,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const faqs = [
|
||||
{
|
||||
question: 'Why are some Twitch features not in Frosty?',
|
||||
answer:
|
||||
'The Twitch API only exposes a limited set of functionality to developers. Features like predictions, polls, pinned messages, VODs with chat, stream qualities, total view count for categories and more are not available.',
|
||||
},
|
||||
{
|
||||
question: 'Why is the stream delayed on iOS?',
|
||||
answer:
|
||||
'There is a delay of around 15 seconds due to how the native iOS player works. As a workaround, Frosty has a message delay option that lets you set the delay (in seconds) before each message is rendered.',
|
||||
},
|
||||
{
|
||||
question: 'Is ad block planned?',
|
||||
answer:
|
||||
'Ad block is not planned because it would probably violate the Twitch terms of service.',
|
||||
},
|
||||
{
|
||||
question: 'Will Frosty support Apple/Android TV?',
|
||||
answer:
|
||||
"Not yet, because Flutter (the framework that Frosty is built upon) doesn't officially support TVs.",
|
||||
},
|
||||
{
|
||||
question: 'Where can I report a bug or request a new feature?',
|
||||
answer: (
|
||||
<>
|
||||
You can open a new issue on the{' '}
|
||||
<a
|
||||
className='underline'
|
||||
href={githubLink}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
GitHub repo
|
||||
</a>{' '}
|
||||
or email{' '}
|
||||
<a
|
||||
className='underline'
|
||||
href={emailLink}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
contact@frostyapp.io
|
||||
</a>
|
||||
.
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
const staggerContainer = {
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: 0.12 } },
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const downloadButtons = (
|
||||
<div className='grid w-full grid-cols-2 divide-x divide-neutral-300 border-y border-neutral-300 font-semibold dark:divide-neutral-900 dark:border-neutral-900'>
|
||||
<a
|
||||
className='flex items-center justify-center gap-2 p-4 transition hover:bg-blue-700 hover:text-neutral-100 dark:hover:bg-blue-800'
|
||||
href={appStoreLink}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
function DownloadButtons() {
|
||||
return (
|
||||
<div className='flex flex-wrap justify-center gap-3'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='lg'
|
||||
className='bg-background h-14 rounded-xl px-6'
|
||||
render={<a href={appStoreLink} target='_blank' rel='noreferrer' />}
|
||||
>
|
||||
<SiApple className='text-blue-500 dark:text-blue-500' />
|
||||
App Store
|
||||
</a>
|
||||
<a
|
||||
className='flex items-center justify-center gap-2 p-4 transition hover:bg-green-700 hover:text-neutral-100 dark:hover:bg-green-800'
|
||||
href={playStoreLink}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
<SiApple className='mr-2.5 size-6 text-blue-500 dark:text-blue-400' />
|
||||
<div className='flex flex-col items-start'>
|
||||
<span className='text-xs leading-tight font-normal opacity-60'>
|
||||
Download on the
|
||||
</span>
|
||||
<span className='text-base leading-tight font-semibold'>
|
||||
App Store
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='lg'
|
||||
className='bg-background h-14 rounded-xl px-6'
|
||||
render={<a href={playStoreLink} target='_blank' rel='noreferrer' />}
|
||||
>
|
||||
<SiGoogleplay className='text-green-500 dark:text-green-400' />
|
||||
Google Play
|
||||
</a>
|
||||
<SiGoogleplay className='mr-2.5 size-5 text-green-500 dark:text-green-400' />
|
||||
<div className='flex flex-col items-start'>
|
||||
<span className='text-xs leading-tight font-normal opacity-60'>
|
||||
Get it on
|
||||
</span>
|
||||
<span className='text-base leading-tight font-semibold'>
|
||||
Google Play
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
function PhoneFrame({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('aspect-[6/13]', className)}>
|
||||
<div className='border-border/50 size-full overflow-hidden rounded-4xl border bg-black p-1 shadow-xl'>
|
||||
<div className='relative size-full overflow-hidden rounded-4xl'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PhoneMedia({
|
||||
media,
|
||||
title,
|
||||
isCurrent,
|
||||
}: {
|
||||
media:
|
||||
| { type: 'video'; src: string }
|
||||
| { type: 'image'; src: StaticImageData }
|
||||
title: string
|
||||
isCurrent: boolean
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (media.type !== 'video') return
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
if (isCurrent) {
|
||||
void video.play()
|
||||
} else {
|
||||
video.pause()
|
||||
}
|
||||
}, [isCurrent, media.type])
|
||||
|
||||
return (
|
||||
<article className='flex flex-col'>
|
||||
<section>
|
||||
<div className='grid w-full items-center [&>*]:col-start-1 [&>*]:row-start-1'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
{Array.from({ length: NUM_ROWS }).map((_, rowIndex) => (
|
||||
<Marquee
|
||||
key={rowIndex}
|
||||
direction={rowIndex % 2 ? 'left' : 'right'}
|
||||
>
|
||||
{Array.from({ length: NUM_COLUMNS }).map((_, colIndex) => (
|
||||
<Image
|
||||
key={colIndex}
|
||||
width={32}
|
||||
height={32}
|
||||
alt='pepeD'
|
||||
unoptimized
|
||||
src='https://cdn.7tv.app/emote/6072a16fdcae02001b44e614/4x.webp'
|
||||
/>
|
||||
))}
|
||||
</Marquee>
|
||||
))}
|
||||
</div>
|
||||
<div className='z-10 justify-self-center p-8 pt-16'>
|
||||
<video
|
||||
className='h-[75vh] max-h-[800px] border border-neutral-300 bg-black object-contain py-4 dark:border-neutral-900'
|
||||
src='/video.webm'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
disableRemotePlayback
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className='text-pretty p-8 pb-16 text-center text-xl font-semibold decoration-2 underline-offset-4 md:text-2xl'>
|
||||
Frosty lets you watch{' '}
|
||||
<a
|
||||
className='text-twitch-purple underline'
|
||||
href={twitchLink}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Twitch
|
||||
</a>{' '}
|
||||
with{' '}
|
||||
<a
|
||||
className='text-twitch-purple underline'
|
||||
href={sevenTvLink}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
7TV
|
||||
</a>
|
||||
,{' '}
|
||||
<a
|
||||
className='text-twitch-purple underline'
|
||||
href={bttvLink}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
BTTV
|
||||
</a>
|
||||
, and{' '}
|
||||
<a
|
||||
className='text-twitch-purple underline'
|
||||
href={ffzLink}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
FFZ
|
||||
</a>{' '}
|
||||
emotes
|
||||
</h1>
|
||||
</section>
|
||||
|
||||
{downloadButtons}
|
||||
|
||||
<section className='flex flex-col gap-16 p-16 md:grid md:grid-cols-2'>
|
||||
{coreFeatures.map((feature, index) => (
|
||||
<FeatureCard key={index} {...feature} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
{downloadButtons}
|
||||
|
||||
<section className='flex flex-col items-center' id='faq'>
|
||||
<h2 className='p-8 text-lg font-semibold md:text-xl'>
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
|
||||
<div className='w-full divide-y divide-neutral-300 border-y border-neutral-300 dark:divide-neutral-900 dark:border-neutral-900'>
|
||||
{faqs.map((faq, index) => (
|
||||
<details key={index}>
|
||||
<summary className='p-8 font-medium transition hover:cursor-pointer hover:bg-neutral-200 dark:hover:bg-neutral-900'>
|
||||
{faq.question}
|
||||
</summary>
|
||||
<p className='border-t border-neutral-300 px-12 py-8 text-neutral-600 dark:border-neutral-900 dark:text-neutral-300'>
|
||||
{faq.answer}
|
||||
</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
);
|
||||
<div className='relative size-full bg-black py-3'>
|
||||
{media.type === 'video' ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={media.src}
|
||||
loop
|
||||
muted
|
||||
playsInline
|
||||
className='size-full object-contain'
|
||||
/>
|
||||
) : (
|
||||
<Image src={media.src} alt={title} fill className='object-contain' />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const STEP_MOBILE = 220
|
||||
const STEP_DESKTOP = 370
|
||||
const stepQuery = '(min-width: 768px)'
|
||||
function subscribeStep(callback: () => void) {
|
||||
const mq = window.matchMedia(stepQuery)
|
||||
mq.addEventListener('change', callback)
|
||||
return () => mq.removeEventListener('change', callback)
|
||||
}
|
||||
function getStepSnapshot() {
|
||||
return window.matchMedia(stepQuery).matches ? STEP_DESKTOP : STEP_MOBILE
|
||||
}
|
||||
function getStepServerSnapshot() {
|
||||
return STEP_DESKTOP
|
||||
}
|
||||
|
||||
function Carousel() {
|
||||
const [current, setCurrent] = useState(0)
|
||||
const [hovered, setHovered] = useState<number | null>(null)
|
||||
const step = useSyncExternalStore(
|
||||
subscribeStep,
|
||||
getStepSnapshot,
|
||||
getStepServerSnapshot,
|
||||
)
|
||||
|
||||
const touchStartRef = useRef(0)
|
||||
|
||||
const go = useCallback((delta: number) => {
|
||||
setCurrent((prev) =>
|
||||
Math.max(0, Math.min(features.length - 1, prev + delta)),
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-4'>
|
||||
{/* Phone track with overlay arrows */}
|
||||
<div
|
||||
className='relative h-full max-h-120 min-h-0 w-full shrink touch-pan-y overflow-hidden md:max-h-200'
|
||||
onTouchStart={(e) => {
|
||||
touchStartRef.current = e.touches[0]!.clientX
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
const delta = e.changedTouches[0]!.clientX - touchStartRef.current
|
||||
if (delta > 50) go(-1)
|
||||
else if (delta < -50) go(1)
|
||||
}}
|
||||
>
|
||||
{/* Animated phones on the track */}
|
||||
{features.map((feature, i) => {
|
||||
const offset = i - current
|
||||
|
||||
if (Math.abs(offset) > 2) return null
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={feature.title}
|
||||
initial={{ x: offset * step, opacity: 0 }}
|
||||
animate={{
|
||||
x: offset * step,
|
||||
opacity:
|
||||
offset === 0
|
||||
? 1
|
||||
: Math.abs(offset) === 1
|
||||
? hovered === i
|
||||
? 0.5
|
||||
: 0.25
|
||||
: 0,
|
||||
}}
|
||||
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||
className='pointer-events-none absolute inset-0 flex items-center justify-center'
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full items-center justify-center',
|
||||
offset !== 0 && 'pointer-events-auto cursor-pointer',
|
||||
)}
|
||||
onMouseEnter={offset !== 0 ? () => setHovered(i) : undefined}
|
||||
onMouseLeave={offset !== 0 ? () => setHovered(null) : undefined}
|
||||
onClick={offset !== 0 ? () => go(offset) : undefined}
|
||||
>
|
||||
<PhoneFrame className='h-full max-h-110 max-w-48 md:max-h-190 md:max-w-88'>
|
||||
<PhoneMedia
|
||||
media={feature.media}
|
||||
title={feature.title}
|
||||
isCurrent={offset === 0}
|
||||
/>
|
||||
</PhoneFrame>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Arrow overlays */}
|
||||
<AnimatePresence>
|
||||
{current > 0 && (
|
||||
<motion.div
|
||||
key='prev'
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -8 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className='absolute top-1/2 left-1 z-10 -translate-y-1/2'
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => go(-1)}
|
||||
aria-label='Previous feature'
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
{current < features.length - 1 && (
|
||||
<motion.div
|
||||
key='next'
|
||||
initial={{ opacity: 0, x: 8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 8 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className='absolute top-1/2 right-1 z-10 -translate-y-1/2'
|
||||
>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
onClick={() => go(1)}
|
||||
aria-label='Next feature'
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className='min-h-10 px-4 text-center'>
|
||||
<AnimatePresence mode='wait'>
|
||||
<motion.p
|
||||
key={current}
|
||||
initial={{ opacity: 0, y: 4, filter: 'blur(4px)' }}
|
||||
animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, y: -4, filter: 'blur(4px)' }}
|
||||
transition={{ duration: 0.3, ease: 'easeOut' }}
|
||||
className='text-muted-foreground text-sm'
|
||||
>
|
||||
{features[current]?.description}
|
||||
</motion.p>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className='grid h-dvh grid-rows-[2fr_4fr] overflow-hidden md:grid-cols-2 md:grid-rows-none'>
|
||||
{/* Left cell — intro */}
|
||||
<motion.div
|
||||
className='bg-muted/50 dark:bg-muted/30 relative flex flex-col gap-4 overflow-hidden'
|
||||
initial='hidden'
|
||||
animate='visible'
|
||||
variants={staggerContainer}
|
||||
>
|
||||
<EmotePhysicsBackground />
|
||||
<motion.div
|
||||
variants={item}
|
||||
className='absolute inset-x-0 top-0 z-20 p-4'
|
||||
>
|
||||
<Header />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
variants={staggerContainer}
|
||||
className='relative z-10 flex min-h-full flex-col items-center justify-center gap-4 text-center md:gap-6'
|
||||
>
|
||||
<motion.div variants={item} className='flex flex-col gap-3 md:gap-4'>
|
||||
<h1 className='text-2xl font-semibold tracking-tight text-pretty md:text-4xl'>
|
||||
Watch Twitch on mobile with
|
||||
<br />
|
||||
<a
|
||||
href={sevenTvLink}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='text-primary underline'
|
||||
>
|
||||
7TV
|
||||
</a>
|
||||
,{' '}
|
||||
<a
|
||||
href={bttvLink}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='text-primary underline'
|
||||
>
|
||||
BTTV
|
||||
</a>
|
||||
, and{' '}
|
||||
<a
|
||||
href={ffzLink}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='text-primary underline'
|
||||
>
|
||||
FFZ
|
||||
</a>{' '}
|
||||
emotes
|
||||
</h1>
|
||||
<p className='text-muted-foreground mx-auto max-w-sm text-sm text-balance md:text-base'>
|
||||
A fast, open-source Twitch client for iOS and Android with native
|
||||
7TV, BTTV, and FFZ support.
|
||||
</p>
|
||||
</motion.div>
|
||||
<motion.div variants={item}>
|
||||
<DownloadButtons />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
<motion.div variants={item} className='z-30'>
|
||||
<Footer />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* Right cell — carousel */}
|
||||
<motion.div
|
||||
className='flex flex-col overflow-hidden py-4'
|
||||
initial={{ opacity: 0, y: 30, filter: 'blur(4px)' }}
|
||||
animate={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
|
||||
transition={{ duration: 0.6, ease: 'easeOut', delay: 0.4 }}
|
||||
>
|
||||
<Carousel />
|
||||
</motion.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,77 +1,158 @@
|
|||
import type { Metadata } from 'next'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Privacy Policy',
|
||||
description:
|
||||
'Privacy policy for Frosty, an open-source Twitch client for iOS and Android.',
|
||||
alternates: {
|
||||
canonical: '/privacy',
|
||||
},
|
||||
}
|
||||
|
||||
function ExtLink({
|
||||
href,
|
||||
children,
|
||||
}: {
|
||||
href: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='text-muted-foreground underline decoration-muted-foreground/40 underline-offset-4 transition-colors hover:text-foreground hover:decoration-foreground/40'
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Privacy() {
|
||||
return (
|
||||
<article className='prose prose-neutral mt-32 !max-w-none dark:prose-invert'>
|
||||
<h1>Privacy policy for Frosty</h1>
|
||||
<p>Last updated: May 2023</p>
|
||||
<div className='font-mono mx-auto max-w-2xl px-6 py-12'>
|
||||
<Link href='/' className='mb-12 flex items-center gap-2'>
|
||||
<Image src='/logo.svg' alt='' width={24} height={24} />
|
||||
<span className='text-foreground font-sans text-sm font-semibold'>Frosty</span>
|
||||
</Link>
|
||||
|
||||
<p>
|
||||
Frosty is an unofficial open-source mobile Twitch client/app for iOS and
|
||||
<h1 className='text-foreground text-2xl font-bold tracking-tight'>
|
||||
Privacy Policy
|
||||
</h1>
|
||||
<p className='text-muted-foreground mt-1 text-sm'>
|
||||
Last updated: February 2026
|
||||
</p>
|
||||
|
||||
<p className='text-muted-foreground mt-8 text-sm leading-relaxed'>
|
||||
Frosty is an unofficial open-source mobile Twitch client for iOS and
|
||||
Android. We are dedicated to protecting your privacy. Frosty does not
|
||||
collect or share any personal information. However, we may gather
|
||||
anonymous usage data and crash logs solely to improve the app. For more
|
||||
information, please refer to the sections below.
|
||||
</p>
|
||||
|
||||
<h2>Third-party services</h2>
|
||||
<p>
|
||||
<h2 className='text-foreground mt-10 text-lg font-semibold tracking-tight'>
|
||||
Third-party services
|
||||
</h2>
|
||||
<p className='text-muted-foreground mt-3 text-sm leading-relaxed'>
|
||||
Frosty uses and interacts with the following services in order to
|
||||
provide the best experience possible:
|
||||
</p>
|
||||
|
||||
<h3>Twitch</h3>
|
||||
<p>
|
||||
<h3 className='text-foreground mt-8 text-base font-semibold'>Twitch</h3>
|
||||
<p className='text-muted-foreground mt-2 text-sm leading-relaxed'>
|
||||
Frosty uses the official Twitch API to showcase live channels, connect
|
||||
to chat, and provide additional features. You can optionally log in with
|
||||
your Twitch account to access user-specific features, such as sending
|
||||
chat messages and viewing your followed channels.
|
||||
</p>
|
||||
<p>
|
||||
<p className='text-muted-foreground mt-3 text-sm leading-relaxed'>
|
||||
If you log in using Twitch, Frosty will only ask you for the necessary
|
||||
and required permissions to function. Frosty will then obtain your OAuth
|
||||
access token and send requests to receive and transmit data to Twitch
|
||||
only on your behalf. This access token is stored and encrypted locally
|
||||
on your device only.
|
||||
</p>
|
||||
<p>
|
||||
<p className='text-muted-foreground mt-3 text-sm leading-relaxed'>
|
||||
For more information on how Twitch handles your data, please refer to
|
||||
their privacy policy.
|
||||
their{' '}
|
||||
<ExtLink href='https://www.twitch.tv/p/en/legal/privacy-notice/'>
|
||||
privacy policy
|
||||
</ExtLink>
|
||||
.
|
||||
</p>
|
||||
|
||||
<h3>7TV, BetterTTV, and FrankerFaceZ</h3>
|
||||
<p>
|
||||
<h3 className='text-foreground mt-8 text-base font-semibold'>
|
||||
7TV, BetterTTV, and FrankerFaceZ
|
||||
</h3>
|
||||
<p className='text-muted-foreground mt-2 text-sm leading-relaxed'>
|
||||
Frosty uses APIs from 7TV, BetterTTV (BTTV), and FrankerFaceZ (FFZ) to
|
||||
display custom badges and emotes in chat. When you visit your own
|
||||
channel, Frosty will request these services using your public Twitch ID
|
||||
or username to obtain emotes and badges associated with your channel.
|
||||
display custom badges and emotes in chat. When you view a channel,
|
||||
Frosty will request these services using the channel's public
|
||||
Twitch ID or username to obtain emotes and badges associated with that
|
||||
channel.
|
||||
</p>
|
||||
<p>
|
||||
For more information on how 7TV, BTTV, and FFZ handle your data, please
|
||||
refer to their respective privacy policies.
|
||||
<p className='text-muted-foreground mt-3 text-sm leading-relaxed'>
|
||||
For more information on how these services handle your data, please
|
||||
refer to their respective privacy policies:{' '}
|
||||
<ExtLink href='https://7tv.app/legal/privacy'>7TV</ExtLink>,{' '}
|
||||
<ExtLink href='https://betterttv.com/privacy'>BTTV</ExtLink>, and{' '}
|
||||
<ExtLink href='https://www.frankerfacez.com/privacy'>FFZ</ExtLink>.
|
||||
</p>
|
||||
|
||||
<h3>Firebase</h3>
|
||||
<p>
|
||||
Frosty utilizes Firebase for crash logs, usage data, and analytics to
|
||||
aid in the development of new features, improvements, and bug fixes. The
|
||||
collected data is anonymous and does not contain any personal
|
||||
information. You can opt out of this data collection by turning off
|
||||
crash logs and analytics in the settings.
|
||||
<h3 className='text-foreground mt-8 text-base font-semibold'>
|
||||
Firebase
|
||||
</h3>
|
||||
<p className='text-muted-foreground mt-2 text-sm leading-relaxed'>
|
||||
Frosty utilizes Firebase for crash reporting, performance monitoring,
|
||||
and analytics to aid in the development of new features, improvements,
|
||||
and bug fixes. Data collected may include device model, OS version,
|
||||
crash traces, session analytics, and network performance traces. This
|
||||
data is anonymous and does not contain any personal information. You can
|
||||
opt out of this data collection by turning off crash logs and analytics
|
||||
in the settings.
|
||||
</p>
|
||||
<p className='text-muted-foreground mt-3 text-sm leading-relaxed'>
|
||||
Firebase retains analytics and crash data according to its default
|
||||
retention periods. For more information, please refer to the{' '}
|
||||
<ExtLink href='https://firebase.google.com/support/privacy'>
|
||||
Firebase privacy policy
|
||||
</ExtLink>
|
||||
.
|
||||
</p>
|
||||
<p>For more information, please refer to the Firebase privacy policy.</p>
|
||||
|
||||
<h2>Privacy policy updates</h2>
|
||||
<p>
|
||||
<h2 className='text-foreground mt-10 text-lg font-semibold tracking-tight'>
|
||||
Data retention
|
||||
</h2>
|
||||
<p className='text-muted-foreground mt-3 text-sm leading-relaxed'>
|
||||
Frosty does not operate its own servers or databases. All data
|
||||
collection is handled by Firebase, which retains data according to its
|
||||
default retention policies.
|
||||
</p>
|
||||
|
||||
<h2 className='text-foreground mt-10 text-lg font-semibold tracking-tight'>
|
||||
Privacy policy updates
|
||||
</h2>
|
||||
<p className='text-muted-foreground mt-3 text-sm leading-relaxed'>
|
||||
We may occasionally update this privacy policy, and the most recent
|
||||
version will always be available on this page. We recommend reviewing
|
||||
this privacy policy periodically for any changes. Changes to this
|
||||
privacy policy become effective when they are posted on this page.
|
||||
</p>
|
||||
|
||||
<h2>Contact</h2>
|
||||
<p>
|
||||
<h2 className='text-foreground mt-10 text-lg font-semibold tracking-tight'>
|
||||
Contact
|
||||
</h2>
|
||||
<p className='text-muted-foreground mt-3 text-sm leading-relaxed'>
|
||||
If you have any questions or suggestions about this privacy policy,
|
||||
please feel free to contact us at contact@frostyapp.io.
|
||||
please feel free to contact us at{' '}
|
||||
<ExtLink href='mailto:contact@frostyapp.io'>
|
||||
contact@frostyapp.io
|
||||
</ExtLink>
|
||||
.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
8
web/src/app/robots.ts
Normal file
8
web/src/app/robots.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import type { MetadataRoute } from 'next'
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: { userAgent: '*', allow: '/' },
|
||||
sitemap: 'https://frostyapp.io/sitemap.xml',
|
||||
}
|
||||
}
|
||||
12
web/src/app/sitemap.ts
Normal file
12
web/src/app/sitemap.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import type { MetadataRoute } from 'next'
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{ url: 'https://frostyapp.io', lastModified: new Date(), priority: 1 },
|
||||
{
|
||||
url: 'https://frostyapp.io/privacy',
|
||||
lastModified: new Date(),
|
||||
priority: 0.3,
|
||||
},
|
||||
]
|
||||
}
|
||||
377
web/src/components/EmotePhysicsBackground.tsx
Normal file
377
web/src/components/EmotePhysicsBackground.tsx
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
'use client'
|
||||
|
||||
import { motion } from 'motion/react'
|
||||
import { useEffect, useRef, useSyncExternalStore } from 'react'
|
||||
|
||||
// ─── 7TV emotes (xQc's channel set) ─────────────────────────────────────────
|
||||
// Curated from xQc's 7TV emote set — images load directly from cdn.7tv.app.
|
||||
const EMOTE_IDS = [
|
||||
'01FZ975PV8000B4AWRZNMVNEXN', // LOL
|
||||
'01GBPSCGR00007Q17796BDN5AJ', // Classic
|
||||
'01JP0FG0NN10YJPYVTZN5K67WY', // LO
|
||||
'01F6MZGCNG000255K4X1K7NTHR', // GIGACHAD
|
||||
'01HTKMB7680004B0MV8TERR9A4', // AURA
|
||||
'01F00Z3A9G0007E4VV006YKSK9', // OMEGALUL
|
||||
'01HMBMJPV0000D32KQCYBK4S1D', // aga
|
||||
'01F71VQYHR000D3ZZ6Q11NR7TV', // WW
|
||||
'01FS5ZCFG0000500DPPCXJWCP8', // o7
|
||||
'01F7CTADRG000351ERP2WA0ME9', // FLASHBANG
|
||||
'01F6T8NM9R0007M5BTFWSP1YSJ', // Clueless
|
||||
'01F22WE74G000F9Q0G00A2DE8N', // LULW
|
||||
'01F6TCFKM0000CBRZV8MZ7ZGGH', // Deadass
|
||||
'01F6MG1HPR0009K1N00D4GG63Z', // ICANT
|
||||
'01FHNBZRW8000C3ZWT2Z63JS92', // Sadge
|
||||
'01EZTCN91800012PTN006Q50PR', // Pog
|
||||
'01FFWH9WV80000JT8GHDKHJNZC', // Aware
|
||||
'01F010F9GR0007E4VV006YKSKN', // PepeLaugh
|
||||
'01F6MA6Y100002B6P5MWZ5D916', // Hmm
|
||||
'01F6NACCD80006SZ7ZW5FMWKWK', // Prayge
|
||||
'01FG1NDHJR0001XDR7G9054X2Q', // HUH
|
||||
'01GFBTYEV80008P4E5PB4NX0XC', // DIESOFCRINGE
|
||||
'01F6MDFCSR0000WDA7ERT623YT', // NODDERS
|
||||
'01F6MQ33FG000FFJ97ZB8MWV52', // catJAM
|
||||
'01EZY967K0000CYST6006V20T8', // pepeJAM
|
||||
'01F6NMMEER00015NVG2J8ZH77N', // peepoHey
|
||||
'01H3ZS4R1R0003PH84VNH1WMYC', // Madge
|
||||
'01F6MMQCM80009C9ZSNZT3GTK1', // PagChomp
|
||||
'01FAJR9X80000136YH153JYZTB', // modCheck
|
||||
'01G7RNEB2R00029YRR37CZ24HX', // LockIn
|
||||
'01G7YR9X5G0003Z50SB3FM5WR4', // Happi
|
||||
'01GBEBQQN00001RAQMJBWFVDXF', // COOKING
|
||||
'01JJ0D0C2XWZ640NP4WJKB8MYX', // Cooked
|
||||
'01GY1RDSR8000ENJJARE9FMTJR', // SLAY
|
||||
'01FBZESCNR000A6AWCB1X558GZ', // Chatting
|
||||
'01GF1Y2Q5G0000BGNJSP34TQRD', // widepeepoHappy
|
||||
]
|
||||
|
||||
const EMOTE_URLS = EMOTE_IDS.map(
|
||||
(id) => `https://cdn.7tv.app/emote/${id}/2x.webp`,
|
||||
)
|
||||
|
||||
// ─── Physics constants ───────────────────────────────────────────────────────
|
||||
const PARTICLE_COUNT = 30
|
||||
const EMOTE_SIZE = 40
|
||||
const TARGET_OPACITY = 0.3
|
||||
const INITIAL_SPEED = 0.4
|
||||
const MIN_SPEED = 0.2
|
||||
const MAX_SPEED = 2.5
|
||||
const FRICTION = 0.998
|
||||
const RESTITUTION = 0.8
|
||||
const REPULSE_RADIUS = 130
|
||||
const REPULSE_STRENGTH = 1.6
|
||||
const CENTER_ZONE_X = 0.6 // fraction of container width
|
||||
const CENTER_ZONE_Y = 0.5 // fraction of container height
|
||||
const CENTER_PUSH = 0.15
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
interface ParticleState {
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
rotation: number
|
||||
rotationSpeed: number
|
||||
halfW: number
|
||||
measured: boolean
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
function rand(min: number, max: number) {
|
||||
return Math.random() * (max - min) + min
|
||||
}
|
||||
|
||||
function spawnInEdgeZone(w: number, h: number): { x: number; y: number } {
|
||||
// Spawn emotes in the outer ring, avoiding the center zone
|
||||
const half = EMOTE_SIZE / 2
|
||||
const zoneRx = (w * CENTER_ZONE_X) / 2
|
||||
const zoneRy = (h * CENTER_ZONE_Y) / 2
|
||||
let x: number
|
||||
let y: number
|
||||
do {
|
||||
x = rand(half, w - half)
|
||||
y = rand(half, h - half)
|
||||
} while (((x - w / 2) / zoneRx) ** 2 + ((y - h / 2) / zoneRy) ** 2 < 1)
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
function createParticle(w: number, h: number): ParticleState {
|
||||
const { x, y } = spawnInEdgeZone(w, h)
|
||||
const angle = rand(0, Math.PI * 2)
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
vx: Math.cos(angle) * INITIAL_SPEED,
|
||||
vy: Math.sin(angle) * INITIAL_SPEED,
|
||||
rotation: rand(-0.3, 0.3),
|
||||
rotationSpeed: rand(-0.003, 0.003),
|
||||
halfW: EMOTE_SIZE / 2,
|
||||
measured: false,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
const desktopQuery = '(min-width: 768px)'
|
||||
function subscribeDesktop(callback: () => void) {
|
||||
const mq = window.matchMedia(desktopQuery)
|
||||
mq.addEventListener('change', callback)
|
||||
return () => mq.removeEventListener('change', callback)
|
||||
}
|
||||
function getDesktopSnapshot() {
|
||||
return window.matchMedia(desktopQuery).matches
|
||||
}
|
||||
function getDesktopServerSnapshot() {
|
||||
return false
|
||||
}
|
||||
|
||||
export function EmotePhysicsBackground() {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const isDesktop = useSyncExternalStore(
|
||||
subscribeDesktop,
|
||||
getDesktopSnapshot,
|
||||
getDesktopServerSnapshot,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDesktop) return
|
||||
|
||||
const container = containerRef.current
|
||||
if (container === null) return
|
||||
|
||||
const elements =
|
||||
container.querySelectorAll<HTMLImageElement>('[data-emote]')
|
||||
if (elements.length === 0) return
|
||||
|
||||
let cancelled = false
|
||||
let rafId = 0
|
||||
let w = 0
|
||||
let h = 0
|
||||
const mouseRef = { current: null as { x: number; y: number } | null }
|
||||
|
||||
// Initialize dimensions
|
||||
const rect = container.getBoundingClientRect()
|
||||
w = rect.width
|
||||
h = rect.height
|
||||
|
||||
// Initialize particle states
|
||||
const particles: ParticleState[] = []
|
||||
elements.forEach(() => {
|
||||
if (w > 0 && h > 0) {
|
||||
particles.push(createParticle(w, h))
|
||||
}
|
||||
})
|
||||
|
||||
// Resize handling
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry === undefined) return
|
||||
const { width, height } = entry.contentRect
|
||||
if (width > 0 && height > 0) {
|
||||
w = width
|
||||
h = height
|
||||
const halfH = EMOTE_SIZE / 2
|
||||
for (const p of particles) {
|
||||
p.x = Math.max(p.halfW, Math.min(p.x, w - p.halfW))
|
||||
p.y = Math.max(halfH, Math.min(p.y, h - halfH))
|
||||
}
|
||||
}
|
||||
})
|
||||
ro.observe(container)
|
||||
|
||||
// Mouse tracking on the parent panel (content z-10 sits above container)
|
||||
const panel = container.parentElement ?? container
|
||||
function onMouseMove(e: MouseEvent) {
|
||||
const r = container!.getBoundingClientRect()
|
||||
mouseRef.current = { x: e.clientX - r.left, y: e.clientY - r.top }
|
||||
}
|
||||
function onMouseLeave() {
|
||||
mouseRef.current = null
|
||||
}
|
||||
panel.addEventListener('mousemove', onMouseMove)
|
||||
panel.addEventListener('mouseleave', onMouseLeave)
|
||||
|
||||
function tick() {
|
||||
if (cancelled) return
|
||||
|
||||
const m = mouseRef.current
|
||||
const centerX = w / 2
|
||||
const centerY = h / 2
|
||||
const zoneRadiusX = (w * CENTER_ZONE_X) / 2
|
||||
const zoneRadiusY = (h * CENTER_ZONE_Y) / 2
|
||||
|
||||
// Per-particle forces, friction, speed limits, and integration
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
const p = particles[i]
|
||||
if (p === undefined) continue
|
||||
|
||||
// Center avoidance — soft elliptical repulsion
|
||||
const cdx = p.x - centerX
|
||||
const cdy = p.y - centerY
|
||||
const normDist = (cdx / zoneRadiusX) ** 2 + (cdy / zoneRadiusY) ** 2
|
||||
if (normDist < 1 && normDist > 0.001) {
|
||||
const strength = (1 - normDist) * CENTER_PUSH
|
||||
const dist = Math.sqrt(cdx * cdx + cdy * cdy) || 1
|
||||
p.vx += (cdx / dist) * strength
|
||||
p.vy += (cdy / dist) * strength
|
||||
}
|
||||
|
||||
// Mouse repulsion
|
||||
if (m !== null) {
|
||||
const dx = p.x - m.x
|
||||
const dy = p.y - m.y
|
||||
const distSq = dx * dx + dy * dy
|
||||
if (distSq < REPULSE_RADIUS * REPULSE_RADIUS && distSq > 0.01) {
|
||||
const dist = Math.sqrt(distSq)
|
||||
const force =
|
||||
((REPULSE_RADIUS - dist) / REPULSE_RADIUS) * REPULSE_STRENGTH
|
||||
p.vx += (dx / dist) * force
|
||||
p.vy += (dy / dist) * force
|
||||
}
|
||||
}
|
||||
|
||||
// Friction
|
||||
p.vx *= FRICTION
|
||||
p.vy *= FRICTION
|
||||
|
||||
// Maintain minimum speed — keeps emotes always drifting
|
||||
const speed = Math.sqrt(p.vx * p.vx + p.vy * p.vy)
|
||||
if (speed < MIN_SPEED) {
|
||||
if (speed > 0.001) {
|
||||
const scale = MIN_SPEED / speed
|
||||
p.vx *= scale
|
||||
p.vy *= scale
|
||||
} else {
|
||||
const angle = rand(0, Math.PI * 2)
|
||||
p.vx = Math.cos(angle) * MIN_SPEED
|
||||
p.vy = Math.sin(angle) * MIN_SPEED
|
||||
}
|
||||
} else if (speed > MAX_SPEED) {
|
||||
const ratio = MAX_SPEED / speed
|
||||
p.vx *= ratio
|
||||
p.vy *= ratio
|
||||
}
|
||||
|
||||
// Integrate position
|
||||
p.x += p.vx
|
||||
p.y += p.vy
|
||||
|
||||
// Lazily measure actual rendered width once image loads
|
||||
const el = elements[i]
|
||||
if (
|
||||
el !== undefined &&
|
||||
!p.measured &&
|
||||
el.naturalWidth > 0 &&
|
||||
el.naturalHeight > 0
|
||||
) {
|
||||
p.halfW = ((el.naturalWidth / el.naturalHeight) * EMOTE_SIZE) / 2
|
||||
p.measured = true
|
||||
}
|
||||
}
|
||||
|
||||
// Particle-particle elastic collision
|
||||
const halfH = EMOTE_SIZE / 2
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
const a = particles[i]
|
||||
if (a === undefined) continue
|
||||
const rA = Math.max(a.halfW, halfH)
|
||||
for (let j = i + 1; j < particles.length; j++) {
|
||||
const b = particles[j]
|
||||
if (b === undefined) continue
|
||||
const rB = Math.max(b.halfW, halfH)
|
||||
const dx = b.x - a.x
|
||||
const dy = b.y - a.y
|
||||
const distSq = dx * dx + dy * dy
|
||||
const minDist = rA + rB
|
||||
if (distSq < minDist * minDist && distSq > 0.01) {
|
||||
const dist = Math.sqrt(distSq)
|
||||
const nx = dx / dist
|
||||
const ny = dy / dist
|
||||
// Separate overlapping particles
|
||||
const overlap = (minDist - dist) / 2
|
||||
a.x -= nx * overlap
|
||||
a.y -= ny * overlap
|
||||
b.x += nx * overlap
|
||||
b.y += ny * overlap
|
||||
// Elastic velocity exchange along collision normal
|
||||
const dvx = a.vx - b.vx
|
||||
const dvy = a.vy - b.vy
|
||||
const dvDotN = dvx * nx + dvy * ny
|
||||
if (dvDotN > 0) {
|
||||
a.vx -= dvDotN * nx * RESTITUTION
|
||||
a.vy -= dvDotN * ny * RESTITUTION
|
||||
b.vx += dvDotN * nx * RESTITUTION
|
||||
b.vy += dvDotN * ny * RESTITUTION
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wall bounce and DOM write
|
||||
for (let i = 0; i < particles.length; i++) {
|
||||
const p = particles[i]
|
||||
const el = elements[i]
|
||||
if (p === undefined || el === undefined) continue
|
||||
|
||||
if (p.x - p.halfW < 0) {
|
||||
p.x = p.halfW
|
||||
p.vx = Math.abs(p.vx) * RESTITUTION
|
||||
} else if (p.x + p.halfW > w) {
|
||||
p.x = w - p.halfW
|
||||
p.vx = -Math.abs(p.vx) * RESTITUTION
|
||||
}
|
||||
if (p.y - halfH < 0) {
|
||||
p.y = halfH
|
||||
p.vy = Math.abs(p.vy) * RESTITUTION
|
||||
} else if (p.y + halfH > h) {
|
||||
p.y = h - halfH
|
||||
p.vy = -Math.abs(p.vy) * RESTITUTION
|
||||
}
|
||||
|
||||
// Rotation
|
||||
p.rotation += p.rotationSpeed
|
||||
|
||||
// Write to DOM — only transform, no layout thrash
|
||||
el.style.transform = `translate3d(${p.x - p.halfW}px, ${p.y - halfH}px, 0) rotate(${p.rotation}rad)`
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
// Start loop only if we have particles
|
||||
if (particles.length > 0) {
|
||||
tick()
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
cancelAnimationFrame(rafId)
|
||||
ro.disconnect()
|
||||
panel.removeEventListener('mousemove', onMouseMove)
|
||||
panel.removeEventListener('mouseleave', onMouseLeave)
|
||||
}
|
||||
}, [isDesktop])
|
||||
|
||||
if (!isDesktop) return null
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className='absolute inset-0 overflow-hidden'>
|
||||
{EMOTE_URLS.slice(0, PARTICLE_COUNT).map((url, i) => (
|
||||
<motion.img
|
||||
key={url}
|
||||
data-emote
|
||||
src={url}
|
||||
alt=''
|
||||
draggable={false}
|
||||
crossOrigin='anonymous'
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: TARGET_OPACITY }}
|
||||
transition={{ duration: 0.6, delay: i * 0.04, ease: 'easeOut' }}
|
||||
className='pointer-events-none absolute top-0 left-0 select-none'
|
||||
style={{
|
||||
height: EMOTE_SIZE,
|
||||
willChange: 'transform',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import Image, { type StaticImageData } from 'next/image';
|
||||
|
||||
interface FeatureCardProps {
|
||||
caption: string;
|
||||
screenshot: StaticImageData;
|
||||
}
|
||||
|
||||
export function FeatureCard({ caption, screenshot }: FeatureCardProps) {
|
||||
return (
|
||||
<figure className='flex flex-col items-center gap-4'>
|
||||
<Image
|
||||
className='max-h-[75vh] w-fit border border-neutral-300 dark:border-neutral-900'
|
||||
src={screenshot}
|
||||
alt={caption}
|
||||
placeholder='blur'
|
||||
/>
|
||||
<figcaption className='text-pretty text-center text-sm text-neutral-500 dark:text-neutral-400'>
|
||||
{caption}
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,19 +1,19 @@
|
|||
import { tommyLink } from '@/lib/constants';
|
||||
import { tommyLink } from '@/lib/constants'
|
||||
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className='flex w-full items-center justify-center gap-4 px-4 py-8 text-sm lg:px-0'>
|
||||
<p className='text-neutral-500 dark:text-neutral-400'>
|
||||
Designed and developed by{' '}
|
||||
<footer className='absolute inset-x-0 bottom-8 z-30 text-center'>
|
||||
<p className='text-muted-foreground/60 text-xs'>
|
||||
Made by{' '}
|
||||
<a
|
||||
className='underline'
|
||||
href={tommyLink}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className='underline underline-offset-4 transition-colors hover:text-muted-foreground'
|
||||
>
|
||||
Tommy Chow
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,144 @@
|
|||
import { donateLink, githubLink } from '@/lib/constants';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { SiBuymeacoffee, SiGithub } from 'react-icons/si';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
'use client'
|
||||
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
donateLink,
|
||||
emailAddress,
|
||||
emailLink,
|
||||
githubLink,
|
||||
} from '@/lib/constants'
|
||||
import { Copy, Github, Heart, Mail, Menu, Shield } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
function ContactDropdown() {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant='ghost' />}>
|
||||
<Mail />
|
||||
Contact
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem render={<a href={emailLink} />}>
|
||||
<Mail />
|
||||
Send email
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(emailAddress)
|
||||
toast.success('Email copied to clipboard')
|
||||
}}
|
||||
>
|
||||
<Copy />
|
||||
Copy email
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className='sticky top-0 z-50 flex w-full max-w-screen-lg justify-between gap-4 divide-x divide-neutral-300 border-b border-inherit bg-inherit dark:divide-neutral-900 dark:from-black'>
|
||||
<Link
|
||||
className='flex items-center gap-2 border-r border-inherit px-4 transition hover:bg-neutral-200 dark:hover:bg-neutral-900'
|
||||
href='/'
|
||||
>
|
||||
<div className='relative size-8'>
|
||||
<Image alt='Logo' src={`/logo.svg`} layout='fill' priority />
|
||||
<header>
|
||||
<nav className='flex items-center justify-between'>
|
||||
<Link href='/' className='flex items-center gap-2'>
|
||||
<Image src='/logo.svg' alt='' width={28} height={28} />
|
||||
<span className='font-semibold'>Frosty</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop actions */}
|
||||
<div className='hidden items-center gap-1 lg:flex'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
render={<a href={githubLink} target='_blank' rel='noreferrer' />}
|
||||
>
|
||||
<Github />
|
||||
GitHub
|
||||
</Button>
|
||||
<Button
|
||||
variant='ghost'
|
||||
render={<a href={donateLink} target='_blank' rel='noreferrer' />}
|
||||
>
|
||||
<Heart />
|
||||
Donate
|
||||
</Button>
|
||||
<Button variant='ghost' render={<Link href='/privacy' />}>
|
||||
<Shield />
|
||||
Privacy
|
||||
</Button>
|
||||
<ContactDropdown />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className='flex items-center divide-x divide-inherit'>
|
||||
<a
|
||||
className='p-4 transition hover:bg-neutral-200 dark:hover:bg-neutral-900'
|
||||
href={donateLink}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
<SiBuymeacoffee className='size-6' />
|
||||
</a>
|
||||
|
||||
<a
|
||||
className='p-4 transition hover:bg-neutral-200 dark:hover:bg-neutral-900'
|
||||
href={githubLink}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
<SiGithub className='size-6' />
|
||||
</a>
|
||||
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
{/* Mobile actions */}
|
||||
<div className='flex items-center gap-1 lg:hidden'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
render={
|
||||
<a
|
||||
href={githubLink}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
aria-label='GitHub'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Github />
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant='ghost' size='icon' aria-label='Menu' />
|
||||
}
|
||||
>
|
||||
<Menu />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<a
|
||||
href={donateLink}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Heart />
|
||||
Donate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem render={<Link href='/privacy' />}>
|
||||
<Shield />
|
||||
Privacy
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem render={<a href={emailLink} />}>
|
||||
<Mail />
|
||||
Send email
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(emailAddress)
|
||||
toast.success('Email copied to clipboard')
|
||||
}}
|
||||
>
|
||||
<Copy />
|
||||
Copy email
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,26 @@
|
|||
'use client';
|
||||
'use client'
|
||||
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
interface ProvidersProps {
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function Providers({ children }: ProvidersProps) {
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
void import('react-scan').then(({ scan }) => {
|
||||
scan({ enabled: true })
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ThemeProvider attribute='class' disableTransitionOnChange>
|
||||
{children}
|
||||
<Toaster position='top-center' />
|
||||
</ThemeProvider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +1,52 @@
|
|||
'use client';
|
||||
'use client'
|
||||
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { HiMoon, HiSun } from 'react-icons/hi2';
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Monitor, Moon, Sun } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
// eslint-disable-next-line -- intentional hydration pattern
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
if (!mounted) return <div className='size-6' />;
|
||||
|
||||
const isDarkMode = resolvedTheme === 'dark';
|
||||
if (!mounted) return <div className='size-9' />
|
||||
|
||||
return (
|
||||
<button
|
||||
className='p-4 transition hover:bg-neutral-200 dark:hover:bg-neutral-900'
|
||||
aria-label={`Toggle ${isDarkMode ? 'light mode' : 'dark mode'}`}
|
||||
onClick={() => setTheme(isDarkMode ? 'light' : 'dark')}
|
||||
>
|
||||
{isDarkMode ? (
|
||||
<HiSun className='size-6' />
|
||||
) : (
|
||||
<HiMoon className='size-6' />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label='Toggle theme'
|
||||
render={<Button variant='ghost' size='icon' />}
|
||||
>
|
||||
{theme === 'dark' && <Moon />}
|
||||
{theme === 'light' && <Sun />}
|
||||
{theme === 'system' && <Monitor />}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem onClick={() => setTheme('light')}>
|
||||
<Sun className='mr-2' />
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('dark')}>
|
||||
<Moon className='mr-2' />
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme('system')}>
|
||||
<Monitor className='mr-2' />
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
74
web/src/components/ui/accordion.tsx
Normal file
74
web/src/components/ui/accordion.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"use client"
|
||||
|
||||
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
data-slot="accordion"
|
||||
className={cn("flex w-full flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("not-last:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AccordionPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"focus-visible:ring-ring/50 focus-visible:border-ring focus-visible:after:border-ring **:data-[slot=accordion-trigger-icon]:text-muted-foreground rounded-md py-4 text-left text-sm font-medium hover:underline focus-visible:ring-3 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 group/accordion-trigger relative flex flex-1 items-start justify-between border border-transparent transition-all outline-none disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
||||
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AccordionPrimitive.Panel.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Panel
|
||||
data-slot="accordion-content"
|
||||
className="data-open:animate-accordion-down data-closed:animate-accordion-up text-sm overflow-hidden"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"pt-0 pb-4 [&_a]:hover:text-foreground h-(--accordion-panel-height) data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
57
web/src/components/ui/button.tsx
Normal file
57
web/src/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"use client"
|
||||
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-md border border-transparent bg-clip-padding text-sm font-medium focus-visible:ring-3 aria-invalid:ring-3 [&_svg:not([class*='size-'])]:size-4 inline-flex items-center justify-center whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none shrink-0 [&_svg]:shrink-0 outline-none group/button select-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline: "border-border bg-background hover:bg-muted hover:text-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 aria-expanded:bg-muted aria-expanded:text-foreground shadow-xs",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50 aria-expanded:bg-muted aria-expanded:text-foreground",
|
||||
destructive: "bg-destructive/10 hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/20 text-destructive focus-visible:border-destructive/40 dark:hover:bg-destructive/30",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
|
||||
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-9",
|
||||
"icon-xs": "size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm": "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
nativeButton,
|
||||
render,
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
nativeButton={nativeButton ?? (render != null ? false : undefined)}
|
||||
render={render}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
94
web/src/components/ui/card.tsx
Normal file
94
web/src/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn("ring-foreground/10 bg-card text-card-foreground gap-6 overflow-hidden rounded-xl py-6 text-sm shadow-xs ring-1 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl group/card flex flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"gap-1 rounded-t-xl px-6 group-data-[size=sm]/card:px-4 [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4 group/card-header @container/card-header grid auto-rows-min items-start has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("text-base leading-normal font-medium group-data-[size=sm]/card:text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6 group-data-[size=sm]/card:px-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("rounded-b-xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4 flex items-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
262
web/src/components/ui/dropdown-menu.tsx
Normal file
262
web/src/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-32 rounded-md p-1 shadow-md ring-1 duration-100 data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2 z-50 max-h-(--available-height) w-(--anchor-width) origin-(--transform-origin) overflow-x-hidden overflow-y-auto outline-none data-closed:overflow-hidden", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs font-medium data-inset:pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-sm px-2 py-1.5 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 group/dropdown-menu-item relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground gap-2 rounded-sm px-2 py-1.5 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 data-popup-open:bg-accent data-popup-open:text-accent-foreground flex cursor-default items-center outline-hidden select-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 ring-foreground/10 bg-popover text-popover-foreground min-w-[96px] rounded-md p-1 shadow-lg ring-1 duration-100 w-auto", className)}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm data-inset:pl-8 [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="absolute right-2 flex items-center justify-center pointer-events-none"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn("text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground ml-auto text-xs tracking-widest", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
13
web/src/components/ui/skeleton.tsx
Normal file
13
web/src/components/ui/skeleton.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="skeleton"
|
||||
className={cn("bg-muted rounded-md animate-pulse", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
49
web/src/components/ui/sonner.tsx
Normal file
49
web/src/components/ui/sonner.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
export const githubLink = 'https://github.com/tommyxchow/frosty';
|
||||
export const githubLink = 'https://github.com/tommyxchow/frosty'
|
||||
export const appStoreLink =
|
||||
'https://apps.apple.com/us/app/frosty-for-twitch/id1603987585';
|
||||
'https://apps.apple.com/us/app/frosty-for-twitch/id1603987585'
|
||||
export const playStoreLink =
|
||||
'https://play.google.com/store/apps/details?id=com.tommychow.frosty';
|
||||
export const twitchLink = 'https://www.twitch.tv/';
|
||||
export const bttvLink = 'https://www.betterttv.net/';
|
||||
export const ffzLink = 'https://www.frankerfacez.com/';
|
||||
export const sevenTvLink = 'https://7tv.app/';
|
||||
export const donateLink = 'https://buymeacoffee.com/tommychow';
|
||||
export const emailLink = 'mailto:contact@frostyapp.io';
|
||||
export const tommyLink = 'https://www.tommychow.com/';
|
||||
export const sourceLink = 'https://github.com/tommyxchow/frostyapp.io';
|
||||
'https://play.google.com/store/apps/details?id=com.tommychow.frosty'
|
||||
export const twitchLink = 'https://www.twitch.tv/'
|
||||
export const bttvLink = 'https://www.betterttv.net/'
|
||||
export const ffzLink = 'https://www.frankerfacez.com/'
|
||||
export const sevenTvLink = 'https://7tv.app/'
|
||||
export const donateLink = 'https://buymeacoffee.com/tommychow'
|
||||
export const emailAddress = 'contact@frostyapp.io'
|
||||
export const emailLink = `mailto:${emailAddress}`
|
||||
export const tommyLink = 'https://www.tommychow.com/'
|
||||
export const sourceLink = 'https://github.com/tommyxchow/frostyapp.io'
|
||||
|
|
|
|||
6
web/src/lib/utils.ts
Normal file
6
web/src/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue