hotdogee / origami-moving-cubes-drawing-designer

Origami Moving Cubes Drawing Designer (NextJS, Canvas API, CSS Animations)

Home Page:https://origami-moving-cubes-drawing-designer.vercel.app

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Origami Moving Cubes Drawing Designer

Inspired by Steve Mould's video: Self-assembling material pops into 3D (YouTube)

Live

Deployed on Vercel: https://cubes.hanl.in/

Screenshot_20231006-104724_Chrome

Features

  • Real-time Transformation Preview
    • Create and design with a live preview, ensuring your creations come to life as you envision them.
  • Synced Brush Cursor
    • Improve precision and accuracy with a synced brush cursor that's visible in the preview.
  • *Persistent Brush Settings
    • Your preferred brush color, size, and opacity settings will remain intact even after reloading the app.
  • Adaptable Dark and Light Themes
    • Switch between dark and light themes or use your system settings by default.
  • Smooth State Transitions
    • Switch between different states with smooth animations or manually scrub through transitions for precise control.
  • Optimized Responsive Design
    • Maximizes screen real estate usage to provide an optimal design experience.
  • Offline support
    • Able to load and reload without an active network connection.
  • Multilanguage support
    Responsive Design with CSS aspect-ratio @media query

Screen Recordings

2023-10-01.12-56-48.mp4

Tech Stack

  • Next.js: React framework with server-side rendering (SSR) and static site generation (SSG).
    • Single component dark mode toggle
    • Simple i18n middleware implementation without using 3rd party libraries.
  • Tailwind CSS: Utility-first CSS framework.
  • Canvas API: Simple custom drawing implementation.
  • CSS animations with custom @keyframes
  • Progressive Web App (PWA) optimized with offline capability.
  • TypeScript

How to add Internationalization (i18n) to Next.js 13 App Router

locale-switch-android-windows-small

Design Goals

  1. Support static site generation (SSG), automatically generated as static HTML + JSON (uses getStaticProps).
  2. Auto-detect the user's locale by default.
  3. Allow users to select their preferred locale and remember their preference.
  4. Display the default locale without a URL prefix.
  5. Allow users to manually change the locale in the URL and override the detected and saved locales.

Locale Priority

Locale Priority Description
URL locale (/locale) The locale specified in the URL, e.g. /de or /en/about.
Preferred locale The user's preferred locale, saved in a cookie.
Detected locale The user's locale, detected using the accept-language header.
Default locale The website's default locale.

Routing Behavior

Scenario Routing Behavior
User visits the website for the first time and their locale is de. The user is redirected to /de.
User visits the website and their preferred locale is fr. The user is redirected to /fr, even if their detected locale is de.
User visits /en and their preferred locale is fr. The user's preferred locale is changed to en, and they are redirected to /.
User visits /de and their preferred locale is fr. The user's preferred locale is ignored, and /de is displayed.

Example

Suppose a website has the following configuration:

  • Default locale: en
  • Supported locales: en, de, fr

A user visits the website for the first time and their locale is de. The user is redirected to /de because their detected locale is de and there is a supported locale for that language.

The user then selects fr as their preferred locale. The next time the user visits the website, they are redirected to /fr because their preferred locale is fr.

The user then visits /en. Their preferred locale is changed to en and they are redirected to /. This is because the user's preferred locale has precedence over the detected locale.

Implementation

Install dependencies

npm i @formatjs/intl-localematcher negotiator
npm i -D @types/negotiator

i18n.ts

Configure your default locale (defaultLocale) and supported locales (locales)

Server Components: Use getDictionary to get the translation dictionary t for the current locale

export const LOCALE_COOKIE = 'locale' as const
export const locales = ['en', 'tw', 'fr', 'de', 'ru', 'nl', 'hu'] as const
export const defaultLocale = 'en' as const
export type Locale = (typeof locales)[number]
const dictionaries = locales.reduce(
(acc, locale) => {
return {
...acc,
[locale]: () =>
import(`./dictionaries/${locale}.json`).then((module) => module.default),
}
},
{} as Record<Locale, () => Promise<Record<string, string>>>
)
export const getDictionary = (locale: Locale) =>
dictionaries[locale]?.() ?? dictionaries[defaultLocale]()

middleware.ts

Handles locale detection, redirects, and rewrites, and sets cookies if manually navigated to /defaultLocale

