chore: initialize project
Deploy monie-web to prod (kaniko) / build-and-deploy (push) Successful in 10m51s
Deploy monie-web to prod (kaniko) / build-and-deploy (push) Successful in 10m51s
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
export default function Footer() {
|
||||
const year = new Date().getFullYear()
|
||||
|
||||
return (
|
||||
<footer className="mt-20 border-t border-[var(--line)] px-4 pb-14 pt-10 text-[var(--sea-ink-soft)]">
|
||||
<div className="page-wrap flex flex-col items-center justify-between gap-4 text-center sm:flex-row sm:text-left">
|
||||
<p className="m-0 text-sm">
|
||||
© {year} Your name here. All rights reserved.
|
||||
</p>
|
||||
<p className="island-kicker m-0">Built with TanStack Start</p>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-center gap-4">
|
||||
<a
|
||||
href="https://x.com/tan_stack"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-xl p-2 text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)]"
|
||||
>
|
||||
<span className="sr-only">Follow TanStack on X</span>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12.6 1h2.2L10 6.48 15.64 15h-4.41L7.78 9.82 3.23 15H1l5.14-5.84L.72 1h4.52l3.12 4.73L12.6 1zm-.77 12.67h1.22L4.57 2.26H3.26l8.57 11.41z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/TanStack"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-xl p-2 text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)]"
|
||||
>
|
||||
<span className="sr-only">Go to TanStack GitHub</span>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import ParaglideLocaleSwitcher from './LocaleSwitcher.tsx'
|
||||
import ThemeToggle from './ThemeToggle'
|
||||
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-[var(--line)] bg-[var(--header-bg)] px-4 backdrop-blur-lg">
|
||||
<nav className="page-wrap flex flex-wrap items-center gap-x-3 gap-y-2 py-3 sm:py-4">
|
||||
<h2 className="m-0 flex-shrink-0 text-base font-semibold tracking-tight">
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1.5 text-sm text-[var(--sea-ink)] no-underline shadow-[0_8px_24px_rgba(30,90,72,0.08)] sm:px-4 sm:py-2"
|
||||
>
|
||||
<span className="h-2 w-2 rounded-full bg-[linear-gradient(90deg,#56c6be,#7ed3bf)]" />
|
||||
TanStack Start
|
||||
</Link>
|
||||
</h2>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1.5 sm:ml-0 sm:gap-2">
|
||||
<a
|
||||
href="https://x.com/tan_stack"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hidden rounded-xl p-2 text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)] sm:block"
|
||||
>
|
||||
<span className="sr-only">Follow TanStack on X</span>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" width="24" height="24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12.6 1h2.2L10 6.48 15.64 15h-4.41L7.78 9.82 3.23 15H1l5.14-5.84L.72 1h4.52l3.12 4.73L12.6 1zm-.77 12.67h1.22L4.57 2.26H3.26l8.57 11.41z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/TanStack"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hidden rounded-xl p-2 text-[var(--sea-ink-soft)] transition hover:bg-[var(--link-bg-hover)] hover:text-[var(--sea-ink)] sm:block"
|
||||
>
|
||||
<span className="sr-only">Go to TanStack GitHub</span>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" width="24" height="24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<ParaglideLocaleSwitcher />
|
||||
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div className="order-3 flex w-full flex-wrap items-center gap-x-4 gap-y-1 pb-1 text-sm font-semibold sm:order-2 sm:w-auto sm:flex-nowrap sm:pb-0">
|
||||
<Link
|
||||
to="/"
|
||||
className="nav-link"
|
||||
activeProps={{ className: 'nav-link is-active' }}
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
className="nav-link"
|
||||
activeProps={{ className: 'nav-link is-active' }}
|
||||
>
|
||||
About
|
||||
</Link>
|
||||
<a
|
||||
href="https://tanstack.com/start/latest/docs/framework/react/overview"
|
||||
className="nav-link"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Docs
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Locale switcher refs:
|
||||
// - Paraglide docs: https://inlang.com/m/gerre34r/library-inlang-paraglideJs
|
||||
// - Router example: https://github.com/TanStack/router/tree/main/examples/react/i18n-paraglide#switching-locale
|
||||
import { getLocale, locales, setLocale } from '#/paraglide/runtime'
|
||||
import { m } from '#/paraglide/messages'
|
||||
|
||||
export default function ParaglideLocaleSwitcher() {
|
||||
const currentLocale = getLocale()
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
alignItems: 'center',
|
||||
color: 'inherit',
|
||||
}}
|
||||
aria-label={m.language_label()}
|
||||
>
|
||||
<span style={{ opacity: 0.85 }}>
|
||||
{m.current_locale({ locale: currentLocale })}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '0.25rem' }}>
|
||||
{locales.map((locale) => (
|
||||
<button
|
||||
key={locale}
|
||||
onClick={() => setLocale(locale)}
|
||||
aria-pressed={locale === currentLocale}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
padding: '0.35rem 0.75rem',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid #d1d5db',
|
||||
background: locale === currentLocale ? '#0f172a' : 'transparent',
|
||||
color: locale === currentLocale ? '#f8fafc' : 'inherit',
|
||||
fontWeight: locale === currentLocale ? 700 : 500,
|
||||
letterSpacing: '0.01em',
|
||||
}}
|
||||
>
|
||||
{locale.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
type ThemeMode = 'light' | 'dark' | 'auto'
|
||||
|
||||
function getInitialMode(): ThemeMode {
|
||||
if (typeof window === 'undefined') {
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
const stored = window.localStorage.getItem('theme')
|
||||
if (stored === 'light' || stored === 'dark' || stored === 'auto') {
|
||||
return stored
|
||||
}
|
||||
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
function applyThemeMode(mode: ThemeMode) {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const resolved = mode === 'auto' ? (prefersDark ? 'dark' : 'light') : mode
|
||||
|
||||
document.documentElement.classList.remove('light', 'dark')
|
||||
document.documentElement.classList.add(resolved)
|
||||
|
||||
if (mode === 'auto') {
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
} else {
|
||||
document.documentElement.setAttribute('data-theme', mode)
|
||||
}
|
||||
|
||||
document.documentElement.style.colorScheme = resolved
|
||||
}
|
||||
|
||||
export default function ThemeToggle() {
|
||||
const [mode, setMode] = useState<ThemeMode>('auto')
|
||||
|
||||
useEffect(() => {
|
||||
const initialMode = getInitialMode()
|
||||
setMode(initialMode)
|
||||
applyThemeMode(initialMode)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'auto') {
|
||||
return
|
||||
}
|
||||
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const onChange = () => applyThemeMode('auto')
|
||||
|
||||
media.addEventListener('change', onChange)
|
||||
return () => {
|
||||
media.removeEventListener('change', onChange)
|
||||
}
|
||||
}, [mode])
|
||||
|
||||
function toggleMode() {
|
||||
const nextMode: ThemeMode =
|
||||
mode === 'light' ? 'dark' : mode === 'dark' ? 'auto' : 'light'
|
||||
setMode(nextMode)
|
||||
applyThemeMode(nextMode)
|
||||
window.localStorage.setItem('theme', nextMode)
|
||||
}
|
||||
|
||||
const label =
|
||||
mode === 'auto'
|
||||
? 'Theme mode: auto (system). Click to switch to light mode.'
|
||||
: `Theme mode: ${mode}. Click to switch mode.`
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMode}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="rounded-full border border-[var(--chip-line)] bg-[var(--chip-bg)] px-3 py-1.5 text-sm font-semibold text-[var(--sea-ink)] shadow-[0_8px_22px_rgba(30,90,72,0.08)] transition hover:-translate-y-0.5"
|
||||
>
|
||||
{mode === 'auto' ? 'Auto' : mode === 'dark' ? 'Dark' : 'Light'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useStore } from '@tanstack/react-form'
|
||||
|
||||
import { useFieldContext, useFormContext } from '#/hooks/demo.form-context'
|
||||
|
||||
export function SubscribeButton({ label }: { label: string }) {
|
||||
const form = useFormContext()
|
||||
return (
|
||||
<form.Subscribe selector={(state) => state.isSubmitting}>
|
||||
{(isSubmitting) => (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-6 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorMessages({
|
||||
errors,
|
||||
}: {
|
||||
errors: Array<string | { message: string }>
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{errors.map((error) => (
|
||||
<div
|
||||
key={typeof error === 'string' ? error : error.message}
|
||||
className="text-red-500 mt-1 font-bold"
|
||||
>
|
||||
{typeof error === 'string' ? error : error.message}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextField({
|
||||
label,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string
|
||||
placeholder?: string
|
||||
}) {
|
||||
const field = useFieldContext<string>()
|
||||
const errors = useStore(field.store, (state) => state.meta.errors)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={label} className="block font-bold mb-1 text-xl">
|
||||
{label}
|
||||
<input
|
||||
value={field.state.value}
|
||||
placeholder={placeholder}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
className="w-full px-4 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</label>
|
||||
{field.state.meta.isTouched && <ErrorMessages errors={errors} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextArea({
|
||||
label,
|
||||
rows = 3,
|
||||
}: {
|
||||
label: string
|
||||
rows?: number
|
||||
}) {
|
||||
const field = useFieldContext<string>()
|
||||
const errors = useStore(field.store, (state) => state.meta.errors)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={label} className="block font-bold mb-1 text-xl">
|
||||
{label}
|
||||
<textarea
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
rows={rows}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
className="w-full px-4 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
/>
|
||||
</label>
|
||||
{field.state.meta.isTouched && <ErrorMessages errors={errors} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Select({
|
||||
label,
|
||||
values,
|
||||
}: {
|
||||
label: string
|
||||
values: Array<{ label: string; value: string }>
|
||||
placeholder?: string
|
||||
}) {
|
||||
const field = useFieldContext<string>()
|
||||
const errors = useStore(field.store, (state) => state.meta.errors)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor={label} className="block font-bold mb-1 text-xl">
|
||||
{label}
|
||||
</label>
|
||||
<select
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
className="w-full px-4 py-2 rounded-md border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
{values.map((value) => (
|
||||
<option key={value.value} value={value.value}>
|
||||
{value.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{field.state.meta.isTouched && <ErrorMessages errors={errors} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useChat, useMessages } from '#/hooks/demo.useChat'
|
||||
|
||||
import Messages from './demo.messages'
|
||||
|
||||
export default function ChatArea() {
|
||||
const { sendMessage } = useChat()
|
||||
|
||||
const messages = useMessages()
|
||||
|
||||
const [message, setMessage] = useState('')
|
||||
const [user, setUser] = useState('Alice')
|
||||
|
||||
const postMessage = () => {
|
||||
if (message.trim().length) {
|
||||
sendMessage(message, user)
|
||||
setMessage('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
postMessage()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="px-4 py-6 space-y-4">
|
||||
<Messages messages={messages} user={user} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white border-t border-gray-200 px-4 py-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<select
|
||||
value={user}
|
||||
onChange={(e) => setUser(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
<option value="Alice">Alice</option>
|
||||
<option value="Bob">Bob</option>
|
||||
</select>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onKeyDown={handleKeyPress}
|
||||
placeholder="Type a message..."
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={postMessage}
|
||||
disabled={message.trim() === ''}
|
||||
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Message } from '#/db-collections'
|
||||
|
||||
export const getAvatarColor = (username: string) => {
|
||||
const colors = [
|
||||
'bg-blue-500',
|
||||
'bg-green-500',
|
||||
'bg-purple-500',
|
||||
'bg-pink-500',
|
||||
'bg-indigo-500',
|
||||
'bg-red-500',
|
||||
'bg-yellow-500',
|
||||
'bg-teal-500',
|
||||
]
|
||||
const index = username
|
||||
.split('')
|
||||
.reduce((acc, char) => acc + char.charCodeAt(0), 0)
|
||||
return colors[index % colors.length]
|
||||
}
|
||||
|
||||
export default function Messages({
|
||||
messages,
|
||||
user,
|
||||
}: {
|
||||
messages: Message[]
|
||||
user: string
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{messages.map((msg: Message) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${
|
||||
msg.user === user ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-start space-x-3 max-w-xs lg:max-w-md ${
|
||||
msg.user === user ? 'flex-row-reverse space-x-reverse' : ''
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-sm font-medium ${getAvatarColor(
|
||||
msg.user,
|
||||
)}`}
|
||||
>
|
||||
{msg.user.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`px-4 py-2 rounded-2xl ${
|
||||
msg.user === user
|
||||
? 'bg-blue-500 text-white rounded-br-md'
|
||||
: 'bg-white text-gray-800 border border-gray-200 rounded-bl-md'
|
||||
}`}
|
||||
>
|
||||
{msg.user !== user && (
|
||||
<p className="text-xs text-gray-500 mb-1 font-medium">
|
||||
{msg.user}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm">{msg.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user