├── .env.example ├── .gitignore ├── .prettierrc ├── .scripts ├── open-analyze.cjs └── start.cjs ├── .vscode └── settings.json ├── README.md ├── app ├── app.css ├── components │ ├── footer.tsx │ └── header.tsx ├── hooks │ ├── use-device-detect.ts │ └── use-hydrated.ts ├── lib │ ├── constants.ts │ ├── gsap │ │ ├── effects.ts │ │ ├── index.ts │ │ └── types │ │ │ ├── effects.d.ts │ │ │ └── global.d.ts │ └── utils │ │ ├── breakpoints.ts │ │ ├── cn.ts │ │ ├── image.ts │ │ ├── index.ts │ │ ├── links.ts │ │ ├── logger.ts │ │ └── meta.ts ├── root.tsx ├── routes.ts └── routes │ ├── about.tsx │ └── home.tsx ├── env.d.ts ├── eslint.config.mjs ├── package.json ├── pnpm-lock.yaml ├── public ├── JOYCO.png ├── about │ └── opengraph-image.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── banner.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── logo.svg ├── opengraph-image.png └── site.webmanifest ├── react-router.config.ts ├── tailwind.config.ts ├── tsconfig.json └── vite.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | VITE_SITE_URL=http://localhost:3000 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /node_modules/ 3 | 4 | # React Router 5 | /.react-router/ 6 | /build/ 7 | 8 | # Vercel 9 | /.vercel/ 10 | 11 | # Environment 12 | .env 13 | .env.local 14 | .env.development 15 | .env.test 16 | .env.production 17 | !.env.example 18 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "printWidth": 120, 6 | "tabWidth": 2 7 | } -------------------------------------------------------------------------------- /.scripts/open-analyze.cjs: -------------------------------------------------------------------------------- 1 | const child_process = require('child_process') 2 | const os = require('os') 3 | const path = require('path') 4 | 5 | const MS = 'microsoft' 6 | 7 | function ensureCommander(platform) { 8 | switch (platform) { 9 | case 'linux': { 10 | if (os.release().toLocaleLowerCase().indexOf(MS) !== -1) { 11 | return ['win32', 'cmd.exe'] 12 | } 13 | return ['linux', 'xdg-open'] 14 | } 15 | case 'win32': 16 | return ['win32', 'cmd.exe'] 17 | case 'darwin': 18 | return ['darwin', 'open'] 19 | default: 20 | return [platform, 'xdg-open'] 21 | } 22 | } 23 | 24 | function opener(argvs) { 25 | const [platform, command] = ensureCommander(os.platform()) 26 | 27 | // https://stackoverflow.com/questions/154075/using-the-start-command-with-parameters-passed-to-the-started-program/154090#154090 28 | if (platform === 'win32') { 29 | argvs = argvs.map((arg) => arg.replace(/[&^]/g, '^$&')) 30 | argvs = ['/c', 'start', '""'].concat(argvs) 31 | } 32 | 33 | return child_process.spawn(command, argvs) 34 | } 35 | 36 | const statsPath = path.resolve(__dirname, '../build/client/stats.html') 37 | 38 | console.log(statsPath) 39 | 40 | opener([statsPath]) 41 | -------------------------------------------------------------------------------- /.scripts/start.cjs: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const { spawn } = require('child_process') 4 | 5 | // Path to the JSON file 6 | const jsonFilePath = path.resolve(__dirname, '../.vercel/react-router-build-result.json') 7 | 8 | // Read and parse the JSON file 9 | fs.readFile(jsonFilePath, 'utf8', (err, data) => { 10 | if (err) { 11 | console.error('Error reading the JSON file:', err) 12 | process.exit(1) 13 | } 14 | 15 | try { 16 | const buildResult = JSON.parse(data) 17 | const serverBundles = buildResult.buildManifest.serverBundles 18 | 19 | // Assuming you want to start the first server bundle found 20 | const firstBundleKey = Object.keys(serverBundles)[0] 21 | const serverBundle = serverBundles[firstBundleKey] 22 | const serverFilePath = path.resolve(__dirname, '../', serverBundle.file) 23 | 24 | // Start the server and pipe output to the CLI 25 | const serverProcess = spawn('react-router-serve', [serverFilePath], { stdio: 'inherit' }) 26 | 27 | serverProcess.on('error', (err) => { 28 | console.error('Error starting the server:', err) 29 | }) 30 | 31 | serverProcess.on('close', (code) => { 32 | console.log(`Server process exited with code ${code}`) 33 | }) 34 | } catch (parseError) { 35 | console.error('Error parsing the JSON file:', parseError) 36 | process.exit(1) 37 | } 38 | }) 39 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # JOYCO Logo  JOYCO RRv7 2 | 3 | ![banner.png](./public/banner.png) 4 | 5 | The JOYCO `React Router v7` + `React 19` + `React Compiler` ready template to power your next project. 6 | 7 | ## Features 8 | 9 | - 🚀 Quick Setup 10 | - ⚛ React 19 + React Compiler Ready 11 | - 🤓 Preconfigured Eslint + Prettier 12 | - 🪄 Page Transitions 13 | - 🦸‍♂️ GSAP Setup 14 | - 🖌️ Tailwind Setup 15 | - ▲ Vercel Compatible 16 | - 🔎 Bundle Analyzer 17 | 18 | ## Handy utils 19 | - `generateMeta()` at `/app/lib/meta` | Generates meta tags through a type safe interface with great defaults. 20 | - `generateLinks()` at `/app/lib/links` | Same as above but for link tags, optimized for fonts, prefetching and essential page data. -------------------------------------------------------------------------------- /app/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --header-height: 48px; 7 | 8 | @screen md { 9 | --header-height: 72px; 10 | } 11 | } 12 | 13 | html, 14 | body { 15 | font-family: 'Barlow Condensed', sans-serif; 16 | font-display: swap; 17 | text-rendering: geometricprecision; 18 | text-size-adjust: 100%; 19 | -webkit-font-smoothing: antialiased; 20 | -moz-font-smoothing: antialiased; 21 | -moz-osx-font-smoothing: grayscale; 22 | 23 | @apply text-primary bg-background; 24 | 25 | @media (prefers-color-scheme: dark) { 26 | color-scheme: dark; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/components/footer.tsx: -------------------------------------------------------------------------------- 1 | import type { loader } from '@/root' 2 | import { usePreservedLoaderData } from '@joycostudio/transitions' 3 | import { Link } from 'react-router' 4 | 5 | export default function Footer() { 6 | const { rebelLog } = usePreservedLoaderData() 7 | 8 | return ( 9 |
10 | 15 | {rebelLog} 16 | 17 |
18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /app/components/header.tsx: -------------------------------------------------------------------------------- 1 | import type { loader } from '@/root' 2 | import routes from '@/routes' 3 | import { usePreservedLoaderData } from '@joycostudio/transitions' 4 | import { Link } from 'react-router' 5 | 6 | export const Header = () => { 7 | const { mediaLinks } = usePreservedLoaderData() 8 | 9 | return ( 10 |
11 |
12 | 13 | Rebels logo 14 | 15 | 24 |
25 | 26 |
    27 | {mediaLinks.map((link, index) => ( 28 |
  • 29 | 30 | {link.label} 31 | 32 |
  • 33 | ))} 34 |
35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /app/hooks/use-device-detect.ts: -------------------------------------------------------------------------------- 1 | import * as ReactDeviceDetect from 'react-device-detect' 2 | 3 | import { useHydrated } from './use-hydrated' 4 | 5 | type DD = { 6 | isMobile?: boolean 7 | isTablet?: boolean 8 | isDesktop?: boolean 9 | isMobileSafari?: boolean 10 | isMobileOnly?: boolean 11 | isSafari?: boolean 12 | isChrome?: boolean 13 | isFirefox?: boolean 14 | isMacOs?: boolean 15 | isWindows?: boolean 16 | isIOS?: boolean 17 | isAndroid?: boolean 18 | isBrowser?: boolean 19 | isTouch?: boolean 20 | } 21 | 22 | function getDD() { 23 | const isTouchDevice = 24 | 'ontouchstart' in window || 25 | navigator.maxTouchPoints > 0 || 26 | // @ts-expect-error - this is a legacy property 27 | navigator.msMaxTouchPoints > 0 28 | 29 | const isIpadPro = ReactDeviceDetect.isDesktop && ReactDeviceDetect.isSafari && isTouchDevice 30 | 31 | return { 32 | isDesktop: ReactDeviceDetect.isDesktop && !isIpadPro, 33 | isMobile: ReactDeviceDetect.isMobile || isIpadPro, 34 | isMobileOnly: ReactDeviceDetect.isMobileOnly, 35 | isMobileSafari: ReactDeviceDetect.isMobileSafari, 36 | isTablet: ReactDeviceDetect.isTablet || isIpadPro, 37 | isChrome: ReactDeviceDetect.isChrome, 38 | isFirefox: ReactDeviceDetect.isFirefox, 39 | isSafari: ReactDeviceDetect.isSafari, 40 | isMacOs: ReactDeviceDetect.isMacOs, 41 | isWindows: ReactDeviceDetect.isWindows, 42 | isIOS: ReactDeviceDetect.isIOS, 43 | isAndroid: ReactDeviceDetect.isAndroid, 44 | isBrowser: ReactDeviceDetect.isBrowser, 45 | isTouch: isTouchDevice, 46 | } 47 | } 48 | 49 | export const useDeviceDetect = (): DD => { 50 | const isHydrated = useHydrated() 51 | 52 | if (!isHydrated) { 53 | return {} 54 | } 55 | 56 | return getDD() 57 | } 58 | -------------------------------------------------------------------------------- /app/hooks/use-hydrated.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | let hydrated = false 4 | 5 | export const useHydrated = () => { 6 | const [, setIsHydrated] = useState(false); 7 | 8 | useEffect(() => { 9 | if (!hydrated) { 10 | hydrated = true; 11 | setIsHydrated(true); 12 | } 13 | }, []); 14 | 15 | return hydrated; 16 | }; 17 | -------------------------------------------------------------------------------- /app/lib/constants.ts: -------------------------------------------------------------------------------- 1 | import { prependProtocol } from '@/lib/utils' 2 | 3 | export const isServer = typeof window === 'undefined' 4 | 5 | export const isClient = typeof window !== 'undefined' 6 | 7 | export const isDevelopment = __vercel.env === 'development' || import.meta.env.NODE_ENV === 'development' 8 | 9 | export const isProduction = __vercel.env === 'production' || import.meta.env.NODE_ENV === 'production' 10 | 11 | export const SITE_URL = prependProtocol(__vercel.url || import.meta.env.VITE_SITE_URL) 12 | 13 | if (!SITE_URL) { 14 | console.error('VITE_SITE_URL is not set! This could break stuff like opengraph data.') 15 | } 16 | 17 | export const WATERMARK = ` 18 | .;5####57.. 19 | .5#########;. 20 | ;########### 21 | ;###########. 22 | .;#######N5. 23 | .;;;.. .;75557.. .;;;. 24 | .5######; .;######5. 25 | #########; ;######### 26 | ##########.. ..########## 27 | ;##########; ;##########; 28 | .7##########5;. .;5#########N7 29 | .7############7;.. .;7#N##########7. 30 | ;###############5577777755#############N#;. 31 | .7####################################7. 32 | ..;5#N############################5;. 33 | .;7########################7;.. 34 | .;;755##########557;;... 35 | 36 | Made by joyco.studio ` 37 | -------------------------------------------------------------------------------- /app/lib/gsap/effects.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Export here to agument types 3 | */ 4 | import gsap from 'gsap' 5 | import { noop } from '@/lib/utils' 6 | import { logger } from '@/utils/logger' 7 | 8 | const getElement = (node: GSAPTweenTarget): Element | Element[] | null => { 9 | if (node instanceof HTMLElement || node instanceof SVGElement) { 10 | return node 11 | } 12 | 13 | if (typeof node === 'string') { 14 | return document.querySelector(node) 15 | } 16 | 17 | if (Array.isArray(node)) { 18 | return node.map(getElement).filter((elm): elm is Element => elm != null) 19 | } 20 | 21 | return null 22 | } 23 | 24 | const clipIn: GSAPEffect<{ 25 | direction?: 'up' | 'down' 26 | duration?: number 27 | stagger?: number 28 | ease?: string 29 | delay?: number 30 | }> = { 31 | effect: (node, config) => { 32 | const log = logger('clipIn') 33 | 34 | if (node instanceof SVGElement) { 35 | throw new Error('SVGElement is not supported') 36 | } 37 | 38 | const elements = getElement(node) 39 | 40 | if (!elements) { 41 | log.warn(node, 'Element not found') 42 | return noop 43 | } 44 | 45 | const firstElement = Array.isArray(elements) ? elements[0] : elements 46 | const parentNode = firstElement.parentElement 47 | 48 | if (!parentNode) { 49 | log.warn(node, 'Parent element not found') 50 | return noop 51 | } 52 | 53 | gsap.set(parentNode, { 54 | overflow: 'hidden', 55 | }) 56 | 57 | gsap.set([node], { 58 | yPercent: config?.direction === 'down' ? -100 : 100, 59 | }) 60 | 61 | return gsap.to(node, { 62 | yPercent: 0, 63 | stagger: config?.stagger, 64 | duration: config?.duration, 65 | ease: config?.ease, 66 | delay: config?.delay, 67 | }) 68 | }, 69 | defaults: { 70 | direction: 'down', 71 | duration: 0.5, 72 | stagger: 0.05, 73 | ease: 'expo.out', 74 | delay: 0, 75 | }, 76 | extendTimeline: true, 77 | } 78 | 79 | /** 80 | * Clip + Translate text out 81 | */ 82 | const clipOut: GSAPEffect<{ 83 | direction?: 'up' | 'down' 84 | duration?: number 85 | stagger?: number 86 | ease?: string 87 | delay?: number 88 | }> = { 89 | effect: (node, config) => { 90 | const log = logger('clipOut') 91 | 92 | if (node instanceof SVGElement) { 93 | throw new Error('SVGElement is not supported') 94 | } 95 | 96 | const elements = getElement(node) 97 | 98 | if (!elements) { 99 | log.warn(node, 'Element not found') 100 | return noop 101 | } 102 | 103 | const firstElement = Array.isArray(elements) ? elements[0] : elements 104 | const parentNode = firstElement.parentElement 105 | 106 | if (!parentNode) { 107 | log.warn(node, 'Parent element not found') 108 | return noop 109 | } 110 | 111 | gsap.set(parentNode, { 112 | overflow: 'hidden', 113 | }) 114 | 115 | return gsap.to(node, { 116 | yPercent: config?.direction === 'down' ? 100 : -100, 117 | stagger: config?.stagger, 118 | duration: config?.duration, 119 | ease: config?.ease, 120 | delay: config?.delay, 121 | }) 122 | }, 123 | defaults: { 124 | direction: 'down', 125 | duration: 0.5, 126 | stagger: 0.05, 127 | ease: 'expo.out', 128 | delay: 0, 129 | }, 130 | extendTimeline: true, 131 | } 132 | 133 | export const effects = { 134 | clipIn, 135 | clipOut, 136 | } as const satisfies Record 137 | 138 | export const registerEffects = (gsap: GSAP) => { 139 | Object.entries(effects).forEach(([key, effect]) => { 140 | gsap.registerEffect({ 141 | name: key, 142 | effect: effect.effect, 143 | defaults: effect.defaults, 144 | extendTimeline: effect.extendTimeline, 145 | }) 146 | }) 147 | } 148 | -------------------------------------------------------------------------------- /app/lib/gsap/index.ts: -------------------------------------------------------------------------------- 1 | import gsap from 'gsap' 2 | 3 | import { useGSAP } from '@gsap/react' 4 | import { isClient } from '@/lib/constants' 5 | import { registerEffects } from './effects' 6 | 7 | if (isClient) { 8 | gsap.registerPlugin(useGSAP) 9 | 10 | /** 11 | * Force 3D transforms as default. 12 | */ 13 | gsap.config({ 14 | force3D: true, 15 | }) 16 | 17 | registerEffects(gsap) 18 | } 19 | 20 | export { gsap } 21 | 22 | export const promisifyGsap = (tl: GSAPTimeline) => { 23 | return new Promise((resolve) => { 24 | tl.then(() => resolve()) 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /app/lib/gsap/types/effects.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-object-type */ 2 | declare namespace gsap { 3 | interface EffectsMap extends RegisteredGSAPEffects {} 4 | } 5 | -------------------------------------------------------------------------------- /app/lib/gsap/types/global.d.ts: -------------------------------------------------------------------------------- 1 | import { effects } from '../effects' 2 | 3 | type TimelineChild = string | GSAPAnimation | GSAPCallback | Array 4 | 5 | declare global { 6 | export type GSAPEffect = { 7 | effect: (node: GSAPTweenTarget, config?: TConfig) => TimelineChild 8 | defaults?: TConfig 9 | extendTimeline: boolean 10 | } 11 | 12 | type InferConfigFromGSAPEffect = T extends GSAPEffect ? U : never 13 | 14 | type RegisteredGSAPEffects = { 15 | [K in keyof typeof effects]: ( 16 | node: GSAPTweenTarget, 17 | config?: InferConfigFromGSAPEffect<(typeof effects)[K]> 18 | ) => TimelineChild 19 | } 20 | } 21 | 22 | export {} 23 | -------------------------------------------------------------------------------- /app/lib/utils/breakpoints.ts: -------------------------------------------------------------------------------- 1 | export type BreakpointMin = 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' 2 | export type BreakpointMax = 'max-sm' | 'max-md' | 'max-lg' | 'max-xl' | 'max-2xl' | 'max-3xl' 3 | export type Breakpoint = BreakpointMin | BreakpointMax 4 | 5 | export const breakpoints = { 6 | sm: { min: 640, max: 767 }, 7 | md: { min: 768, max: 1023 }, 8 | lg: { min: 1024, max: 1279 }, 9 | xl: { min: 1280, max: 1535 }, 10 | '2xl': { min: 1536, max: 1919 }, 11 | '3xl': { min: 1920, max: Infinity }, 12 | } 13 | 14 | export const query: Record = { 15 | sm: `(min-width: ${breakpoints.sm.min}px)`, 16 | md: `(min-width: ${breakpoints.md.min}px)`, 17 | lg: `(min-width: ${breakpoints.lg.min}px)`, 18 | xl: `(min-width: ${breakpoints.xl.min}px)`, 19 | '2xl': `(min-width: ${breakpoints['2xl'].min}px)`, 20 | '3xl': `(min-width: ${breakpoints['3xl'].min}px)`, 21 | 'max-sm': `(max-width: ${breakpoints.sm.max}px)`, 22 | 'max-md': `(max-width: ${breakpoints.md.max}px)`, 23 | 'max-lg': `(max-width: ${breakpoints.lg.max}px)`, 24 | 'max-xl': `(max-width: ${breakpoints.xl.max}px)`, 25 | 'max-2xl': `(max-width: ${breakpoints['2xl'].max}px)`, 26 | 'max-3xl': `(max-width: ${breakpoints['3xl'].max}px)`, 27 | } 28 | 29 | export const getCurrBreakpoint = (max?: boolean): Breakpoint | 'base' => { 30 | const breakpointEntries = Object.entries(breakpoints).reverse() 31 | for (const [breakpoint, { min: minValue, max: maxValue }] of breakpointEntries) 32 | if (max) { 33 | if (window.innerWidth <= maxValue) return `max-${breakpoint}` as BreakpointMax 34 | } else if (window.innerWidth >= minValue) return breakpoint as BreakpointMin 35 | 36 | return 'sm' 37 | } 38 | -------------------------------------------------------------------------------- /app/lib/utils/cn.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export default function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /app/lib/utils/image.ts: -------------------------------------------------------------------------------- 1 | import { query } from './breakpoints' 2 | 3 | import type { Breakpoint } from './breakpoints' 4 | 5 | export type GetImageSizesArg = (Partial> & { default?: string }) | string 6 | 7 | export const getImageSizes = (sizes: GetImageSizesArg) => { 8 | if (!sizes) return '' 9 | 10 | if (typeof sizes === 'string') return sizes 11 | 12 | return Object.entries(sizes ?? {}) 13 | .map(([breakpoint, size]) => { 14 | if (breakpoint === 'default') return size 15 | return `${query[breakpoint as Breakpoint]} ${size}` 16 | }) 17 | .join(', ') 18 | } 19 | -------------------------------------------------------------------------------- /app/lib/utils/index.ts: -------------------------------------------------------------------------------- 1 | export const noop = () => {} 2 | 3 | export const prependProtocol = (url: string | undefined) => { 4 | if (!url) return undefined 5 | 6 | if (url.startsWith('http://') || url.startsWith('https://')) { 7 | return url 8 | } 9 | return `https://${url}` 10 | } 11 | -------------------------------------------------------------------------------- /app/lib/utils/links.ts: -------------------------------------------------------------------------------- 1 | import type { LinkDescriptors } from 'react-router/route-module' 2 | 3 | type CrossOrigin = 'anonymous' | 'use-credentials' | undefined 4 | type As = 'script' | 'style' | 'font' | 'image' | 'fetch' | 'worker' | 'document' | 'audio' | 'video' 5 | 6 | type LinksConfig = { 7 | stylesheets: string[] 8 | /** 9 | * Use https://realfavicongenerator.net/ to generate a complete favicon set 10 | */ 11 | favicon: { 12 | '32x32': string 13 | '16x16': string 14 | 'apple-touch-icon'?: string 15 | } 16 | manifest?: string 17 | preconnect?: { href: string; crossOrigin?: CrossOrigin }[] 18 | preload?: { 19 | href: string 20 | as?: As 21 | type?: string 22 | crossOrigin?: CrossOrigin 23 | }[] 24 | } 25 | 26 | /** 27 | * Generate head tags for Remix. 28 | * 29 | * @param links - Links configuration 30 | * @param extra - Extra links 31 | * @returns Remix links 32 | */ 33 | export const generateLinks = (links: LinksConfig, extra: LinkDescriptors = []): LinkDescriptors => { 34 | const _links: LinkDescriptors = [] 35 | 36 | if (links.stylesheets) { 37 | _links.push(...links.stylesheets.map((stylesheet) => ({ rel: 'stylesheet', href: stylesheet }))) 38 | } 39 | 40 | if (links.favicon) { 41 | _links.push( 42 | { rel: 'icon', type: 'image/png', sizes: '32x32', href: links.favicon['32x32'] }, 43 | { rel: 'icon', type: 'image/png', sizes: '16x16', href: links.favicon['16x16'] } 44 | ) 45 | 46 | if (links.favicon['apple-touch-icon']) { 47 | _links.push({ rel: 'apple-touch-icon', href: links.favicon['apple-touch-icon'] }) 48 | } 49 | } 50 | 51 | if (links.manifest) { 52 | _links.push({ rel: 'manifest', href: links.manifest }) 53 | } 54 | 55 | if (links.preconnect) { 56 | _links.push(...links.preconnect.map(({ href, crossOrigin }) => ({ rel: 'preconnect', href, crossOrigin }))) 57 | } 58 | 59 | if (links.preload) { 60 | _links.push( 61 | ...links.preload.map(({ href, as, type, crossOrigin }) => ({ rel: 'preload', href, as, type, crossOrigin })) 62 | ) 63 | } 64 | 65 | return [..._links, ...extra] 66 | } 67 | -------------------------------------------------------------------------------- /app/lib/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo } from 'react' 2 | import { isProduction } from '../constants' 3 | 4 | export const logger = (ctx: string, enabled = true) => { 5 | let _enabled = enabled 6 | 7 | return { 8 | log: (...args: Parameters) => { 9 | if (isProduction || !_enabled) return 10 | console.log(`[${ctx}]`, ...args) 11 | }, 12 | warn: (...args: Parameters) => { 13 | if (isProduction || !_enabled) return 14 | console.warn(`[${ctx}]`, ...args) 15 | }, 16 | error: (...args: Parameters) => { 17 | if (isProduction || !_enabled) return 18 | console.error(`[${ctx}]`, ...args) 19 | }, 20 | enable: () => { 21 | _enabled = true 22 | }, 23 | disable: () => { 24 | _enabled = false 25 | }, 26 | } 27 | } 28 | 29 | export const useLogger = (ctx: string, enabled = true) => { 30 | const _logger = useMemo(() => logger(ctx, enabled), [ctx]) 31 | 32 | useEffect(() => { 33 | if (enabled) { 34 | _logger.enable() 35 | } else { 36 | _logger.disable() 37 | } 38 | }, [_logger, enabled]) 39 | 40 | return _logger 41 | } 42 | -------------------------------------------------------------------------------- /app/lib/utils/meta.ts: -------------------------------------------------------------------------------- 1 | import type { MetaDescriptor } from 'react-router' 2 | 3 | type MetaImage = { 4 | url: string 5 | /** 6 | * Recommended: 1200px 7 | */ 8 | width: number 9 | /** 10 | * Recommended: 630px 11 | */ 12 | height: number 13 | type: 'image/png' | 'image/jpeg' | 'image/jpg' | 'image/webp' 14 | } 15 | 16 | type MetaConfigBase = { 17 | title: string 18 | description: string 19 | url: string 20 | siteName: string 21 | image: MetaImage 22 | twitter?: { 23 | card?: 'summary' | 'summary_large_image' 24 | title?: string 25 | description?: string 26 | creator?: string 27 | site?: string 28 | image?: MetaImage 29 | } 30 | } 31 | 32 | type MetaConfig = 33 | | (MetaConfigBase & { 34 | strict?: true 35 | }) 36 | | (Partial & { 37 | strict?: false 38 | }) 39 | 40 | /** 41 | * Generate meta tags for Remix. It also runs dedupe and purge for duplicate and empty meta tags. 42 | * 43 | * @param structuredMeta - Meta configuration 44 | * @param extra - Extra meta tags 45 | * @returns Remix meta tags 46 | */ 47 | export const generateMeta = (structuredMeta: MetaConfig, extra?: MetaDescriptor[]): MetaDescriptor[] => { 48 | const _meta: MetaDescriptor[] = [] 49 | 50 | const dedupeAndPurge = (meta: MetaDescriptor[]) => { 51 | const deduped = new Map() 52 | meta.forEach((m) => { 53 | if ('name' in m && m.content !== undefined) { 54 | deduped.set(m.name as string, m) 55 | } else if ('property' in m && m.content !== undefined) { 56 | deduped.set(m.property as string, m) 57 | } else { 58 | deduped.set(Object.keys(m)[0] as string, m) 59 | } 60 | }) 61 | return Array.from(deduped.values()) 62 | } 63 | 64 | const { title, description, url, siteName, twitter, image } = structuredMeta 65 | 66 | /* base */ 67 | _meta.push({ title }, { name: 'description', content: description }) 68 | 69 | /* og */ 70 | _meta.push( 71 | { property: 'og:title', content: title }, 72 | { property: 'og:description', content: description }, 73 | { property: 'og:url', content: url }, 74 | { property: 'og:site_name', content: siteName }, 75 | { property: 'og:image', content: structuredMeta.image?.url }, 76 | { property: 'og:image:width', content: structuredMeta.image?.width.toString() }, 77 | { property: 'og:image:height', content: structuredMeta.image?.height.toString() }, 78 | { property: 'og:image:type', content: structuredMeta.image?.type } 79 | ) 80 | 81 | /* twitter */ 82 | _meta.push( 83 | { name: 'twitter:card', content: twitter?.card || 'summary_large_image' }, 84 | { name: 'twitter:title', content: twitter?.title || title }, 85 | { name: 'twitter:description', content: twitter?.description || description }, 86 | { name: 'twitter:creator', content: twitter?.creator }, 87 | { name: 'twitter:site', content: twitter?.site } 88 | ) 89 | _meta.push( 90 | { name: 'twitter:image', content: twitter?.image?.url || image?.url }, 91 | { name: 'twitter:image:width', content: twitter?.image?.width?.toString() || image?.width?.toString() }, 92 | { name: 'twitter:image:height', content: twitter?.image?.height?.toString() || image?.height?.toString() }, 93 | { name: 'twitter:image:type', content: twitter?.image?.type || image?.type } 94 | ) 95 | 96 | return dedupeAndPurge([..._meta, ...(extra || [])]) 97 | } 98 | 99 | export const mergeMeta = (parentMeta: MetaDescriptor[], metaTags: MetaDescriptor[]) => { 100 | const merged = new Map() 101 | 102 | const getMetaKey = (meta: MetaDescriptor) => { 103 | if ('name' in meta) return `name:${meta.name}` 104 | if ('property' in meta) return `property:${meta.property}` 105 | return Object.keys(meta)[0] 106 | } 107 | 108 | parentMeta.forEach((meta) => { 109 | merged.set(getMetaKey(meta), meta) 110 | }) 111 | 112 | metaTags.forEach((meta) => { 113 | merged.set(getMetaKey(meta), meta) 114 | }) 115 | 116 | return Array.from(merged.values()) 117 | } 118 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | isRouteErrorResponse, 3 | Links, 4 | Meta, 5 | Scripts, 6 | ScrollRestoration, 7 | useLocation, 8 | useOutlet, 9 | type MetaFunction, 10 | } from 'react-router' 11 | import gsap from 'gsap' 12 | 13 | import type { Route } from './+types/root' 14 | import stylesheet from './app.css?url' 15 | import { RouteTransitionManager } from '@joycostudio/transitions' 16 | import routes from './routes' 17 | import { promisifyGsap } from '@/lib/gsap' 18 | import { Header } from '@/components/header' 19 | import Footer from '@/components/footer' 20 | import { SITE_URL, WATERMARK } from '@/lib/constants' 21 | import { generateMeta } from '@/lib/utils/meta' 22 | import { generateLinks } from '@/lib/utils/links' 23 | 24 | export const links: Route.LinksFunction = () => 25 | generateLinks({ 26 | stylesheets: [stylesheet, 'https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@700&display=swap'], 27 | favicon: { 28 | '32x32': '/favicon-32x32.png', 29 | '16x16': '/favicon-16x16.png', 30 | 'apple-touch-icon': '/apple-touch-icon.png', 31 | }, 32 | manifest: '/site.webmanifest', 33 | preconnect: [ 34 | { href: 'https://fonts.googleapis.com' }, 35 | { href: 'https://fonts.gstatic.com', crossOrigin: 'anonymous' }, 36 | ], 37 | preload: [ 38 | { 39 | href: 'https://fonts.gstatic.com/s/barlowcondensed/v12/HTxwL3I-JCGChYJ8VI-L6OO_au7B46r2z3bWuYMBYro.woff2', 40 | as: 'font', 41 | type: 'font/woff2', 42 | crossOrigin: 'anonymous', 43 | }, 44 | ], 45 | }) 46 | 47 | export const loader = () => { 48 | const mediaLinks = [ 49 | { label: 'x', link: 'https://x.com/joyco_studio' }, 50 | { label: 'github', link: 'https://github.com/joyco-studio/rrv7-starter' }, 51 | ] 52 | return { rebelLog: WATERMARK, mediaLinks } 53 | } 54 | 55 | export const meta: MetaFunction = () => { 56 | const meta = generateMeta({ 57 | strict: true, 58 | title: 'Rebels Starter', 59 | description: 60 | 'A react-router v7 starter made by rebels for rebels. Featuring: react-router v7, react 19 + compiler, tailwindcss, gsap, eslint + prettier, page transitions, + 1000 aura.', 61 | url: SITE_URL, 62 | siteName: 'Rebels Starter', 63 | image: { url: `${SITE_URL}/opengraph-image.png`, width: 1200, height: 630, type: 'image/png' }, 64 | }) 65 | 66 | return meta 67 | } 68 | 69 | export function Layout({ children }: { children: React.ReactNode }) { 70 | return ( 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 |
80 | {children} 81 |