import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { LOCALE_COOKIE, Locale, defaultLocale, locales } from '@/i18n'
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
function getDetectedLocale(request: NextRequest): string | undefined {
// Negotiator expects plain object so we need to transform headers
const languages = new Negotiator({ headers: Object.fromEntries(request.headers) })
.languages()
// languages = [ '*' ] // no accept-language header
// filter out '*' since match() throw an error
.filter((language) => language !== '*')
// replace zh with tw, for example 'zh-TW' -> 'tw-TW'
.map((language) => {
return language.replace('zh', 'tw')
})
const locale = match(languages, locales, defaultLocale)
return locale
}
export function middleware(request: NextRequest) {
// Priority
// 1. route prefix
// 2. cookie
// 3. accept-language header
// 4. default locale
let locale = request.nextUrl.pathname.split('/')[1] as string | undefined
if (locale === defaultLocale) {
// redirect /defaultLocale{} -> /{}, and set cookie
const response = NextResponse.redirect(
newUrl(request.nextUrl.pathname.replace(`/${locale}`, ''))
)
response.cookies.set(LOCALE_COOKIE, locale, {
sameSite: 'strict',
maxAge: 31536000, // 1 year
})
return response
}
if (locales.includes(locale as Locale)) {
// render /non-defaultLocale{}, does NOT set cookie
// Frontend sets cookie on language change
return NextResponse.next()
}
// check cookie or accept-language header
locale =
(request.cookies.get(LOCALE_COOKIE)?.value as string | undefined) ||
getDetectedLocale(request)
if (locale === defaultLocale) {
// rewrite /{} -> /defaultLocale{}
return NextResponse.rewrite(newUrl(`/${locale}${request.nextUrl.pathname}`))
}
if (locales.includes(locale as Locale)) {
// redirect /{} -> /non-defaultLocale{}
return NextResponse.redirect(newUrl(`/${locale}${request.nextUrl.pathname}`))
} else {
// broken cookie, redirect to default locale
return NextResponse.redirect(newUrl(`/${defaultLocale}${request.nextUrl.pathname}`))
}
// handle edge cases
function newUrl(pathname: string) {
// remove trailing slash
if (pathname.endsWith('/')) {
pathname = pathname.slice(0, -1)
}
const url = request.nextUrl.clone()
url.pathname = pathname || '/'
return url
}
}
export const config = {
// Matcher ignoring `/_next/`, `_vercel`, `/api/` and files with a '.' like `favicon.ico`
matcher: ['/((?!api|_next|_vercel|.*\\..*).*)'],
}

dictionaries

Translations are stored in /src/dictionaries as JSON files

{
"Origami Moving Cubes Drawing Designer": "動態立方輔助設計繪圖工具",
"Brush Color": "畫筆顏色",
"Brush Opacity": "畫筆透明度",
"Brush Width": "畫筆大小",
"Preview": "預覽",
"Transition": "轉換",
"Bursh Menu Toggle": "畫筆選單開關",
"Buy me a coffee": "請我喝杯咖啡"
}

i18n.context.tsx

Client Components: Use DictionaryContext to get the translation dictionary t for the current locale

'use client'
import { createContext } from 'react'
export const DictionaryContext = createContext<Record<string, string>>({})
export function DictionaryProvider({
t,
children,
}: {
t: Record<string, string>
children: React.ReactNode
}) {
return <DictionaryContext.Provider value={t}>{children}</DictionaryContext.Provider>
}

app/[lang]/layout.tsx

Move your layout.tsx and page.tsx files inside app/ into app/[lang], icons (favicon.ico, icon.png, apple-icon.png) and manifest.ts should stay in app/. In the root layout, use generateStaticParams to statically generate all locale routes at build time (SSG) instead of on-demand at request time (SSR).

import { DictionaryProvider } from '@/context/i18n.context'
import '@/globals.css'
import { Locale, getDictionary, locales } from '@/i18n'
import { Analytics } from '@vercel/analytics/react'
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
export async function generateStaticParams() {
return locales.map((locale) => ({ lang: locale }))
}

In the root layout, set up DictionaryContext by passing the translation dictionary t from getDictionary to DictionaryProvider.

export default async function RootLayout({
children,
params: { lang },
}: {
children: React.ReactNode
params: { lang: Locale }
}) {
const t = await getDictionary(lang)
return (
<html lang={lang} suppressHydrationWarning>
<body className={`bg-gray-100 dark:bg-gray-800 ${inter.className}`}>
<DictionaryProvider t={t}>{children}</DictionaryProvider>
<Analytics />
</body>
</html>
)
}

LocaleSelect.tsx

You will likely need to implement your own locale selector component so it matches the styling of your site.

This example uses simple HTML <select> and <options> without any external libraries.

Set LOCALE_COOKIE when the user chooses a new locale.

