diff --git a/apps/web/content/docs/dev/i18n/index.mdx b/apps/web/content/docs/dev/i18n/index.mdx index 3a5f719da..69f5598cc 100644 --- a/apps/web/content/docs/dev/i18n/index.mdx +++ b/apps/web/content/docs/dev/i18n/index.mdx @@ -163,6 +163,29 @@ export const vitNodeApiConfig = buildApiConfig({ --- +### Your app's own strings + +Copy that belongs to no package - your landing page, say - gets a namespace of its own. Pick an id no package uses, put the tree under it, and register it like any other file: + +```json title="apps/web/src/locales/site/en.json" +{ + "site": { + "home": { "title": "A home for your people" } + } +} +``` + +```ts title="apps/web/src/locales/app.ts" +export const appMessages: AppMessagesMap = { + en: { site: async () => await import('./site/en.json') }, + pl: { site: async () => await import('./site/pl.json') }, +} +``` + +`i18n:check` treats it like a package: your default-locale file is the source of truth, and every other language is checked against it for missing and leftover keys. No default-locale file at all is an error - there would be nothing to translate from. + +--- + ## Translation Architecture | Source | Role | Order | diff --git a/apps/web/src/locales/app.ts b/apps/web/src/locales/app.ts index edd67ca5a..c4961d1a0 100644 --- a/apps/web/src/locales/app.ts +++ b/apps/web/src/locales/app.ts @@ -10,8 +10,12 @@ import type { AppMessagesMap } from '@vitnode/core/lib/i18n/types' * API registers the same file through `vitnode.api.config.ts`. */ export const appMessages: AppMessagesMap = { + en: { + site: async () => await import('./site/en.json'), + }, pl: { '@vitnode/blog': async () => await import('./@vitnode/blog/pl.json'), '@vitnode/core': async () => await import('./@vitnode/core/pl.json'), + site: async () => await import('./site/pl.json'), }, } diff --git a/apps/web/src/locales/site/en.json b/apps/web/src/locales/site/en.json new file mode 100644 index 000000000..0d468d065 --- /dev/null +++ b/apps/web/src/locales/site/en.json @@ -0,0 +1,24 @@ +{ + "site": { + "home": { + "hero": { + "canary": "VitNode 2.0 is an early canary", + "description": "Publish content, bring members together and build the features your community needs on an open-source platform you control.", + "explore": "Explore what you can build", + "preview": { + "alt": "The AdminCP dashboard with a sidebar listing Core, Blog and Example plugin sections, a private notes widget and a send-notification widget.", + "members": "{count, plural, one {# member} other {# members}}", + "toast_meta": "Live toast · just now", + "toast_title": "Welcome aboard, {name}!" + }, + "see_product": "See the product", + "title": "A home for your people, built around your idea.", + "trust": { + "agents": "Made for AI coding agents", + "license": "MIT licence", + "open_source": "Open source" + } + } + } + } +} diff --git a/apps/web/src/locales/site/pl.json b/apps/web/src/locales/site/pl.json new file mode 100644 index 000000000..e3970df81 --- /dev/null +++ b/apps/web/src/locales/site/pl.json @@ -0,0 +1,24 @@ +{ + "site": { + "home": { + "hero": { + "canary": "VitNode 2.0 to wczesna wersja canary", + "description": "Publikuj treści, łącz ludzi i twórz funkcje, których potrzebuje Twoja społeczność, na platformie open source, nad którą masz pełną kontrolę.", + "explore": "Odkryj, co możesz zbudować", + "preview": { + "alt": "Pulpit AdminCP z paskiem bocznym z sekcjami Core, Blog i wtyczki Example, widżetem prywatnych notatek i widżetem wysyłania powiadomień.", + "members": "{count, plural, one {# członek} few {# członkowie} many {# członków} other {# członka}}", + "toast_meta": "Komunikat na żywo · przed chwilą", + "toast_title": "Witaj na pokładzie, {name}!" + }, + "see_product": "Zobacz produkt", + "title": "Dom dla Twojej społeczności, zbudowany wokół Twojego pomysłu.", + "trust": { + "agents": "Stworzony dla agentów AI do kodowania", + "license": "Licencja MIT", + "open_source": "Open source" + } + } + } + } +} diff --git a/apps/web/src/routes/_main/index.tsx b/apps/web/src/routes/_main/index.tsx index ea2fccaf2..e4c3d7673 100644 --- a/apps/web/src/routes/_main/index.tsx +++ b/apps/web/src/routes/_main/index.tsx @@ -1,9 +1,20 @@ import { createFileRoute } from '@tanstack/react-router' +import { intlQueryOptions } from '@vitnode/core/tanstack/i18n' import { HomeRouteContent } from '@/site/home/home-content' +import { HOME_NAMESPACES } from '@/site/home/namespaces' import { MARKETING_PAGES, marketingHead } from '@/site/marketing/metadata' export const Route = createFileRoute('/_main/')({ + loader: async ({ context }) => { + await context.queryClient.query({ + ...intlQueryOptions({ + locale: context.locale, + namespaces: HOME_NAMESPACES, + }), + staleTime: 'static', + }) + }, head: () => marketingHead(MARKETING_PAGES.home), component: HomeRouteContent, }) diff --git a/apps/web/src/site/home/home-content.tsx b/apps/web/src/site/home/home-content.tsx index b8cb33deb..c97f3bb4d 100644 --- a/apps/web/src/site/home/home-content.tsx +++ b/apps/web/src/site/home/home-content.tsx @@ -1,3 +1,6 @@ +import { RouteMessages } from '@vitnode/core/tanstack/i18n' + +import { HOME_NAMESPACES } from '@/site/home/namespaces' import { AgentsSection } from '@/site/home/sections/agents' import { CommunitySection } from '@/site/home/sections/community' import { DevelopersSection } from '@/site/home/sections/developers' @@ -5,7 +8,7 @@ import { FaqSection } from '@/site/home/sections/faq' import { FeaturesBentoSection } from '@/site/home/sections/features-bento' import { FinalCtaSection } from '@/site/home/sections/final-cta' import { HeroSection } from '@/site/home/sections/hero' -// import { MakerSection } from '@/site/home/sections/maker' +import { IntegrationsSection } from '@/site/home/sections/integrations' import { OutcomesSection } from '@/site/home/sections/outcomes' import { PluginsSection } from '@/site/home/sections/plugins' import { PoweringBySection } from '@/site/home/sections/powering-by' @@ -16,23 +19,25 @@ import { MarketingPage } from '@/site/marketing/marketing-page' import { CanaryNotice } from '@/site/marketing/shared' export const HomeRouteContent = () => ( - - - -
- -
- - - - - - - - - {/* */} - - - -
+ + + + +
+ +
+ + + + + + + + + + + + +
+
) diff --git a/apps/web/src/site/home/illustrations/hero-icon-field.tsx b/apps/web/src/site/home/illustrations/hero-icon-field.tsx new file mode 100644 index 000000000..4d80b7929 --- /dev/null +++ b/apps/web/src/site/home/illustrations/hero-icon-field.tsx @@ -0,0 +1,74 @@ +import type { LucideIcon } from 'lucide-react' + +import { + Bell, + Blocks, + Bot, + CalendarDays, + Code, + Database, + FileText, + Globe, + Image as ImageIcon, + Languages, + LayoutDashboard, + Lock, + Mail, + Plug, + Search, + ShieldCheck, + Sparkles, + Timer, + Users, + Zap, +} from 'lucide-react' + +const ICONS: LucideIcon[] = [ + Bell, + Blocks, + Bot, + CalendarDays, + Code, + Database, + FileText, + Globe, + ImageIcon, + Languages, + LayoutDashboard, + Lock, + Mail, + Plug, + Search, + ShieldCheck, + Sparkles, + Timer, + Users, + Zap, +] + +const FIELD = Array.from({ length: 26 }, (_, index) => ({ + Icon: ICONS[index % ICONS.length], + rotate: ((index * 47) % 30) - 15, + x: (index * 29.7 + 7) % 96, + y: (index * 53.3 + 11) % 92, +})) + +export const HeroIconField = () => ( +
+ {FIELD.map(({ Icon, rotate, x, y }) => ( + + ))} +
+) diff --git a/apps/web/src/site/home/illustrations/hero-orbit.tsx b/apps/web/src/site/home/illustrations/hero-orbit.tsx deleted file mode 100644 index be544f320..000000000 --- a/apps/web/src/site/home/illustrations/hero-orbit.tsx +++ /dev/null @@ -1,316 +0,0 @@ -import type { LucideIcon } from 'lucide-react' - -import { - Bell, - FileText, - Globe, - Plug, - Search, - ShieldCheck, - Sparkles, - Users, -} from 'lucide-react' - -const CX = 270 -const CY = 240 - -interface OrbitNode { - angle: number - Icon: LucideIcon -} - -interface Orbit { - duration: number - nodes: OrbitNode[] - radius: number - reverse?: boolean -} - -const ORBITS: Orbit[] = [ - { - duration: 32, - nodes: [ - { angle: 30, Icon: Users }, - { angle: 210, Icon: FileText }, - ], - radius: 92, - }, - { - duration: 52, - nodes: [ - { angle: 80, Icon: Plug }, - { angle: 200, Icon: Globe }, - { angle: 320, Icon: Bell }, - ], - radius: 146, - }, - { - duration: 76, - nodes: [ - { angle: 140, Icon: ShieldCheck }, - { angle: 20, Icon: Sparkles }, - { angle: 260, Icon: Search }, - ], - radius: 196, - reverse: true, - }, -] - -const toPoint = (radius: number, angle: number) => { - const radians = (angle * Math.PI) / 180 - - return { - x: CX + radius * Math.cos(radians), - y: CY + radius * Math.sin(radians), - } -} - -const spin = ( - duration: number, - origin: { x: number; y: number }, - reverse?: boolean, -): React.CSSProperties => ({ - animation: `${reverse ? 'mk-orbit-reverse' : 'mk-orbit'} ${duration}s linear infinite`, - transformOrigin: `${origin.x}px ${origin.y}px`, -}) - -const OrbitRing = ({ duration, nodes, radius, reverse }: Orbit) => ( - - - - {nodes.map(({ angle, Icon }) => { - const point = toPoint(radius, angle) - - return ( - - - - - - - - ) - })} - -) - -const FloatingCard = ({ - children, - delay, - height = 44, - width, - x, - y, -}: { - children: React.ReactNode - delay: number - height?: number - width: number - x: number - y: number -}) => ( - - - {children} - -) - -export const HeroOrbit = () => ( - - - - - - - - - - - - - - - {ORBITS.map((orbit) => ( - - ))} - - - - - - - - - - - - - - - - - - Hello from the admin - - - live · over WebSocket - - - - - - - - +42 members - - - joined this week - - - - - - - - New article published - - - Blog · 2 min read - - - - - - - EN · PL · ES - - - -) diff --git a/apps/web/src/site/home/illustrations/hero-preview.tsx b/apps/web/src/site/home/illustrations/hero-preview.tsx new file mode 100644 index 000000000..ea98334ef --- /dev/null +++ b/apps/web/src/site/home/illustrations/hero-preview.tsx @@ -0,0 +1,163 @@ +import { cn } from 'cn' +import { FileCode2, Sparkles } from 'lucide-react' +import { useTranslations } from 'use-intl' + +import { ScreenFrame } from '@/site/marketing/screen-frame' +import { SCREENS } from '@/site/marketing/screens' + +const MEMBERS = ['MA', 'KW', 'OL'] +const MEMBER_COUNT = 1285 + +const depthStyle = (depth: number) => + ({ '--depth': `${depth}` }) as React.CSSProperties + +const Floating = ({ + children, + className, + delay, + depth, +}: { + children: React.ReactNode + className?: string + delay: number + depth: number +}) => ( +
+
+
+ {children} +
+
+
+) + +const ToastCard = () => { + const t = useTranslations('site.home.hero.preview') + + return ( +
+ + + +
+

+ {t('toast_title', { name: 'Maya' })} +

+

+ {t('toast_meta')} +

+
+
+ ) +} + +const ConfigCard = () => ( +
+

+ + vitnode.config.ts +

+

+ plugins: [ +

+

+ blogPlugin(), +

+

+ eventsPlugin(), +

+

]

+
+) + +const MembersCard = () => { + const t = useTranslations('site.home.hero.preview') + + return ( +
+
+ {MEMBERS.map((initials, index) => ( + + {initials} + + ))} +
+

+ {t.rich('members', { + count: MEMBER_COUNT, + strong: (chunks) => ( + {chunks} + ), + })} +

+
+ ) +} + +export const HeroPreview = ({ ref }: { ref?: React.Ref }) => { + const t = useTranslations('site.home.hero.preview') + + return ( +
+
+
+ +
+
+ +
+ + + + + + + + + +
+
+ ) +} diff --git a/apps/web/src/site/home/illustrations/hero-strands.tsx b/apps/web/src/site/home/illustrations/hero-strands.tsx new file mode 100644 index 000000000..7ff6e22fc --- /dev/null +++ b/apps/web/src/site/home/illustrations/hero-strands.tsx @@ -0,0 +1,69 @@ +import { cn } from 'cn' + +const STRANDS = Array.from({ length: 22 }, (_, index) => { + const t = index / 21 + + return `M-60 ${250 + t * 70} C 380 ${40 + t * 170}, 1040 ${440 - t * 150}, 1500 ${170 + t * 90}` +}) + +const PULSES = [3, 9, 14, 19] + +const timing = (delay: number, duration: number): React.CSSProperties => ({ + animationDelay: `${delay}s`, + animationDuration: `${duration}s`, +}) + +export const HeroStrands = ({ className }: { className?: string }) => ( + + + + + + + + + + + + {STRANDS.filter((_, index) => index % 2 === 0).map((d) => ( + + ))} + + + {STRANDS.filter((_, index) => index % 2 === 1).map((d) => ( + + ))} + + {PULSES.map((index, order) => ( + + ))} + +) diff --git a/apps/web/src/site/home/namespaces.ts b/apps/web/src/site/home/namespaces.ts new file mode 100644 index 000000000..b9e5a62db --- /dev/null +++ b/apps/web/src/site/home/namespaces.ts @@ -0,0 +1 @@ +export const HOME_NAMESPACES = ['core.global', 'site.home.hero'] as const diff --git a/apps/web/src/site/home/sections/hero.tsx b/apps/web/src/site/home/sections/hero.tsx index a9f5ab173..6c8e7c752 100644 --- a/apps/web/src/site/home/sections/hero.tsx +++ b/apps/web/src/site/home/sections/hero.tsx @@ -1,67 +1,88 @@ import { Link } from '@tanstack/react-router' -import { ArrowRight, Bot, Scale, Server } from 'lucide-react' +import { buttonVariants } from '@vitnode/core/components/ui/button' +import { cn } from 'cn' +import { ArrowRight, Bird, Bot, CodeXml, Scale } from 'lucide-react' +import { useTranslations } from 'use-intl' -import { HeroOrbit } from '@/site/home/illustrations/hero-orbit' -import { MarketingActions } from '@/site/marketing/shared' +import { HeroIconField } from '@/site/home/illustrations/hero-icon-field' +import { HeroPreview } from '@/site/home/illustrations/hero-preview' +import { HeroStrands } from '@/site/home/illustrations/hero-strands' +import { usePointerParallax } from '@/site/home/use-pointer-parallax' +import { BUTTON } from '@/site/marketing/shared' const TRUST = [ - { Icon: Scale, label: 'MIT licence, free forever' }, - { Icon: Server, label: 'Self-host or bring your cloud' }, - { Icon: Bot, label: 'Made for AI coding agents' }, -] + { Icon: CodeXml, key: 'open_source' }, + { Icon: Scale, key: 'license' }, + { Icon: Bot, key: 'agents' }, +] as const -export const HeroSection = () => ( -
-
-
+export const HeroSection = () => { + const t = useTranslations('site.home.hero') + const { handlers, targetRef } = usePointerParallax() -
-
- - - - - - VitNode 2.0 Canary · a very early build - - + return ( +
+ + -

- Build the community your people{' '} - deserve. -

+
+
+

+ + {t('canary')} +

-

- VitNode is a free, open-source community framework. Members, roles, - content, real-time updates, search and an Admin Control Panel come in - the box. Your ideas go on top. Plugins hold it all together. -

+

+ {t.rich('title', { + highlight: (chunks) => ( + {chunks} + ), + })} +

- +

+ {t('description')} +

-
    - {TRUST.map(({ Icon, label }) => ( -
  • - - {label} -
  • - ))} -
-
+
+ + {t('explore')} + + + + + {t('see_product')} + +
+ +
    + {TRUST.map(({ Icon, key }) => ( +
  • + + {t(`trust.${key}`)} +
  • + ))} +
+
-
- +
-
-
-) + + ) +} diff --git a/apps/web/src/site/home/sections/integrations.tsx b/apps/web/src/site/home/sections/integrations.tsx new file mode 100644 index 000000000..4f3f8edc6 --- /dev/null +++ b/apps/web/src/site/home/sections/integrations.tsx @@ -0,0 +1,318 @@ +import type { LucideIcon } from 'lucide-react' + +import { Link } from '@tanstack/react-router' +import { cn } from 'cn' +import { + ArrowUpRight, + HardDrive, + Mail, + ShieldCheck, + Sparkles, +} from 'lucide-react' + +import { INTEGRATION_MARKS } from '@/site/home/sections/logos/integration-marks' +import { MarketingSection, SectionHeading } from '@/site/marketing/shared' + +type Destination = { docs: string } | { href: string } + +type Integration = Destination & { + blurb?: string + category?: string + color: string + featured?: boolean + glyph?: LucideIcon + highlighted?: boolean + mark?: string + name: string + short?: string +} + +const FOREGROUND = 'var(--foreground)' + +const INTEGRATIONS: Integration[] = [ + { + blurb: 'Full-stack React with type-safe routing and SSR.', + category: 'Front end', + color: FOREGROUND, + highlighted: true, + href: 'https://tanstack.com/start', + mark: INTEGRATION_MARKS.tanstack, + name: 'TanStack Start', + }, + { + blurb: 'A fast, typed API with OpenAPI docs served at /api/swagger.', + category: 'API', + color: '#e36002', + highlighted: true, + href: 'https://hono.dev/', + mark: INTEGRATION_MARKS.hono, + name: 'Hono', + }, + { + color: '#0e9f6e', + docs: 'dev/email/nodemailer', + glyph: Mail, + name: 'Nodemailer', + }, + { + color: '#ec7211', + docs: 'dev/storage/s3-r2', + glyph: HardDrive, + name: 'S3-compatible storage', + short: 'S3 / R2', + }, + { + color: '#00bfb3', + docs: 'dev/search-elasticsearch', + mark: INTEGRATION_MARKS.elasticsearch, + name: 'Elasticsearch', + }, + { + blurb: 'Full-text search out of the box, tuned per language.', + category: 'Search', + color: '#4169e1', + docs: 'dev/search', + featured: true, + mark: INTEGRATION_MARKS.postgresql, + name: 'PostgreSQL', + }, + { + color: '#06b6d4', + href: 'https://tailwindcss.com/', + mark: INTEGRATION_MARKS.tailwindcss, + name: 'Tailwind CSS', + }, + { + color: '#84a816', + href: 'https://orm.drizzle.team/', + mark: INTEGRATION_MARKS.drizzle, + name: 'Drizzle ORM', + short: 'Drizzle', + }, + { + color: '#f38020', + docs: 'dev/captcha/cloudflare', + mark: INTEGRATION_MARKS.cloudflare, + name: 'Cloudflare Turnstile', + short: 'Turnstile', + }, + { + color: '#4285f4', + docs: 'dev/captcha/recaptcha', + glyph: ShieldCheck, + name: 'Google reCAPTCHA', + short: 'reCAPTCHA', + }, + { + color: FOREGROUND, + docs: 'dev/ai', + glyph: Sparkles, + name: 'AI SDK', + }, + { + color: '#2496ed', + docs: 'dev/deployments/self-hosted', + mark: INTEGRATION_MARKS.docker, + name: 'Docker', + }, + { + color: '#5fa04e', + docs: 'dev/cron/node-cron', + mark: INTEGRATION_MARKS.nodejs, + name: 'node-cron', + }, +] + +const HIGHLIGHTED = INTEGRATIONS.filter(({ highlighted }) => highlighted) +const MOSAIC = INTEGRATIONS.filter(({ highlighted }) => !highlighted) + +const brandStyle = (color: string) => + ({ '--brand': color }) as React.CSSProperties + +const IntegrationMark = ({ + className, + integration, +}: { + className?: string + integration: Integration +}) => { + const Glyph = integration.glyph + + if (Glyph) return + + return ( + + + + ) +} + +const IntegrationLink = ({ + children, + className, + integration, +}: { + children: React.ReactNode + className: string + integration: Integration +}) => { + if ('href' in integration) + return ( + + {children} + + ) + + return ( + + {children} + + ) +} + +const CARD = + 'group focus-visible:outline-ring flex w-full rounded-3xl bg-(--brand)/8 transition-[background-color,scale] duration-200 ease-out hover:bg-(--brand)/12 focus-visible:outline-2 focus-visible:outline-offset-2 active:scale-[0.98]' + +const CardArrow = ({ className }: { className?: string }) => ( + +) + +const CardMark = ({ integration }: { integration: Integration }) => ( + + + +) + +const HighlightCard = ({ integration }: { integration: Integration }) => ( + + + + + + {integration.name} + + + {integration.category} + + + + {integration.blurb} + + + + +) + +const FeaturedCard = ({ integration }: { integration: Integration }) => ( + + + + + + + + {integration.category} + + + {integration.name} + + + {integration.blurb} + + + +) + +const IntegrationTile = ({ integration }: { integration: Integration }) => ( + + + + {integration.short ?? integration.name} + + +) + +export const IntegrationsSection = () => ( + + + Built on TanStack Start, Hono, Drizzle and Tailwind CSS, with adapters for + email, storage, search, captcha and AI. Every tile links to its guide or + homepage. + + +
+
    + {HIGHLIGHTED.map((integration) => ( +
  • + +
  • + ))} +
+ +
    + {MOSAIC.map((integration) => ( +
  • + {integration.featured ? ( + + ) : ( + + )} +
  • + ))} +
+
+
+) diff --git a/apps/web/src/site/home/sections/logos/integration-marks.ts b/apps/web/src/site/home/sections/logos/integration-marks.ts new file mode 100644 index 000000000..e80ff5797 --- /dev/null +++ b/apps/web/src/site/home/sections/logos/integration-marks.ts @@ -0,0 +1,19 @@ +export const INTEGRATION_MARKS = { + cloudflare: + 'M16.5088 16.8447c.1475-.5068.0908-.9707-.1553-1.3154-.2246-.3164-.6045-.499-1.0615-.5205l-8.6592-.1123a.1559.1559 0 0 1-.1333-.0713c-.0283-.042-.0351-.0986-.021-.1553.0278-.084.1123-.1484.2036-.1562l8.7359-.1123c1.0351-.0489 2.1601-.8868 2.5537-1.9136l.499-1.3013c.0215-.0561.0293-.1128.0147-.168-.5625-2.5463-2.835-4.4453-5.5499-4.4453-2.5039 0-4.6284 1.6177-5.3876 3.8614-.4927-.3658-1.1187-.5625-1.794-.499-1.2026.119-2.1665 1.083-2.2861 2.2856-.0283.31-.0069.6128.0635.894C1.5683 13.171 0 14.7754 0 16.752c0 .1748.0142.3515.0352.5273.0141.083.0844.1475.1689.1475h15.9814c.0909 0 .1758-.0645.2032-.1553l.12-.4268zm2.7568-5.5634c-.0771 0-.1611 0-.2383.0112-.0566 0-.1054.0415-.127.0976l-.3378 1.1744c-.1475.5068-.0918.9707.1543 1.3164.2256.3164.6055.498 1.0625.5195l1.8437.1133c.0557 0 .1055.0263.1329.0703.0283.043.0351.1074.0214.1562-.0283.084-.1132.1485-.204.1553l-1.921.1123c-1.041.0488-2.1582.8867-2.5527 1.914l-.1406.3585c-.0283.0713.0215.1416.0986.1416h6.5977c.0771 0 .1474-.0489.169-.126.1122-.4082.1757-.837.1757-1.2803 0-2.6025-2.125-4.727-4.7344-4.727', + docker: + 'M13.983 11.078h2.119a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.119a.185.185 0 00-.185.185v1.888c0 .102.083.185.185.185m-2.954-5.43h2.118a.186.186 0 00.186-.186V3.574a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m0 2.716h2.118a.187.187 0 00.186-.186V6.29a.186.186 0 00-.186-.185h-2.118a.185.185 0 00-.185.185v1.887c0 .102.082.185.185.186m-2.93 0h2.12a.186.186 0 00.184-.186V6.29a.185.185 0 00-.185-.185H8.1a.185.185 0 00-.185.185v1.887c0 .102.083.185.185.186m-2.964 0h2.119a.186.186 0 00.185-.186V6.29a.185.185 0 00-.185-.185H5.136a.186.186 0 00-.186.185v1.887c0 .102.084.185.186.186m5.893 2.715h2.118a.186.186 0 00.186-.185V9.006a.186.186 0 00-.186-.186h-2.118a.185.185 0 00-.185.185v1.888c0 .102.082.185.185.185m-2.93 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.083.185.185.185m-2.964 0h2.119a.185.185 0 00.185-.185V9.006a.185.185 0 00-.184-.186h-2.12a.186.186 0 00-.186.186v1.887c0 .102.084.185.186.185m-2.92 0h2.12a.185.185 0 00.184-.185V9.006a.185.185 0 00-.184-.186h-2.12a.185.185 0 00-.184.185v1.888c0 .102.082.185.185.185M23.763 9.89c-.065-.051-.672-.51-1.954-.51-.338.001-.676.03-1.01.087-.248-1.7-1.653-2.53-1.716-2.566l-.344-.199-.226.327c-.284.438-.49.922-.612 1.43-.23.97-.09 1.882.403 2.661-.595.332-1.55.413-1.744.42H.751a.751.751 0 00-.75.748 11.376 11.376 0 00.692 4.062c.545 1.428 1.355 2.48 2.41 3.124 1.18.723 3.1 1.137 5.275 1.137.983.003 1.963-.086 2.93-.266a12.248 12.248 0 003.823-1.389c.98-.567 1.86-1.288 2.61-2.136 1.252-1.418 1.998-2.997 2.553-4.4h.221c1.372 0 2.215-.549 2.68-1.009.309-.293.55-.65.707-1.046l.098-.288Z', + drizzle: + 'M5.353 11.823a1.036 1.036 0 0 0-.395-1.422 1.063 1.063 0 0 0-1.437.399L.138 16.702a1.035 1.035 0 0 0 .395 1.422 1.063 1.063 0 0 0 1.437-.398l3.383-5.903Zm11.216 0a1.036 1.036 0 0 0-.394-1.422 1.064 1.064 0 0 0-1.438.399l-3.382 5.902a1.036 1.036 0 0 0 .394 1.422c.506.283 1.15.104 1.438-.398l3.382-5.903Zm7.293-4.525a1.036 1.036 0 0 0-.395-1.422 1.062 1.062 0 0 0-1.437.399l-3.383 5.902a1.036 1.036 0 0 0 .395 1.422 1.063 1.063 0 0 0 1.437-.399l3.383-5.902Zm-11.219 0a1.035 1.035 0 0 0-.394-1.422 1.064 1.064 0 0 0-1.438.398l-3.382 5.903a1.036 1.036 0 0 0 .394 1.422c.506.282 1.15.104 1.438-.399l3.382-5.902Z', + elasticsearch: + 'M13.394 0C8.683 0 4.609 2.716 2.644 6.667h15.641a4.77 4.77 0 0 0 3.073-1.11c.446-.375.864-.785 1.247-1.243l.001-.002A11.974 11.974 0 0 0 13.394 0zM1.804 8.889a12.009 12.009 0 0 0 0 6.222h14.7a3.111 3.111 0 1 0 0-6.222zm.84 8.444C4.61 21.283 8.684 24 13.395 24c3.701 0 7.011-1.677 9.212-4.312l-.001-.002a9.958 9.958 0 0 0-1.247-1.243 4.77 4.77 0 0 0-3.073-1.11z', + hono: 'M12.445.002a45.529 45.529 0 0 0-5.252 8.146 8.595 8.595 0 0 1-.555-.53 27.796 27.796 0 0 0-1.205-1.542 8.762 8.762 0 0 0-1.251 2.12 20.743 20.743 0 0 0-1.448 5.88 8.867 8.867 0 0 0 .338 3.468c1.312 3.48 3.794 5.593 7.445 6.337 3.055.438 5.755-.333 8.097-2.312 2.677-2.59 3.359-5.634 2.047-9.132a33.287 33.287 0 0 0-2.988-5.59A91.34 91.34 0 0 0 12.615.053a.216.216 0 0 0-.17-.051Zm-.336 3.906a50.93 50.93 0 0 1 4.794 6.552c.448.767.817 1.57 1.108 2.41.606 2.386-.044 4.354-1.951 5.904-1.845 1.298-3.87 1.683-6.072 1.156-2.376-.737-3.75-2.335-4.121-4.794a5.107 5.107 0 0 1 .242-2.266c.358-.908.79-1.774 1.3-2.601l1.446-2.121a397.33 397.33 0 0 0 3.254-4.24Z', + nodejs: + 'M11.998,24c-0.321,0-0.641-0.084-0.922-0.247l-2.936-1.737c-0.438-0.245-0.224-0.332-0.08-0.383 c0.585-0.203,0.703-0.25,1.328-0.604c0.065-0.037,0.151-0.023,0.218,0.017l2.256,1.339c0.082,0.045,0.197,0.045,0.272,0l8.795-5.076 c0.082-0.047,0.134-0.141,0.134-0.238V6.921c0-0.099-0.053-0.192-0.137-0.242l-8.791-5.072c-0.081-0.047-0.189-0.047-0.271,0 L3.075,6.68C2.99,6.729,2.936,6.825,2.936,6.921v10.15c0,0.097,0.054,0.189,0.139,0.235l2.409,1.392 c1.307,0.654,2.108-0.116,2.108-0.89V7.787c0-0.142,0.114-0.253,0.256-0.253h1.115c0.139,0,0.255,0.112,0.255,0.253v10.021 c0,1.745-0.95,2.745-2.604,2.745c-0.508,0-0.909,0-2.026-0.551L2.28,18.675c-0.57-0.329-0.922-0.945-0.922-1.604V6.921 c0-0.659,0.353-1.275,0.922-1.603l8.795-5.082c0.557-0.315,1.296-0.315,1.848,0l8.794,5.082c0.57,0.329,0.924,0.944,0.924,1.603 v10.15c0,0.659-0.354,1.273-0.924,1.604l-8.794,5.078C12.643,23.916,12.324,24,11.998,24z M19.099,13.993 c0-1.9-1.284-2.406-3.987-2.763c-2.731-0.361-3.009-0.548-3.009-1.187c0-0.528,0.235-1.233,2.258-1.233 c1.807,0,2.473,0.389,2.747,1.607c0.024,0.115,0.129,0.199,0.247,0.199h1.141c0.071,0,0.138-0.031,0.186-0.081 c0.048-0.054,0.074-0.123,0.067-0.196c-0.177-2.098-1.571-3.076-4.388-3.076c-2.508,0-4.004,1.058-4.004,2.833 c0,1.925,1.488,2.457,3.895,2.695c2.88,0.282,3.103,0.703,3.103,1.269c0,0.983-0.789,1.402-2.642,1.402 c-2.327,0-2.839-0.584-3.011-1.742c-0.02-0.124-0.126-0.215-0.253-0.215h-1.137c-0.141,0-0.254,0.112-0.254,0.253 c0,1.482,0.806,3.248,4.655,3.248C17.501,17.007,19.099,15.91,19.099,13.993z', + postgresql: + 'M23.5594 14.7228a.5269.5269 0 0 0-.0563-.1191c-.139-.2632-.4768-.3418-1.0074-.2321-1.6533.3411-2.2935.1312-2.5256-.0191 1.342-2.0482 2.445-4.522 3.0411-6.8297.2714-1.0507.7982-3.5237.1222-4.7316a1.5641 1.5641 0 0 0-.1509-.235C21.6931.9086 19.8007.0248 17.5099.0005c-1.4947-.0158-2.7705.3461-3.1161.4794a9.449 9.449 0 0 0-.5159-.0816 8.044 8.044 0 0 0-1.3114-.1278c-1.1822-.0184-2.2038.2642-3.0498.8406-.8573-.3211-4.7888-1.645-7.2219.0788C.9359 2.1526.3086 3.8733.4302 6.3043c.0409.818.5069 3.334 1.2423 5.7436.4598 1.5065.9387 2.7019 1.4334 3.582.553.9942 1.1259 1.5933 1.7143 1.7895.4474.1491 1.1327.1441 1.8581-.7279.8012-.9635 1.5903-1.8258 1.9446-2.2069.4351.2355.9064.3625 1.39.3772a.0569.0569 0 0 0 .0004.0041 11.0312 11.0312 0 0 0-.2472.3054c-.3389.4302-.4094.5197-1.5002.7443-.3102.064-1.1344.2339-1.1464.8115-.0025.1224.0329.2309.0919.3268.2269.4231.9216.6097 1.015.6331 1.3345.3335 2.5044.092 3.3714-.6787-.017 2.231.0775 4.4174.3454 5.0874.2212.5529.7618 1.9045 2.4692 1.9043.2505 0 .5263-.0291.8296-.0941 1.7819-.3821 2.5557-1.1696 2.855-2.9059.1503-.8707.4016-2.8753.5388-4.1012.0169-.0703.0357-.1207.057-.1362.0007-.0005.0697-.0471.4272.0307a.3673.3673 0 0 0 .0443.0068l.2539.0223.0149.001c.8468.0384 1.9114-.1426 2.5312-.4308.6438-.2988 1.8057-1.0323 1.5951-1.6698zM2.371 11.8765c-.7435-2.4358-1.1779-4.8851-1.2123-5.5719-.1086-2.1714.4171-3.6829 1.5623-4.4927 1.8367-1.2986 4.8398-.5408 6.108-.13-.0032.0032-.0066.0061-.0098.0094-2.0238 2.044-1.9758 5.536-1.9708 5.7495-.0002.0823.0066.1989.0162.3593.0348.5873.0996 1.6804-.0735 2.9184-.1609 1.1504.1937 2.2764.9728 3.0892.0806.0841.1648.1631.2518.2374-.3468.3714-1.1004 1.1926-1.9025 2.1576-.5677.6825-.9597.5517-1.0886.5087-.3919-.1307-.813-.5871-1.2381-1.3223-.4796-.839-.9635-2.0317-1.4155-3.5126zm6.0072 5.0871c-.1711-.0428-.3271-.1132-.4322-.1772.0889-.0394.2374-.0902.4833-.1409 1.2833-.2641 1.4815-.4506 1.9143-1.0002.0992-.126.2116-.2687.3673-.4426a.3549.3549 0 0 0 .0737-.1298c.1708-.1513.2724-.1099.4369-.0417.156.0646.3078.26.3695.4752.0291.1016.0619.2945-.0452.4444-.9043 1.2658-2.2216 1.2494-3.1676 1.0128zm2.094-3.988-.0525.141c-.133.3566-.2567.6881-.3334 1.003-.6674-.0021-1.3168-.2872-1.8105-.8024-.6279-.6551-.9131-1.5664-.7825-2.5004.1828-1.3079.1153-2.4468.079-3.0586-.005-.0857-.0095-.1607-.0122-.2199.2957-.2621 1.6659-.9962 2.6429-.7724.4459.1022.7176.4057.8305.928.5846 2.7038.0774 3.8307-.3302 4.7363-.084.1866-.1633.3629-.2311.5454zm7.3637 4.5725c-.0169.1768-.0358.376-.0618.5959l-.146.4383a.3547.3547 0 0 0-.0182.1077c-.0059.4747-.054.6489-.115.8693-.0634.2292-.1353.4891-.1794 1.0575-.11 1.4143-.8782 2.2267-2.4172 2.5565-1.5155.3251-1.7843-.4968-2.0212-1.2217a6.5824 6.5824 0 0 0-.0769-.2266c-.2154-.5858-.1911-1.4119-.1574-2.5551.0165-.5612-.0249-1.9013-.3302-2.6462.0044-.2932.0106-.5909.019-.8918a.3529.3529 0 0 0-.0153-.1126 1.4927 1.4927 0 0 0-.0439-.208c-.1226-.4283-.4213-.7866-.7797-.9351-.1424-.059-.4038-.1672-.7178-.0869.067-.276.1831-.5875.309-.9249l.0529-.142c.0595-.16.134-.3257.213-.5012.4265-.9476 1.0106-2.2453.3766-5.1772-.2374-1.0981-1.0304-1.6343-2.2324-1.5098-.7207.0746-1.3799.3654-1.7088.5321a5.6716 5.6716 0 0 0-.1958.1041c.0918-1.1064.4386-3.1741 1.7357-4.4823a4.0306 4.0306 0 0 1 .3033-.276.3532.3532 0 0 0 .1447-.0644c.7524-.5706 1.6945-.8506 2.802-.8325.4091.0067.8017.0339 1.1742.081 1.939.3544 3.2439 1.4468 4.0359 2.3827.8143.9623 1.2552 1.9315 1.4312 2.4543-1.3232-.1346-2.2234.1268-2.6797.779-.9926 1.4189.543 4.1729 1.2811 5.4964.1353.2426.2522.4522.2889.5413.2403.5825.5515.9713.7787 1.2552.0696.087.1372.1714.1885.245-.4008.1155-1.1208.3825-1.0552 1.717-.0123.1563-.0423.4469-.0834.8148-.0461.2077-.0702.4603-.0994.7662zm.8905-1.6211c-.0405-.8316.2691-.9185.5967-1.0105a2.8566 2.8566 0 0 0 .135-.0406 1.202 1.202 0 0 0 .1342.103c.5703.3765 1.5823.4213 3.0068.1344-.2016.1769-.5189.3994-.9533.6011-.4098.1903-1.0957.333-1.7473.3636-.7197.0336-1.0859-.0807-1.1721-.151zm.5695-9.2712c-.0059.3508-.0542.6692-.1054 1.0017-.055.3576-.112.7274-.1264 1.1762-.0142.4368.0404.8909.0932 1.3301.1066.887.216 1.8003-.2075 2.7014a3.5272 3.5272 0 0 1-.1876-.3856c-.0527-.1276-.1669-.3326-.3251-.6162-.6156-1.1041-2.0574-3.6896-1.3193-4.7446.3795-.5427 1.3408-.5661 2.1781-.463zm.2284 7.0137a12.3762 12.3762 0 0 0-.0853-.1074l-.0355-.0444c.7262-1.1995.5842-2.3862.4578-3.4385-.0519-.4318-.1009-.8396-.0885-1.2226.0129-.4061.0666-.7543.1185-1.0911.0639-.415.1288-.8443.1109-1.3505.0134-.0531.0188-.1158.0118-.1902-.0457-.4855-.5999-1.938-1.7294-3.253-.6076-.7073-1.4896-1.4972-2.6889-2.0395.5251-.1066 1.2328-.2035 2.0244-.1859 2.0515.0456 3.6746.8135 4.8242 2.2824a.908.908 0 0 1 .0667.1002c.7231 1.3556-.2762 6.2751-2.9867 10.5405zm-8.8166-6.1162c-.025.1794-.3089.4225-.6211.4225a.5821.5821 0 0 1-.0809-.0056c-.1873-.026-.3765-.144-.5059-.3156-.0458-.0605-.1203-.178-.1055-.2844.0055-.0401.0261-.0985.0925-.1488.1182-.0894.3518-.1226.6096-.0867.3163.0441.6426.1938.6113.4186zm7.9305-.4114c.0111.0792-.049.201-.1531.3102-.0683.0717-.212.1961-.4079.2232a.5456.5456 0 0 1-.075.0052c-.2935 0-.5414-.2344-.5607-.3717-.024-.1765.2641-.3106.5611-.352.297-.0414.6111.0088.6356.1851z', + tailwindcss: + 'M12.001,4.8c-3.2,0-5.2,1.6-6,4.8c1.2-1.6,2.6-2.2,4.2-1.8c0.913,0.228,1.565,0.89,2.288,1.624 C13.666,10.618,15.027,12,18.001,12c3.2,0,5.2-1.6,6-4.8c-1.2,1.6-2.6,2.2-4.2,1.8c-0.913-0.228-1.565-0.89-2.288-1.624 C16.337,6.182,14.976,4.8,12.001,4.8z M6.001,12c-3.2,0-5.2,1.6-6,4.8c1.2-1.6,2.6-2.2,4.2-1.8c0.913,0.228,1.565,0.89,2.288,1.624 c1.177,1.194,2.538,2.576,5.512,2.576c3.2,0,5.2-1.6,6-4.8c-1.2,1.6-2.6,2.2-4.2,1.8c-0.913-0.228-1.565-0.89-2.288-1.624 C10.337,13.382,8.976,12,6.001,12z', + tanstack: + 'M12 0c6.627 0 9.166 4.102 9.166 12S18.626 24 12 24s-9.166-4.096-9.166-12c0-7.898 2.54-12 9.166-12m3.031 17.485c-.861 0-1.33.234-1.708.423-.327.164-.582.292-1.148.292-.567 0-.822-.128-1.148-.292-.378-.189-.848-.423-1.71-.423-.86 0-1.33.234-1.708.423-.327.164-.581.292-1.148.292v1.251c.862 0 1.331-.234 1.709-.423.326-.163.581-.292 1.148-.292s.821.129 1.148.292c.378.189.847.423 1.709.423.861 0 1.33-.234 1.709-.423.326-.163.58-.292 1.147-.292s.822.129 1.148.292c.378.189.848.423 1.71.423v-1.25c-.565 0-.822-.13-1.149-.293-.377-.189-.847-.423-1.709-.423m.41-12.536c.65-.586 0-1.648-.813-1.328-.45.18-.873.438-1.251.779a4.2 4.2 0 0 0-1.202 1.94 4.2 4.2 0 0 0-1.203-1.94 4.3 4.3 0 0 0-1.25-.779c-.814-.32-1.463.742-.814 1.328l2.385 2.153a4.86 4.86 0 0 0-2.731-.839c-.552 0-1.082.09-1.58.26-.836.284-.604 1.532.275 1.532h3.326a4.2 4.2 0 0 0-2.012.988 4 4 0 0 0-.9 1.165c-.403.776.588 1.529 1.237.948l2.686-2.42-.2 6.656c0 .08-.047.158-.107.223a5 5 0 0 1-.257-.123c-.378-.189-.848-.423-1.71-.423-.861 0-1.33.234-1.708.423-.327.164-.581.292-1.148.292v1.251c.861 0 1.33-.234 1.709-.423.326-.164.58-.292 1.148-.292.566 0 .821.128 1.148.292.377.189.847.423 1.708.423.862 0 1.332-.234 1.71-.423.326-.164.58-.292 1.147-.292s.822.128 1.148.292c.378.189.848.423 1.71.423v-1.25c-.565 0-.822-.13-1.149-.293v-.005c-.378-.19-.847-.424-1.709-.424-.861 0-1.33.235-1.709.424-.097.045-.189.094-.283.131a.34.34 0 0 1-.12-.232l-.2-6.69 2.722 2.457c.65.587 1.64-.166 1.236-.948a4.11 4.11 0 0 0-2.911-2.153h3.326c.882 0 1.108-1.245.275-1.531a4.88 4.88 0 0 0-4.311.578l2.385-2.152z', +} as const diff --git a/apps/web/src/site/home/sections/maker.tsx b/apps/web/src/site/home/sections/maker.tsx deleted file mode 100644 index cfbbb488b..000000000 --- a/apps/web/src/site/home/sections/maker.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import { Coffee, Heart } from 'lucide-react' - -import { - AUTHOR_AVATAR_URL, - AUTHOR_NAME, - AUTHOR_URL, - REPOSITORY_URL, -} from '@/site/marketing/links' -import { Eyebrow, GitHubIcon, MarketingSection } from '@/site/marketing/shared' - -const FACTS = [ - { label: 'human building in the open', value: '1' }, - { label: 'investors, licence fees, dark patterns', value: '0' }, - { label: 'coffee consumed per release', value: '∞' }, -] - -export const MakerSection = () => ( - -
-
- {`${AUTHOR_NAME}, - - Made by a human - -
- -
- A note from the maker -

- Hey, I’m Maciej. I got tired of rebuilding the same admin panel. -

-
-

- I have spent years building community platforms, and every single - one started the same way: a login form, a users table, a roles - screen, a permissions system, translations, and three weeks of glue - before anyone saw a feature. At some point you either accept it or - you fix it. -

-

- VitNode is me fixing it. Open source, opinionated in the useful - places, and built so the interesting work happens in plugins. It is - early and it is honest about that. If you build communities for a - living, or you just want yours to be truly yours, I would love your - bug reports, your feedback and your weird plugin ideas. -

-
- -
- {FACTS.map(({ label, value }) => ( -
-
{label}
-
{value}
-
- ))} -
- - -
-
-
-) diff --git a/apps/web/src/site/home/use-pointer-parallax.ts b/apps/web/src/site/home/use-pointer-parallax.ts new file mode 100644 index 000000000..ae213754a --- /dev/null +++ b/apps/web/src/site/home/use-pointer-parallax.ts @@ -0,0 +1,34 @@ +import { useRef } from 'react' + +const prefersReducedMotion = () => + window.matchMedia('(prefers-reduced-motion: reduce)').matches + +export const usePointerParallax = () => { + const targetRef = useRef(null) + const frameRef = useRef(0) + + const setOffset = (x: number, y: number) => { + cancelAnimationFrame(frameRef.current) + frameRef.current = requestAnimationFrame(() => { + targetRef.current?.style.setProperty('--px', `${x}`) + targetRef.current?.style.setProperty('--py', `${y}`) + }) + } + + const onPointerMove = (event: React.PointerEvent) => { + if (event.pointerType !== 'mouse' || prefersReducedMotion()) return + + const bounds = event.currentTarget.getBoundingClientRect() + + setOffset( + -((event.clientX - bounds.left) / bounds.width - 0.5) * 2, + -((event.clientY - bounds.top) / bounds.height - 0.5) * 2, + ) + } + + const onPointerLeave = () => { + setOffset(0, 0) + } + + return { handlers: { onPointerLeave, onPointerMove }, targetRef } +} diff --git a/apps/web/src/site/marketing/marketing.css b/apps/web/src/site/marketing/marketing.css index 574d5dd21..40d9e5551 100644 --- a/apps/web/src/site/marketing/marketing.css +++ b/apps/web/src/site/marketing/marketing.css @@ -26,17 +26,6 @@ } } -@keyframes mk-pulse-ring { - 0% { - transform: scale(0.7); - opacity: 0.7; - } - 100% { - transform: scale(1.7); - opacity: 0; - } -} - @keyframes mk-breathe { 0%, 100% { @@ -308,10 +297,6 @@ animation: mk-dash 1.4s linear infinite; } -.mk-anim-pulse-ring { - animation: mk-pulse-ring 3s ease-out infinite; -} - .mk-anim-breathe { animation: mk-breathe 6s ease-in-out infinite; } @@ -813,3 +798,129 @@ .mk-anim-bot-block { animation: mk-bot-block 6s ease-in-out infinite; } + +@keyframes mk-sway { + 0%, + 100% { + transform: translateY(0) scaleY(1); + } + 50% { + transform: translateY(-16px) scaleY(1.1); + } +} + +.mk-anim-sway { + transform-box: fill-box; + transform-origin: center; + animation: mk-sway 14s ease-in-out infinite; +} + +@keyframes mk-flow { + to { + stroke-dashoffset: -1; + } +} + +.mk-anim-flow { + stroke-dasharray: 0.07 0.93; + animation: mk-flow 5s linear infinite; +} + +@keyframes mk-enter-fade { + from { + opacity: 0; + transform: translateY(6px); + } +} + +.mk-enter-fade { + animation: mk-enter-fade 600ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +@keyframes mk-enter-pop { + from { + opacity: 0; + transform: scale(0.94); + } +} + +.mk-enter-pop { + animation: mk-enter-pop 500ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +.mk-parallax { + transform: translate3d( + calc(var(--px, 0) * var(--depth, 10) * 1px), + calc(var(--py, 0) * var(--depth, 10) * 1px), + 0 + ); + transition: transform 700ms cubic-bezier(0.23, 1, 0.32, 1); +} + +@media (prefers-reduced-motion: reduce) { + .mk-enter-fade, + .mk-enter-pop { + animation: none; + } + + .mk-parallax { + transition: none; + } +} + +.mk-integration-tile { + background-color: var(--card); + color: var(--brand); + box-shadow: + 0 0 0 1px rgb(0 0 0 / 0.06), + 0 1px 2px rgb(0 0 0 / 0.04), + 0 8px 20px -10px rgb(0 0 0 / 0.1); + transition: + background-color 200ms ease, + box-shadow 200ms ease, + scale 160ms ease-out; + touch-action: manipulation; +} + +.dark .mk-integration-tile { + box-shadow: 0 0 0 1px rgb(255 255 255 / 0.09); +} + +.mk-integration-mark { + transition: scale 240ms cubic-bezier(0.23, 1, 0.32, 1); +} + +@media (hover: hover) and (pointer: fine) { + .mk-integration-tile:hover { + background-color: color-mix(in oklch, var(--brand) 8%, var(--card)); + box-shadow: + 0 0 0 1px color-mix(in oklch, var(--brand) 40%, transparent), + 0 2px 4px rgb(0 0 0 / 0.04), + 0 14px 30px -12px color-mix(in oklch, var(--brand) 50%, transparent); + } + + .mk-integration-tile:hover .mk-integration-mark { + scale: 1.06; + } +} + +.mk-integration-tile:active { + scale: 0.96; +} + +.mk-integration-tile:focus-visible { + outline: 2px solid var(--ring); + outline-offset: 3px; +} + +@media (prefers-reduced-motion: reduce) { + .mk-integration-tile, + .mk-integration-mark { + transition: none; + } + + .mk-integration-tile:active, + .mk-integration-tile:hover .mk-integration-mark { + scale: none; + } +} diff --git a/packages/vitnode/scripts/get-config.ts b/packages/vitnode/scripts/get-config.ts index 7e9d5ea63..abc9540dd 100644 --- a/packages/vitnode/scripts/get-config.ts +++ b/packages/vitnode/scripts/get-config.ts @@ -1,7 +1,7 @@ /* eslint-disable no-console */ import { createJiti } from "jiti"; import { existsSync, readdirSync, statSync } from "node:fs"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import type { VitNodeApiConfig, @@ -112,6 +112,7 @@ export async function getConfig({ const configVarName = CONFIG_EXPORTS[type]; const jiti = createJiti(import.meta.url, { + alias: { "@/": `${dirname(configPath)}/` }, interopDefault: true, }); diff --git a/packages/vitnode/scripts/i18n-check.test.ts b/packages/vitnode/scripts/i18n-check.test.ts new file mode 100644 index 000000000..dd47a0dfc --- /dev/null +++ b/packages/vitnode/scripts/i18n-check.test.ts @@ -0,0 +1,96 @@ +// @vitest-environment node +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { i18nCheck } from "./i18n-check"; + +const writeApp = ( + root: string, + files: Record>, +) => { + mkdirSync(join(root, "src"), { recursive: true }); + writeFileSync(join(root, "package.json"), "{}"); + writeFileSync( + join(root, "src", "vitnode.config.ts"), + `export const vitNodeConfig = { i18n: { defaultLocale: "en", locales: [{ code: "en" }, { code: "pl" }] }, plugins: [] }`, + ); + mkdirSync(join(root, "src", "locales", "site"), { recursive: true }); + writeFileSync( + join(root, "src", "locales", "app.ts"), + `export const appMessages = { en: { site: () => null }, pl: { site: () => null } }`, + ); + writeFileSync( + join(root, "src", "vitnode.server.config.ts"), + `import { appMessages } from "@/locales/app"\nexport const vitNodeServerConfig = { messages: appMessages }`, + ); + + for (const [locale, tree] of Object.entries(files)) { + writeFileSync( + join(root, "src", "locales", "site", `${locale}.json`), + JSON.stringify(tree), + ); + } +}; + +describe("i18nCheck with an app-owned namespace", () => { + let root: string; + let output: string[]; + let exit: ReturnType; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "vitnode-i18n-check-")); + output = []; + vi.spyOn(process, "cwd").mockReturnValue(root); + vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { + output.push(args.join(" ")); + }); + exit = vi + .spyOn(process, "exit") + .mockImplementation((() => undefined) as never); + }); + + afterEach(() => { + vi.restoreAllMocks(); + rmSync(root, { force: true, recursive: true }); + }); + + it("lists keys the app's Polish file is missing and fails under --ci", async () => { + writeApp(root, { + en: { site: { hero: { title: "Title", cta: "Explore" } } }, + pl: { site: { hero: { title: "Tytuł" } } }, + }); + + await i18nCheck("--ci"); + + const report = output.join("\n"); + expect(report).toContain("site · pl: 1 key(s) missing"); + expect(report).toContain("site.hero.cta"); + expect(exit).toHaveBeenCalledWith(1); + }); + + it("reports a complete translation", async () => { + writeApp(root, { + en: { site: { hero: { title: "Title" } } }, + pl: { site: { hero: { title: "Tytuł" } } }, + }); + + await i18nCheck(); + + const report = output.join("\n"); + expect(report).toContain("site · pl: complete"); + expect(report).not.toContain("never loaded"); + }); + + it("errors when the namespace has no default-locale file", async () => { + writeApp(root, { pl: { site: { hero: { title: "Tytuł" } } } }); + + await i18nCheck(); + + expect(output.join("\n")).toContain( + 'site: no "en" messages - create src/locales/site/en.json', + ); + expect(exit).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/vitnode/scripts/i18n-check.ts b/packages/vitnode/scripts/i18n-check.ts index 385e1aced..30a4fce3f 100644 --- a/packages/vitnode/scripts/i18n-check.ts +++ b/packages/vitnode/scripts/i18n-check.ts @@ -3,7 +3,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join, relative } from "node:path"; import { getConfig } from "./get-config.js"; -import { appScope, packageLocaleFiles } from "./i18n-shared.js"; +import { appOwnedIds, appScope, packageLocaleFiles } from "./i18n-shared.js"; import { findRepoRoot } from "./shared/file-utils.js"; const CORE_PLUGIN_ID = "@vitnode/core"; @@ -95,7 +95,7 @@ export const i18nCheck = async (flag?: string) => { const appMessages = serverConfig?.messages ?? config.i18n?.messages ?? {}; // Web and API plugins differ in everything but the id, which is all we need; // union across both configs so an API-only plugin is still checked. - const pluginIds = [ + const packageIds = [ ...new Set([ CORE_PLUGIN_ID, ...[webConfig, apiConfig].flatMap(loaded => @@ -107,12 +107,14 @@ export const i18nCheck = async (flag?: string) => { ]; const appFiles = readAppLocaleFiles(appDir); + const appIds = new Set(appOwnedIds(appFiles, packageIds)); + const pluginIds = [...packageIds, ...appIds]; const locales = [ ...new Set([...declared, ...appFiles.map(file => file.locale)]), ].filter(locale => locale !== defaultLocale); console.log( - `\x1b[34m[VitNode]\x1b[0m Checking ${pluginIds.length} package(s) against ${locales.length || "no"} extra locale(s), default ${dim(defaultLocale)}.`, + `\x1b[34m[VitNode]\x1b[0m Checking ${packageIds.length} package(s) and ${appIds.size} app namespace(s) against ${locales.length || "no"} extra locale(s), default ${dim(defaultLocale)}.`, ); let missingTotal = 0; @@ -146,6 +148,17 @@ export const i18nCheck = async (flag?: string) => { for (const pluginId of pluginIds) { const baseKeys = keysFor(pluginId, defaultLocale); + if (!baseKeys && appIds.has(pluginId)) { + errors += 1; + problems += 1; + console.log( + red( + ` ${pluginId}: no "${defaultLocale}" messages - create src/locales/${pluginId}/${defaultLocale}.json, every other language is checked against it`, + ), + ); + continue; + } + if (!baseKeys) { // `packageLocaleFiles` returns paths only when the package resolves, so an // empty list means it is genuinely absent. A resolved package with no diff --git a/packages/vitnode/scripts/i18n-shared.ts b/packages/vitnode/scripts/i18n-shared.ts index 77e0bdc2e..983d8fe10 100644 --- a/packages/vitnode/scripts/i18n-shared.ts +++ b/packages/vitnode/scripts/i18n-shared.ts @@ -42,6 +42,17 @@ export const packageLocaleFiles = ( return files; }; +export const appOwnedIds = ( + appFiles: readonly { pluginId: string }[], + packageIds: readonly string[], +): string[] => { + const packages = new Set(packageIds); + + return [...new Set(appFiles.map(file => file.pluginId))] + .filter(pluginId => !packages.has(pluginId)) + .sort((a, b) => a.localeCompare(b)); +}; + export const readJsonTree = (filePath: string): Record => { if (!existsSync(filePath)) return {};