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:
Tommy Chow 2026-02-17 22:53:43 -05:00 • committed by GitHub
parent 9119a69b17
commit f19da4151c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 10379 additions and 2175 deletions

View file

@ -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;
}
}

View file

@ -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>
);
)
}

View file

@ -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>
)
}

View file

@ -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&apos;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
View 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
View 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,
},
]
}

View 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>
)
}

View file

@ -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>
);
}

View file

@ -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>
);
)
}

View file

@ -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>
);
)
}

View file

@ -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>
);
)
}

View file

@ -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>
)
}

View 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 }

View 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 }

View 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,
}

View 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,
}

View 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 }

View 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 }

View file

@ -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
View 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))
}