'use client'
import { LOCALE_COOKIE, defaultLocale } from '@/i18n'
import { Noto_Color_Emoji } from 'next/font/google'
import { useParams, usePathname, useRouter } from 'next/navigation'
import { useState } from 'react'
const noto_color_emoji = Noto_Color_Emoji({
weight: '400',
subsets: ['emoji'],
// fallback: ['var(--font-noto)'],
fallback: ['system-ui', 'arial'],
adjustFontFallback: false,
})
export default function LocaleSelect() {
const pathname = usePathname()
const params = useParams()
const router = useRouter()
const [locale, setLocale] = useState(params.lang)
// { params: { lang: 'de' }, pathname: '/de' }
// { params: { lang: 'en' }, pathname: '/' }
// console.log({ params, pathname, defaultLocale, locales })
function onChange(e: React.ChangeEvent<HTMLSelectElement>) {
const locale = e.target.value
setLocale(locale)
const currentLocale = params.lang
const pathnameWithoutLocale = pathname.replace(`/${currentLocale}`, '')
const pathnameWithNewLocale =
locale === defaultLocale
? pathnameWithoutLocale || '/'
: `/${locale}${pathnameWithoutLocale}`
// console.log({ locale, currentLocale, pathnameWithoutLocale, pathnameWithNewLocale })
document.cookie = `${LOCALE_COOKIE}=${locale};path=/;max-age=31536000;samesite=strict;`
router.push(pathnameWithNewLocale)
}
return (
<div className="">
<select
value={locale}
onChange={onChange}
aria-label="Locale Select"
name="choice"
className={`flex w-[42px] appearance-none space-y-5 rounded-full border border-gray-300 bg-gray-100 p-1.5 px-2.5 text-lg outline-none focus:border-gray-500 dark:border-gray-600 dark:bg-gray-800 dark:focus:border-gray-400 ${noto_color_emoji.className}`}
>
<option value="en">🇺🇸 English &nbsp;</option>
<option value="tw">🇹🇼 Taiwan &nbsp;</option>
<option value="de">🇩🇪 Deutsch &nbsp;</option>
<option value="fr">🇫🇷 Français &nbsp;</option>
<option value="nl">🇳🇱 Nederlands &nbsp;</option>
<option value="hu">🇭🇺 Magyar &nbsp;</option>
<option value="ru">🇷🇺 Русский &nbsp;</option>
</select>
</div>
)
}

Implementing a Dark Mode Toggle to Next.js 13 App Router and TailwindCSS

dark-mode-toggle

Design Goals

  • Does not flash the wrong mode on page load. The website should immediately render in the light or dark mode that matches the user's system settings.
  • Adds or removes the dark class for Tailwind CSS and also sets the CSS color-scheme property for scrollbars on the <html> element.
  • The toggle button allows the user to override the system settings. The toggle button has three states: Light, System, and Dark, with System as the default.
  • If the toggle is set to System, the page should live update if the system settings are changed.
  • Theme selection is persisted through page changes and separate sessions using local storage.
  • Does not block rendering of the UI until the React app is hydrated and renders. Only a few lines of JavaScript that set the light/dark mode attribute on the <html> element should block rendering.
  • Does not cause any terminal or browser errors. This includes errors during the server component render and the rendering of any of the React components in the browser.
  • The first time the light/dark mode toggle is rendered on the client, it should already be in the correct state. Since this cannot be determined on the server, the toggle component may flash briefly on the client.

Implementation

Set up tailwind.config.ts to toggle dark mode manually

https://tailwindcss.com/docs/dark-mode#toggling-dark-mode-manually

Add suppressHydrationWarning to the <html> element

This property only applies one level deep to just the <html> element, so it won't block hydration warnings on other elements.

The HydrationWarning is caused by the few lines of JavaScript that add the dark class to the <html> element according to client settings before first rendering. This is used to prevent the wrong theme from flashing on page load.

<html lang={lang} suppressHydrationWarning>

Use the DarkModeToggle

Modify the styling to match your site.

This example is styled to match the toggle on the Official Next.js website, which is located in the bottom right corner.

All styling is done using Tailwind CSS classes and inline SVG for easy modification.

'use client'
import { useEffect, useState } from 'react'
// localStorage.getItem("isDarkMode") === null
// localStorage.getItem("isDarkMode") === 'true'
// localStorage.getItem("isDarkMode") === 'false'
// localStorage.removeItem("isDarkMode")
// localStorage.setItem("isDarkMode", 'true')
// localStorage.setItem("isDarkMode", 'false')
const toggleDarkClass = (isDark: boolean) => {
if (isDark) {
document.documentElement.classList.add('dark')
document.documentElement.style.colorScheme = 'dark'
} else {
document.documentElement.classList.remove('dark')
document.documentElement.style.colorScheme = ''
}
}
function DarkModeToggle() {
const [isDarkMode, setIsDarkMode] = useState<string | null>(
global.localStorage?.getItem('isDarkMode') || null
)
useEffect(() => {
if (isDarkMode === null) {
localStorage.removeItem('isDarkMode')
// check system preference
const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)')
const listener = (e: MediaQueryListEvent | MediaQueryList) => {
toggleDarkClass(e.matches)
}
listener(darkModeQuery)
darkModeQuery.addEventListener('change', listener)
return () => {
darkModeQuery.removeEventListener('change', listener)
}
} else {
localStorage.setItem('isDarkMode', isDarkMode)
toggleDarkClass(isDarkMode === 'true')
}
}, [isDarkMode])
const toggleDarkMode = () => {
if (isDarkMode === 'false') {
setIsDarkMode(null)
} else if (isDarkMode === 'true') {
setIsDarkMode('false')
} else if (isDarkMode === null) {
setIsDarkMode('true')
}
}
const setDarkMode = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value === 'system') {
setIsDarkMode(null)
} else if (e.target.value === 'light') {
setIsDarkMode('false')
} else if (e.target.value === 'dark') {
setIsDarkMode('true')
}
}
const buttonText = () => {
switch (isDarkMode) {
case 'true':
return 'Dark'
case null:
return 'System'
case 'false':
return 'Light'
}
}
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
if (!mounted) {
return (
<>
<script
dangerouslySetInnerHTML={{
__html: `
if (
localStorage.getItem('isDarkMode') === 'true' ||
(localStorage.getItem('isDarkMode') === null &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
document.documentElement.classList.add('dark')
document.documentElement.style.colorScheme = 'dark'
} else {
document.documentElement.classList.remove('dark')
document.documentElement.style.colorScheme = ''
}
`,
}}
></script>
<button className="p-2">
<div className="w-16"></div>
</button>
<div className="rounded-full border border-gray-300 p-1 dark:border-gray-600">
<div className="h-8 w-24"></div>
</div>
</>
)
} else {
return (
<>
<div className="flex rounded-full border border-gray-300 p-1 dark:border-gray-600">
<input
type="radio"
name="theme-toggle"
value="light"
id="theme-toggle-light"
aria-label="Switch to light theme"
className="peer/light appearance-none"
checked={isDarkMode === 'false'}
onChange={setDarkMode}
></input>
<label
htmlFor="theme-toggle-light"
className="gray-100 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-0 text-gray-500 hover:text-gray-900 peer-checked/light:bg-gray-200 peer-checked/light:text-gray-800 dark:text-gray-400 dark:hover:text-gray-50 dark:peer-checked/light:bg-gray-700 dark:peer-checked/light:text-gray-100"
>
<svg
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="1.5"
className="☀︎ h-4 w-4 shrink-0"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="5" />
<path d="M12 1v2m0 18v2M4.2 4.2l1.4 1.4m12.8 12.8 1.4 1.4M1 12h2m18 0h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4" />
</svg>
</label>
<input
type="radio"
name="theme-toggle"
value="system"
id="theme-toggle-system"
aria-label="Switch to system theme"
className="peer/system appearance-none"
checked={isDarkMode === null}
onChange={setDarkMode}
></input>
<label
htmlFor="theme-toggle-system"
className="gray-100 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-0 text-gray-500 hover:text-gray-900 peer-checked/system:bg-gray-200 peer-checked/system:text-gray-800 dark:text-gray-400 dark:hover:text-gray-50 dark:peer-checked/system:bg-gray-700 dark:peer-checked/system:text-gray-100"
>
<svg
className="🖳 h-4 w-4 shrink-0"
fill="none"
shapeRendering="geometricPrecision"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
>
<rect width="20" height="14" x="2" y="3" rx="2" />
<path d="M8 21h8m-4-4v4" />
</svg>
</label>
<input
type="radio"
name="theme-toggle"
value="dark"
id="theme-toggle-dark"
aria-label="Switch to dark theme"
className="peer/dark appearance-none"
checked={isDarkMode === 'true'}
onChange={setDarkMode}
></input>
<label
htmlFor="theme-toggle-dark"
className="gray-100 flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-0 text-gray-500 hover:text-gray-900 peer-checked/dark:bg-gray-200 peer-checked/dark:text-gray-800 dark:text-gray-400 dark:hover:text-gray-50 dark:peer-checked/dark:bg-gray-700 dark:peer-checked/dark:text-gray-100"
>
<svg
className="☾ h-4 w-4 shrink-0"
fill="none"
shapeRendering="geometricPrecision"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
>
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"></path>
</svg>
</label>
</div>
</>
)
}
}
export default DarkModeToggle

About

Origami Moving Cubes Drawing Designer (NextJS, Canvas API, CSS Animations)

https://origami-moving-cubes-drawing-designer.vercel.app

License:Apache License 2.0


Languages

Language:TypeScript 57.8%Language:JavaScript 42.1%Language:CSS 0.1%