chore: initialize project
Deploy monie-web to prod (kaniko) / build-and-deploy (push) Successful in 10m15s
Deploy monie-web to prod (kaniko) / build-and-deploy (push) Successful in 10m15s
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"projectName": "monie-web",
|
||||||
|
"mode": "file-router",
|
||||||
|
"typescript": true,
|
||||||
|
"packageManager": "npm",
|
||||||
|
"includeExamples": false,
|
||||||
|
"tailwind": true,
|
||||||
|
"addOnOptions": {},
|
||||||
|
"envVarValues": {},
|
||||||
|
"git": true,
|
||||||
|
"routerOnly": false,
|
||||||
|
"version": 1,
|
||||||
|
"framework": "react",
|
||||||
|
"chosenAddOns": [
|
||||||
|
"biome",
|
||||||
|
"sentry",
|
||||||
|
"compiler",
|
||||||
|
"db",
|
||||||
|
"form",
|
||||||
|
"paraglide",
|
||||||
|
"t3env",
|
||||||
|
"table",
|
||||||
|
"store",
|
||||||
|
"posthog",
|
||||||
|
"tanstack-query"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
node_modules
|
||||||
|
.output
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.npm
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
README.md
|
||||||
|
.codex
|
||||||
|
.vscode
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
name: Deploy monie-web to dev (kaniko)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [develop]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-dev-monie-web
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: [self-hosted, linux, k8s]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CI_NS: ci
|
||||||
|
APP_NS: dev
|
||||||
|
|
||||||
|
# Kaniko job runs inside cluster pods and can reach registry via node IP.
|
||||||
|
PUSH_REGISTRY: 192.168.1.250:32000
|
||||||
|
# Runtime pull should use the endpoint configured in MicroK8s containerd.
|
||||||
|
DEPLOY_REGISTRY: localhost:32000
|
||||||
|
|
||||||
|
IMAGE: monie-web
|
||||||
|
DEPLOYMENT: monie-web
|
||||||
|
CONTAINER: monie-web
|
||||||
|
|
||||||
|
# Repo URL for Kaniko git context (credentials come from k8s secret).
|
||||||
|
REPO_HOST: git.denjs.ru
|
||||||
|
REPO_PATH: monie/monie-web.git
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Debug environment
|
||||||
|
env:
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
REF: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if command -v microk8s >/dev/null 2>&1; then
|
||||||
|
kctl() { microk8s kubectl "$@"; }
|
||||||
|
else
|
||||||
|
kctl() { kubectl "$@"; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "sha=${SHA}"
|
||||||
|
echo "ref=${REF}"
|
||||||
|
echo "repo=git://${REPO_HOST}/${REPO_PATH}"
|
||||||
|
kctl version --client=true
|
||||||
|
|
||||||
|
- name: Build & push image with Kaniko (K8s Job)
|
||||||
|
env:
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
REF: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if command -v microk8s >/dev/null 2>&1; then
|
||||||
|
kctl() { microk8s kubectl "$@"; }
|
||||||
|
else
|
||||||
|
kctl() { kubectl "$@"; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
JOB="kaniko-${SHA}"
|
||||||
|
DEST="${PUSH_REGISTRY}/${IMAGE}:${SHA}"
|
||||||
|
|
||||||
|
echo "JOB=${JOB}"
|
||||||
|
echo "DEST=${DEST}"
|
||||||
|
echo "REF=${REF}"
|
||||||
|
echo "REPO=git://${REPO_HOST}/${REPO_PATH}"
|
||||||
|
|
||||||
|
kctl -n "${CI_NS}" delete job "${JOB}" --ignore-not-found=true
|
||||||
|
|
||||||
|
cat <<EOF_JOB | kctl -n "${CI_NS}" apply -f -
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: ${JOB}
|
||||||
|
labels:
|
||||||
|
app: kaniko
|
||||||
|
spec:
|
||||||
|
backoffLimit: 0
|
||||||
|
activeDeadlineSeconds: 1800
|
||||||
|
ttlSecondsAfterFinished: 3600
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
containers:
|
||||||
|
- name: kaniko
|
||||||
|
image: gcr.io/kaniko-project/executor:latest
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
env:
|
||||||
|
- name: GIT_USERNAME
|
||||||
|
value: denis
|
||||||
|
- name: GIT_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: gitea-git-token
|
||||||
|
key: token
|
||||||
|
args:
|
||||||
|
- --context=git://${REPO_HOST}/${REPO_PATH}#refs/heads/${REF}
|
||||||
|
- --dockerfile=Dockerfile
|
||||||
|
- --destination=${DEST}
|
||||||
|
- --verbosity=debug
|
||||||
|
- --cache=true
|
||||||
|
- --cache-repo=${PUSH_REGISTRY}/${IMAGE}-cache
|
||||||
|
- --insecure-registry=${PUSH_REGISTRY}
|
||||||
|
- --skip-tls-verify-registry=${PUSH_REGISTRY}
|
||||||
|
EOF_JOB
|
||||||
|
|
||||||
|
DEADLINE_SECONDS=1800
|
||||||
|
START_TS="$(date +%s)"
|
||||||
|
OK=1
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
SUCCEEDED="$(kctl -n "${CI_NS}" get job "${JOB}" -o jsonpath='{.status.succeeded}' 2>/dev/null || true)"
|
||||||
|
FAILED="$(kctl -n "${CI_NS}" get job "${JOB}" -o jsonpath='{.status.failed}' 2>/dev/null || true)"
|
||||||
|
|
||||||
|
SUCCEEDED="${SUCCEEDED:-0}"
|
||||||
|
FAILED="${FAILED:-0}"
|
||||||
|
|
||||||
|
if [ "${SUCCEEDED}" -ge 1 ]; then
|
||||||
|
OK=0
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${FAILED}" -ge 1 ]; then
|
||||||
|
OK=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
NOW_TS="$(date +%s)"
|
||||||
|
if [ $((NOW_TS - START_TS)) -ge "${DEADLINE_SECONDS}" ]; then
|
||||||
|
OK=2
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[ci] job status:"
|
||||||
|
kctl -n "${CI_NS}" get job "${JOB}" -o wide || true
|
||||||
|
|
||||||
|
echo "[ci] job logs (tail):"
|
||||||
|
kctl -n "${CI_NS}" logs "job/${JOB}" --tail=300 || true
|
||||||
|
|
||||||
|
if [ "${OK}" -ne 0 ]; then
|
||||||
|
echo "[ci] job did not reach Complete; describing job/pods for debug"
|
||||||
|
kctl -n "${CI_NS}" describe job "${JOB}" || true
|
||||||
|
kctl -n "${CI_NS}" get pods -l job-name="${JOB}" -o wide || true
|
||||||
|
kctl -n "${CI_NS}" describe pod -l job-name="${JOB}" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Deploy to dev
|
||||||
|
env:
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if command -v microk8s >/dev/null 2>&1; then
|
||||||
|
kctl() { microk8s kubectl "$@"; }
|
||||||
|
else
|
||||||
|
kctl() { kubectl "$@"; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
TARGET_IMAGE="${DEPLOY_REGISTRY}/${IMAGE}:${SHA}"
|
||||||
|
|
||||||
|
kctl -n "${APP_NS}" set image "deployment/${DEPLOYMENT}" \
|
||||||
|
"${CONTAINER}=${TARGET_IMAGE}"
|
||||||
|
|
||||||
|
set +e
|
||||||
|
kctl -n "${APP_NS}" rollout status "deployment/${DEPLOYMENT}" --timeout=15m
|
||||||
|
ROLLOUT_RC=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "${ROLLOUT_RC}" -ne 0 ]; then
|
||||||
|
echo "[deploy] rollout did not complete in time; collecting diagnostics"
|
||||||
|
|
||||||
|
SELECTOR="$(kctl -n "${APP_NS}" get deployment "${DEPLOYMENT}" \
|
||||||
|
-o jsonpath='{range $k,$v := .spec.selector.matchLabels}{$k}={$v},{end}' 2>/dev/null || true)"
|
||||||
|
SELECTOR="${SELECTOR%,}"
|
||||||
|
|
||||||
|
kctl -n "${APP_NS}" get deployment "${DEPLOYMENT}" -o wide || true
|
||||||
|
kctl -n "${APP_NS}" describe deployment "${DEPLOYMENT}" || true
|
||||||
|
|
||||||
|
if [ -n "${SELECTOR}" ]; then
|
||||||
|
kctl -n "${APP_NS}" get rs -l "${SELECTOR}" -o wide || true
|
||||||
|
kctl -n "${APP_NS}" get pods -l "${SELECTOR}" -o wide || true
|
||||||
|
kctl -n "${APP_NS}" describe pods -l "${SELECTOR}" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
kctl -n "${APP_NS}" get events --sort-by=.lastTimestamp | tail -n 100 || true
|
||||||
|
|
||||||
|
DESIRED="$(kctl -n "${APP_NS}" get deployment "${DEPLOYMENT}" \
|
||||||
|
-o jsonpath='{.spec.replicas}' 2>/dev/null || true)"
|
||||||
|
UPDATED="$(kctl -n "${APP_NS}" get deployment "${DEPLOYMENT}" \
|
||||||
|
-o jsonpath='{.status.updatedReplicas}' 2>/dev/null || true)"
|
||||||
|
AVAILABLE="$(kctl -n "${APP_NS}" get deployment "${DEPLOYMENT}" \
|
||||||
|
-o jsonpath='{.status.availableReplicas}' 2>/dev/null || true)"
|
||||||
|
|
||||||
|
DESIRED="${DESIRED:-0}"
|
||||||
|
UPDATED="${UPDATED:-0}"
|
||||||
|
AVAILABLE="${AVAILABLE:-0}"
|
||||||
|
|
||||||
|
echo "[deploy] desired=${DESIRED} updated=${UPDATED} available=${AVAILABLE}"
|
||||||
|
|
||||||
|
if [ "${UPDATED}" -ge "${DESIRED}" ] && [ "${AVAILABLE}" -ge "${DESIRED}" ]; then
|
||||||
|
echo "[deploy] New replica is healthy; old replica termination is delayed. Continuing."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "${ROLLOUT_RC}"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
name: Deploy monie-web to prod (kaniko)
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-prod-monie-web
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-deploy:
|
||||||
|
runs-on: [self-hosted, linux, k8s]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CI_NS: ci
|
||||||
|
APP_NS: prod
|
||||||
|
|
||||||
|
# Kaniko job runs inside cluster pods and can reach registry via node IP.
|
||||||
|
PUSH_REGISTRY: 192.168.1.250:32000
|
||||||
|
# Runtime pull should use the endpoint configured in MicroK8s containerd.
|
||||||
|
DEPLOY_REGISTRY: localhost:32000
|
||||||
|
|
||||||
|
IMAGE: monie-web
|
||||||
|
DEPLOYMENT: monie-web
|
||||||
|
CONTAINER: monie-web
|
||||||
|
|
||||||
|
# Repo URL for Kaniko git context (credentials come from k8s secret).
|
||||||
|
REPO_HOST: git.denjs.ru
|
||||||
|
REPO_PATH: monie/monie-web.git
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Debug environment
|
||||||
|
env:
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
REF: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if command -v microk8s >/dev/null 2>&1; then
|
||||||
|
kctl() { microk8s kubectl "$@"; }
|
||||||
|
else
|
||||||
|
kctl() { kubectl "$@"; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "sha=${SHA}"
|
||||||
|
echo "ref=${REF}"
|
||||||
|
echo "repo=git://${REPO_HOST}/${REPO_PATH}"
|
||||||
|
kctl version --client=true
|
||||||
|
|
||||||
|
- name: Build & push image with Kaniko (K8s Job)
|
||||||
|
env:
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
REF: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if command -v microk8s >/dev/null 2>&1; then
|
||||||
|
kctl() { microk8s kubectl "$@"; }
|
||||||
|
else
|
||||||
|
kctl() { kubectl "$@"; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
JOB="kaniko-${SHA}"
|
||||||
|
DEST="${PUSH_REGISTRY}/${IMAGE}:${SHA}"
|
||||||
|
|
||||||
|
echo "JOB=${JOB}"
|
||||||
|
echo "DEST=${DEST}"
|
||||||
|
echo "REF=${REF}"
|
||||||
|
echo "REPO=git://${REPO_HOST}/${REPO_PATH}"
|
||||||
|
|
||||||
|
kctl -n "${CI_NS}" delete job "${JOB}" --ignore-not-found=true
|
||||||
|
|
||||||
|
cat <<EOF_JOB | kctl -n "${CI_NS}" apply -f -
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: ${JOB}
|
||||||
|
labels:
|
||||||
|
app: kaniko
|
||||||
|
spec:
|
||||||
|
backoffLimit: 0
|
||||||
|
activeDeadlineSeconds: 1800
|
||||||
|
ttlSecondsAfterFinished: 3600
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
containers:
|
||||||
|
- name: kaniko
|
||||||
|
image: gcr.io/kaniko-project/executor:latest
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
env:
|
||||||
|
- name: GIT_USERNAME
|
||||||
|
value: denis
|
||||||
|
- name: GIT_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: gitea-git-token
|
||||||
|
key: token
|
||||||
|
args:
|
||||||
|
- --context=git://${REPO_HOST}/${REPO_PATH}#refs/heads/${REF}
|
||||||
|
- --dockerfile=Dockerfile
|
||||||
|
- --destination=${DEST}
|
||||||
|
- --verbosity=debug
|
||||||
|
- --cache=true
|
||||||
|
- --cache-repo=${PUSH_REGISTRY}/${IMAGE}-cache
|
||||||
|
- --insecure-registry=${PUSH_REGISTRY}
|
||||||
|
- --skip-tls-verify-registry=${PUSH_REGISTRY}
|
||||||
|
EOF_JOB
|
||||||
|
|
||||||
|
DEADLINE_SECONDS=1800
|
||||||
|
START_TS="$(date +%s)"
|
||||||
|
OK=1
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
SUCCEEDED="$(kctl -n "${CI_NS}" get job "${JOB}" -o jsonpath='{.status.succeeded}' 2>/dev/null || true)"
|
||||||
|
FAILED="$(kctl -n "${CI_NS}" get job "${JOB}" -o jsonpath='{.status.failed}' 2>/dev/null || true)"
|
||||||
|
|
||||||
|
SUCCEEDED="${SUCCEEDED:-0}"
|
||||||
|
FAILED="${FAILED:-0}"
|
||||||
|
|
||||||
|
if [ "${SUCCEEDED}" -ge 1 ]; then
|
||||||
|
OK=0
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${FAILED}" -ge 1 ]; then
|
||||||
|
OK=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
NOW_TS="$(date +%s)"
|
||||||
|
if [ $((NOW_TS - START_TS)) -ge "${DEADLINE_SECONDS}" ]; then
|
||||||
|
OK=2
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[ci] job status:"
|
||||||
|
kctl -n "${CI_NS}" get job "${JOB}" -o wide || true
|
||||||
|
|
||||||
|
echo "[ci] job logs (tail):"
|
||||||
|
kctl -n "${CI_NS}" logs "job/${JOB}" --tail=300 || true
|
||||||
|
|
||||||
|
if [ "${OK}" -ne 0 ]; then
|
||||||
|
echo "[ci] job did not reach Complete; describing job/pods for debug"
|
||||||
|
kctl -n "${CI_NS}" describe job "${JOB}" || true
|
||||||
|
kctl -n "${CI_NS}" get pods -l job-name="${JOB}" -o wide || true
|
||||||
|
kctl -n "${CI_NS}" describe pod -l job-name="${JOB}" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Deploy to prod
|
||||||
|
env:
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if command -v microk8s >/dev/null 2>&1; then
|
||||||
|
kctl() { microk8s kubectl "$@"; }
|
||||||
|
else
|
||||||
|
kctl() { kubectl "$@"; }
|
||||||
|
fi
|
||||||
|
|
||||||
|
TARGET_IMAGE="${DEPLOY_REGISTRY}/${IMAGE}:${SHA}"
|
||||||
|
|
||||||
|
kctl -n "${APP_NS}" set image "deployment/${DEPLOYMENT}" \
|
||||||
|
"${CONTAINER}=${TARGET_IMAGE}"
|
||||||
|
|
||||||
|
set +e
|
||||||
|
kctl -n "${APP_NS}" rollout status "deployment/${DEPLOYMENT}" --timeout=15m
|
||||||
|
ROLLOUT_RC=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ "${ROLLOUT_RC}" -ne 0 ]; then
|
||||||
|
echo "[deploy] rollout did not complete in time; collecting diagnostics"
|
||||||
|
|
||||||
|
SELECTOR="$(kctl -n "${APP_NS}" get deployment "${DEPLOYMENT}" \
|
||||||
|
-o jsonpath='{range $k,$v := .spec.selector.matchLabels}{$k}={$v},{end}' 2>/dev/null || true)"
|
||||||
|
SELECTOR="${SELECTOR%,}"
|
||||||
|
|
||||||
|
kctl -n "${APP_NS}" get deployment "${DEPLOYMENT}" -o wide || true
|
||||||
|
kctl -n "${APP_NS}" describe deployment "${DEPLOYMENT}" || true
|
||||||
|
|
||||||
|
if [ -n "${SELECTOR}" ]; then
|
||||||
|
kctl -n "${APP_NS}" get rs -l "${SELECTOR}" -o wide || true
|
||||||
|
kctl -n "${APP_NS}" get pods -l "${SELECTOR}" -o wide || true
|
||||||
|
kctl -n "${APP_NS}" describe pods -l "${SELECTOR}" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
kctl -n "${APP_NS}" get events --sort-by=.lastTimestamp | tail -n 100 || true
|
||||||
|
|
||||||
|
DESIRED="$(kctl -n "${APP_NS}" get deployment "${DEPLOYMENT}" \
|
||||||
|
-o jsonpath='{.spec.replicas}' 2>/dev/null || true)"
|
||||||
|
UPDATED="$(kctl -n "${APP_NS}" get deployment "${DEPLOYMENT}" \
|
||||||
|
-o jsonpath='{.status.updatedReplicas}' 2>/dev/null || true)"
|
||||||
|
AVAILABLE="$(kctl -n "${APP_NS}" get deployment "${DEPLOYMENT}" \
|
||||||
|
-o jsonpath='{.status.availableReplicas}' 2>/dev/null || true)"
|
||||||
|
|
||||||
|
DESIRED="${DESIRED:-0}"
|
||||||
|
UPDATED="${UPDATED:-0}"
|
||||||
|
AVAILABLE="${AVAILABLE:-0}"
|
||||||
|
|
||||||
|
echo "[deploy] desired=${DESIRED} updated=${UPDATED} available=${AVAILABLE}"
|
||||||
|
|
||||||
|
if [ "${UPDATED}" -ge "${DESIRED}" ] && [ "${AVAILABLE}" -ge "${DESIRED}" ]; then
|
||||||
|
echo "[deploy] New replica is healthy; old replica termination is delayed. Continuing."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "${ROLLOUT_RC}"
|
||||||
|
fi
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.nitro
|
||||||
|
.tanstack
|
||||||
|
.wrangler
|
||||||
|
.output
|
||||||
|
.vinxi
|
||||||
|
__unconfig*
|
||||||
|
todos.json
|
||||||
|
.codex
|
||||||
Vendored
+35
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"files.watcherExclude": {
|
||||||
|
"**/routeTree.gen.ts": true
|
||||||
|
},
|
||||||
|
"search.exclude": {
|
||||||
|
"**/routeTree.gen.ts": true
|
||||||
|
},
|
||||||
|
"files.readonlyInclude": {
|
||||||
|
"**/routeTree.gen.ts": true
|
||||||
|
},
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
|
},
|
||||||
|
"[javascriptreact]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
|
},
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
|
},
|
||||||
|
"[typescriptreact]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
|
},
|
||||||
|
"[json]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
|
},
|
||||||
|
"[jsonc]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
|
},
|
||||||
|
"[css]": {
|
||||||
|
"editor.defaultFormatter": "biomejs.biome"
|
||||||
|
},
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.organizeImports.biome": "explicit"
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:24-bookworm-slim AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM node:24-bookworm-slim AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build && npm prune --omit=dev
|
||||||
|
|
||||||
|
FROM node:24-bookworm-slim AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
COPY package*.json ./
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/.output ./.output
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["npm", "run", "start"]
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
Welcome to your new TanStack Start app!
|
||||||
|
|
||||||
|
# Getting Started
|
||||||
|
|
||||||
|
To run this application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
# Building For Production
|
||||||
|
|
||||||
|
To build this application for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
|
||||||
|
|
||||||
|
### Removing Tailwind CSS
|
||||||
|
|
||||||
|
If you prefer not to use Tailwind CSS:
|
||||||
|
|
||||||
|
1. Remove the demo pages in `src/routes/demo/`
|
||||||
|
2. Replace the Tailwind import in `src/styles.css` with your own styles
|
||||||
|
3. Remove `tailwindcss()` from the plugins array in `vite.config.ts`
|
||||||
|
4. Uninstall the packages: `npm install @tailwindcss/vite tailwindcss -D`
|
||||||
|
|
||||||
|
## Linting & Formatting
|
||||||
|
|
||||||
|
This project uses [Biome](https://biomejs.dev/) for linting and formatting. The following scripts are available:
|
||||||
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
npm run format
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
# Paraglide i18n
|
||||||
|
|
||||||
|
This add-on wires up ParaglideJS for localized routing and message formatting.
|
||||||
|
|
||||||
|
- Messages live in `project.inlang/messages`.
|
||||||
|
- URLs are localized through the Paraglide Vite plugin and router `rewrite` hooks.
|
||||||
|
- Run the dev server or build to regenerate the `src/paraglide` outputs.
|
||||||
|
|
||||||
|
|
||||||
|
## T3Env
|
||||||
|
|
||||||
|
- You can use T3Env to add type safety to your environment variables.
|
||||||
|
- Add Environment variables to the `src/env.mjs` file.
|
||||||
|
- Use the environment variables in your code.
|
||||||
|
|
||||||
|
### Usage
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { env } from "#/env";
|
||||||
|
|
||||||
|
console.log(env.VITE_APP_TITLE);
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Setting up PostHog
|
||||||
|
|
||||||
|
1. Create a PostHog account at [posthog.com](https://posthog.com)
|
||||||
|
2. Get your Project API Key from [Project Settings](https://app.posthog.com/project/settings)
|
||||||
|
3. Set `VITE_POSTHOG_KEY` in your `.env.local`
|
||||||
|
|
||||||
|
### Optional Configuration
|
||||||
|
|
||||||
|
- `VITE_POSTHOG_HOST` - Set this if you're using PostHog Cloud EU (`https://eu.i.posthog.com`) or self-hosting
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
This project uses [TanStack Router](https://tanstack.com/router) with file-based routing. Routes are managed as files in `src/routes`.
|
||||||
|
|
||||||
|
### Adding A Route
|
||||||
|
|
||||||
|
To add a new route to your application just add a new file in the `./src/routes` directory.
|
||||||
|
|
||||||
|
TanStack will automatically generate the content of the route file for you.
|
||||||
|
|
||||||
|
Now that you have two routes you can use a `Link` component to navigate between them.
|
||||||
|
|
||||||
|
### Adding Links
|
||||||
|
|
||||||
|
To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Link } from "@tanstack/react-router";
|
||||||
|
```
|
||||||
|
|
||||||
|
Then anywhere in your JSX you can use it like so:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Link to="/about">About</Link>
|
||||||
|
```
|
||||||
|
|
||||||
|
This will create a link that will navigate to the `/about` route.
|
||||||
|
|
||||||
|
More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent).
|
||||||
|
|
||||||
|
### Using A Layout
|
||||||
|
|
||||||
|
In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you render `{children}` in the `shellComponent`.
|
||||||
|
|
||||||
|
Here is an example layout that includes a header:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createRootRoute({
|
||||||
|
head: () => ({
|
||||||
|
meta: [
|
||||||
|
{ charSet: 'utf-8' },
|
||||||
|
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
|
||||||
|
{ title: 'My App' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
shellComponent: ({ children }) => (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<HeadContent />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<nav>
|
||||||
|
<Link to="/">Home</Link>
|
||||||
|
<Link to="/about">About</Link>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
{children}
|
||||||
|
<Scripts />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
|
||||||
|
|
||||||
|
## Server Functions
|
||||||
|
|
||||||
|
TanStack Start provides server functions that allow you to write server-side code that seamlessly integrates with your client components.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createServerFn } from '@tanstack/react-start'
|
||||||
|
|
||||||
|
const getServerTime = createServerFn({
|
||||||
|
method: 'GET',
|
||||||
|
}).handler(async () => {
|
||||||
|
return new Date().toISOString()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Use in a component
|
||||||
|
function MyComponent() {
|
||||||
|
const [time, setTime] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getServerTime().then(setTime)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return <div>Server time: {time}</div>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Routes
|
||||||
|
|
||||||
|
You can create API routes by using the `server` property in your route definitions:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { json } from '@tanstack/react-start'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/api/hello')({
|
||||||
|
server: {
|
||||||
|
handlers: {
|
||||||
|
GET: () => json({ message: 'Hello, World!' }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Fetching
|
||||||
|
|
||||||
|
There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/people')({
|
||||||
|
loader: async () => {
|
||||||
|
const response = await fetch('https://swapi.dev/api/people')
|
||||||
|
return response.json()
|
||||||
|
},
|
||||||
|
component: PeopleComponent,
|
||||||
|
})
|
||||||
|
|
||||||
|
function PeopleComponent() {
|
||||||
|
const data = Route.useLoaderData()
|
||||||
|
return (
|
||||||
|
<ul>
|
||||||
|
{data.results.map((person) => (
|
||||||
|
<li key={person.name}>{person.name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).
|
||||||
|
|
||||||
|
# Demo files
|
||||||
|
|
||||||
|
Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed.
|
||||||
|
|
||||||
|
# Learn More
|
||||||
|
|
||||||
|
You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).
|
||||||
|
|
||||||
|
For TanStack Start specific documentation, visit [TanStack Start](https://tanstack.com/start).
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
|
||||||
|
"vcs": {
|
||||||
|
"enabled": true,
|
||||||
|
"clientKind": "git",
|
||||||
|
"useIgnoreFile": true
|
||||||
|
},
|
||||||
|
"files": {
|
||||||
|
"ignoreUnknown": true,
|
||||||
|
"includes": ["**", "!node_modules", "!.next", "!dist", "!build"]
|
||||||
|
},
|
||||||
|
"formatter": {
|
||||||
|
"enabled": true,
|
||||||
|
"indentStyle": "tab",
|
||||||
|
"indentWidth": 2,
|
||||||
|
"lineWidth": 100
|
||||||
|
},
|
||||||
|
"assist": {
|
||||||
|
"actions": {
|
||||||
|
"source": {
|
||||||
|
"organizeImports": {
|
||||||
|
"level": "on",
|
||||||
|
"options": {
|
||||||
|
"groups": [
|
||||||
|
":URL:",
|
||||||
|
":BLANK_LINE:",
|
||||||
|
":PACKAGE:",
|
||||||
|
":BLANK_LINE:",
|
||||||
|
":ALIAS:",
|
||||||
|
":BLANK_LINE:",
|
||||||
|
":PATH:"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": true,
|
||||||
|
"rules": {
|
||||||
|
"recommended": true,
|
||||||
|
"nursery": {
|
||||||
|
"useSortedClasses": {
|
||||||
|
"level": "error",
|
||||||
|
"fix": "safe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"correctness": {
|
||||||
|
"noUnusedImports": {
|
||||||
|
"level": "error",
|
||||||
|
"fix": "safe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"style": {
|
||||||
|
"noUnusedTemplateLiteral": "error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"domains": {
|
||||||
|
"next": "recommended",
|
||||||
|
"react": "recommended"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"javascript": {
|
||||||
|
"formatter": {
|
||||||
|
"quoteStyle": "single",
|
||||||
|
"trailingCommas": "none"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import * as Sentry from '@sentry/tanstackstart-react'
|
||||||
|
|
||||||
|
const sentryDsn = import.meta.env?.VITE_SENTRY_DSN ?? process.env.VITE_SENTRY_DSN
|
||||||
|
|
||||||
|
if (!sentryDsn) {
|
||||||
|
console.warn('VITE_SENTRY_DSN is not defined. Sentry is not running.')
|
||||||
|
} else {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: sentryDsn,
|
||||||
|
// Adds request headers and IP for users, for more info visit:
|
||||||
|
// https://docs.sentry.io/platforms/javascript/guides/tanstackstart-react/configuration/options/#sendDefaultPii
|
||||||
|
sendDefaultPii: true,
|
||||||
|
tracesSampleRate: 1.0,
|
||||||
|
replaysSessionSampleRate: 1.0,
|
||||||
|
replaysOnErrorSampleRate: 1.0,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
|
"home_page": "Startseite",
|
||||||
|
"about_page": "Über uns",
|
||||||
|
"example_message": "Willkommen in deiner i18n-App.",
|
||||||
|
"language_label": "Sprache",
|
||||||
|
"current_locale": "Aktuelle Sprache: {locale}",
|
||||||
|
"learn_router": "Paraglide JS lernen"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||||
|
"home_page": "Home page",
|
||||||
|
"about_page": "About page",
|
||||||
|
"example_message": "Welcome to your i18n app.",
|
||||||
|
"language_label": "Language",
|
||||||
|
"current_locale": "Current locale: {locale}",
|
||||||
|
"learn_router": "Learn Paraglide JS"
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { defineConfig } from "nitro";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
baseURL: "/app/",
|
||||||
|
serverDir: "./server",
|
||||||
|
});
|
||||||
Generated
+8844
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"name": "monie-web",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"imports": {
|
||||||
|
"#/*": "./src/*"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "dotenv -e .env.local -- sh -c \"NODE_OPTIONS='--import ./instrument.server.mjs' vite dev --port 3000\"",
|
||||||
|
"build": "vite build && cp instrument.server.mjs .output/server",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"format": "biome format",
|
||||||
|
"lint": "biome lint",
|
||||||
|
"check": "biome check",
|
||||||
|
"start": "node --import ./.output/server/instrument.server.mjs .output/server/index.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@faker-js/faker": "^10.3.0",
|
||||||
|
"@posthog/react": "^1.7.0",
|
||||||
|
"@sentry/tanstackstart-react": "^10.42.0",
|
||||||
|
"@t3-oss/env-core": "^0.13.10",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@tanstack/match-sorter-utils": "latest",
|
||||||
|
"@tanstack/query-db-collection": "latest",
|
||||||
|
"@tanstack/react-db": "latest",
|
||||||
|
"@tanstack/react-devtools": "latest",
|
||||||
|
"@tanstack/react-form": "latest",
|
||||||
|
"@tanstack/react-query": "latest",
|
||||||
|
"@tanstack/react-query-devtools": "latest",
|
||||||
|
"@tanstack/react-router": "latest",
|
||||||
|
"@tanstack/react-router-devtools": "latest",
|
||||||
|
"@tanstack/react-router-ssr-query": "latest",
|
||||||
|
"@tanstack/react-start": "latest",
|
||||||
|
"@tanstack/react-store": "latest",
|
||||||
|
"@tanstack/react-table": "latest",
|
||||||
|
"@tanstack/router-plugin": "^1.132.0",
|
||||||
|
"@tanstack/store": "latest",
|
||||||
|
"dotenv-cli": "^11.0.0",
|
||||||
|
"lucide-react": "^0.545.0",
|
||||||
|
"nitro": "^3.0.260311-beta",
|
||||||
|
"posthog-js": "^1.358.1",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@biomejs/biome": "2.4.5",
|
||||||
|
"@inlang/paraglide-js": "^2.13.1",
|
||||||
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
|
"@tanstack/devtools-event-client": "latest",
|
||||||
|
"@tanstack/devtools-vite": "latest",
|
||||||
|
"@testing-library/dom": "^10.4.1",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@types/node": "^22.10.2",
|
||||||
|
"@types/react": "^19.2.0",
|
||||||
|
"@types/react-dom": "^19.2.0",
|
||||||
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
|
"vite": "^7.3.1",
|
||||||
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
|
"vitest": "^3.0.5"
|
||||||
|
},
|
||||||
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"esbuild",
|
||||||
|
"lightningcss"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://inlang.com/schema/project-settings",
|
||||||
|
"baseLocale": "en",
|
||||||
|
"locales": ["en", "de"],
|
||||||
|
"modules": [
|
||||||
|
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
|
||||||
|
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
|
||||||
|
],
|
||||||
|
"plugin.inlang.messageFormat": {
|
||||||
|
"pathPattern": "./messages/{locale}.json"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="222.000000pt" height="132.000000pt" viewBox="0 0 222.000000 132.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
|
||||||
|
<g transform="translate(0.000000,132.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M1480 1297 c-19 -6 -53 -29 -75 -52 -139 -139 -40 -375 157 -375 85
|
||||||
|
0 154 45 193 125 70 142 -23 300 -182 311 -32 2 -74 -2 -93 -9z"/>
|
||||||
|
<path d="M643 1115 c-104 -29 -183 -94 -266 -220 -145 -221 -241 -488 -301
|
||||||
|
-837 l-7 -38 168 0 168 0 23 107 c73 334 260 783 327 783 21 0 28 -22 65 -196
|
||||||
|
85 -397 166 -574 299 -652 41 -24 56 -27 136 -27 74 0 98 4 132 22 69 38 160
|
||||||
|
131 228 235 36 54 94 143 131 198 62 95 86 116 97 87 10 -25 28 -234 35 -392
|
||||||
|
l7 -160 143 -3 142 -3 0 63 c0 133 -45 440 -77 522 -27 68 -85 139 -139 166
|
||||||
|
-39 20 -64 25 -124 25 -136 0 -206 -53 -386 -295 -113 -151 -146 -185 -168
|
||||||
|
-177 -30 12 -56 73 -87 207 -82 356 -130 456 -254 535 -89 57 -200 76 -292 50z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="222.000000pt" height="132.000000pt" viewBox="0 0 222.000000 132.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
|
||||||
|
<g transform="translate(0.000000,132.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#FFFFFF" stroke="none">
|
||||||
|
<path d="M1480 1297 c-19 -6 -53 -29 -75 -52 -139 -139 -40 -375 157 -375 85
|
||||||
|
0 154 45 193 125 70 142 -23 300 -182 311 -32 2 -74 -2 -93 -9z"/>
|
||||||
|
<path d="M643 1115 c-104 -29 -183 -94 -266 -220 -145 -221 -241 -488 -301
|
||||||
|
-837 l-7 -38 168 0 168 0 23 107 c73 334 260 783 327 783 21 0 28 -22 65 -196
|
||||||
|
85 -397 166 -574 299 -652 41 -24 56 -27 136 -27 74 0 98 4 132 22 69 38 160
|
||||||
|
131 228 235 36 54 94 143 131 198 62 95 86 116 97 87 10 -25 28 -234 35 -392
|
||||||
|
l7 -160 143 -3 142 -3 0 63 c0 133 -45 440 -77 522 -27 68 -85 139 -139 166
|
||||||
|
-39 20 -64 25 -124 25 -136 0 -206 -53 -386 -295 -113 -151 -146 -185 -168
|
||||||
|
-177 -30 12 -56 73 -87 207 -82 356 -130 456 -254 535 -89 57 -200 76 -292 50z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" standalone="no"?>
|
||||||
|
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||||
|
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||||
|
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="222.000000pt" height="132.000000pt" viewBox="0 0 222.000000 132.000000"
|
||||||
|
preserveAspectRatio="xMidYMid meet">
|
||||||
|
|
||||||
|
<g transform="translate(0.000000,132.000000) scale(0.100000,-0.100000)"
|
||||||
|
fill="#000000" stroke="none">
|
||||||
|
<path d="M1480 1297 c-19 -6 -53 -29 -75 -52 -139 -139 -40 -375 157 -375 85
|
||||||
|
0 154 45 193 125 70 142 -23 300 -182 311 -32 2 -74 -2 -93 -9z"/>
|
||||||
|
<path d="M643 1115 c-104 -29 -183 -94 -266 -220 -145 -221 -241 -488 -301
|
||||||
|
-837 l-7 -38 168 0 168 0 23 107 c73 334 260 783 327 783 21 0 28 -22 65 -196
|
||||||
|
85 -397 166 -574 299 -652 41 -24 56 -27 136 -27 74 0 98 4 132 22 69 38 160
|
||||||
|
131 228 235 36 54 94 143 131 198 62 95 86 116 97 87 10 -25 28 -234 35 -392
|
||||||
|
l7 -160 143 -3 142 -3 0 63 c0 133 -45 440 -77 522 -27 68 -85 139 -139 166
|
||||||
|
-39 20 -64 25 -124 25 -136 0 -206 -53 -386 -295 -113 -151 -146 -185 -168
|
||||||
|
-177 -30 12 -56 73 -87 207 -82 356 -130 456 -254 535 -89 57 -200 76 -292 50z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 218 78" width="218" height="78">
|
||||||
|
<g fill="#000000" fill-rule="evenodd">
|
||||||
|
<path d="M 167 39 L 166 40 L 166 47 L 165 48 L 165 53 L 164 54 L 164 59 L 163 60 L 163 61 L 164 62 L 164 63 L 166 65 L 171 65 L 172 64 L 174 64 L 175 63 L 174 62 L 172 62 L 171 61 L 171 56 L 172 55 L 172 49 L 173 48 L 173 43 L 174 42 L 174 41 L 173 40 L 173 39 Z"/>
|
||||||
|
<path d="M 130 39 L 129 40 L 129 48 L 128 49 L 128 54 L 127 55 L 127 60 L 126 61 L 126 64 L 133 64 L 133 63 L 134 62 L 134 56 L 135 55 L 135 51 L 142 44 L 143 44 L 144 43 L 148 43 L 149 44 L 149 45 L 150 46 L 150 48 L 149 49 L 149 53 L 148 54 L 148 58 L 147 59 L 147 63 L 149 65 L 154 65 L 155 64 L 157 64 L 158 63 L 157 63 L 155 61 L 155 55 L 156 54 L 156 49 L 157 48 L 157 43 L 156 42 L 156 41 L 154 39 L 148 39 L 147 40 L 145 40 L 143 42 L 142 42 L 141 43 L 140 43 L 138 45 L 138 46 L 137 47 L 136 46 L 136 40 L 135 39 Z"/>
|
||||||
|
<path d="M 196 38 L 195 39 L 191 39 L 190 40 L 189 40 L 188 41 L 187 41 L 181 47 L 181 48 L 180 49 L 180 51 L 179 52 L 179 58 L 180 59 L 180 60 L 183 63 L 184 63 L 185 64 L 187 64 L 188 65 L 195 65 L 196 64 L 199 64 L 200 63 L 201 63 L 202 62 L 203 62 L 205 60 L 206 60 L 206 59 L 205 59 L 204 60 L 203 60 L 202 61 L 201 61 L 200 62 L 198 62 L 197 63 L 192 63 L 191 62 L 190 62 L 187 59 L 187 55 L 186 54 L 188 52 L 196 52 L 197 51 L 200 51 L 201 50 L 203 50 L 207 46 L 207 43 L 206 42 L 206 41 L 205 40 L 204 40 L 203 39 L 200 39 L 199 38 Z M 194 40 L 199 40 L 201 42 L 201 46 L 197 50 L 196 50 L 195 51 L 188 51 L 187 50 L 187 49 L 188 48 L 188 47 L 189 46 L 189 45 Z"/>
|
||||||
|
<path d="M 108 38 L 107 39 L 103 39 L 102 40 L 101 40 L 100 41 L 99 41 L 98 42 L 97 42 L 96 43 L 96 44 L 94 46 L 94 47 L 93 48 L 93 49 L 92 50 L 92 57 L 93 58 L 93 59 L 94 60 L 94 61 L 95 62 L 96 62 L 98 64 L 101 64 L 102 65 L 109 65 L 110 64 L 113 64 L 114 63 L 115 63 L 120 58 L 120 57 L 121 56 L 121 55 L 122 54 L 122 47 L 121 46 L 121 44 L 117 40 L 116 40 L 115 39 L 112 39 L 111 38 Z M 106 41 L 107 40 L 111 40 L 114 43 L 114 44 L 115 45 L 115 53 L 114 54 L 114 56 L 113 57 L 113 58 L 112 59 L 112 60 L 109 63 L 108 63 L 107 64 L 105 64 L 104 63 L 103 63 L 100 60 L 100 49 L 101 48 L 101 46 L 102 45 L 102 44 L 105 41 Z"/>
|
||||||
|
<path d="M 173 23 L 172 24 L 170 24 L 169 25 L 169 26 L 168 27 L 168 30 L 170 32 L 173 32 L 174 31 L 175 31 L 177 29 L 177 25 L 176 24 L 175 24 L 174 23 Z"/>
|
||||||
|
<path d="M 52 10 L 51 11 L 47 11 L 46 12 L 45 12 L 44 13 L 43 13 L 41 15 L 40 15 L 30 25 L 30 26 L 27 29 L 27 30 L 25 32 L 25 33 L 23 35 L 23 36 L 22 37 L 22 38 L 21 39 L 21 40 L 19 42 L 19 43 L 18 44 L 18 45 L 17 46 L 17 47 L 16 48 L 16 49 L 15 50 L 15 51 L 14 52 L 14 54 L 13 55 L 13 56 L 12 57 L 12 58 L 11 59 L 11 61 L 10 62 L 10 65 L 11 66 L 13 66 L 14 67 L 18 67 L 19 66 L 21 66 L 22 65 L 23 65 L 25 63 L 25 61 L 26 60 L 26 59 L 27 58 L 27 56 L 28 55 L 28 53 L 29 52 L 29 50 L 30 49 L 30 47 L 31 46 L 31 45 L 32 44 L 32 43 L 33 42 L 33 41 L 34 40 L 34 39 L 35 38 L 35 37 L 36 36 L 36 35 L 37 34 L 37 33 L 39 31 L 39 30 L 41 28 L 41 27 L 47 21 L 48 22 L 48 25 L 47 26 L 47 29 L 46 30 L 46 32 L 45 33 L 45 36 L 44 37 L 44 40 L 43 41 L 43 45 L 42 46 L 42 54 L 43 55 L 43 57 L 46 60 L 47 60 L 48 61 L 53 61 L 54 60 L 55 60 L 57 58 L 58 58 L 59 57 L 59 56 L 62 53 L 62 52 L 63 51 L 63 50 L 65 48 L 65 47 L 66 46 L 66 45 L 68 43 L 68 42 L 69 41 L 69 40 L 71 38 L 71 37 L 73 35 L 73 34 L 76 31 L 76 30 L 79 27 L 80 27 L 81 28 L 80 29 L 80 31 L 79 32 L 79 34 L 78 35 L 78 37 L 77 38 L 77 40 L 76 41 L 76 44 L 75 45 L 75 48 L 74 49 L 74 54 L 73 55 L 73 57 L 74 58 L 74 61 L 75 62 L 75 63 L 77 65 L 78 65 L 79 66 L 81 66 L 82 67 L 90 67 L 91 66 L 89 66 L 88 65 L 87 65 L 85 63 L 85 62 L 84 61 L 84 50 L 85 49 L 85 46 L 86 45 L 86 42 L 87 41 L 87 38 L 88 37 L 88 34 L 89 33 L 89 31 L 90 30 L 90 27 L 91 26 L 91 23 L 92 22 L 92 19 L 90 17 L 84 17 L 83 18 L 82 18 L 80 20 L 79 20 L 73 26 L 73 27 L 69 31 L 69 32 L 66 35 L 66 36 L 64 38 L 64 39 L 62 41 L 62 42 L 59 45 L 59 46 L 56 49 L 55 49 L 54 48 L 54 47 L 53 46 L 53 44 L 54 43 L 54 38 L 55 37 L 55 34 L 56 33 L 56 31 L 57 30 L 57 27 L 58 26 L 58 24 L 59 23 L 59 19 L 60 18 L 60 16 L 59 15 L 59 13 L 58 12 L 57 12 L 56 11 L 53 11 Z"/>
|
||||||
|
</g></svg>
|
||||||
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"short_name": "Monie",
|
||||||
|
"name": "Monie",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "Logo.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# https://www.robotstxt.org/robotstxt.html
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
@@ -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>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { faker } from '@faker-js/faker'
|
||||||
|
|
||||||
|
export type Person = {
|
||||||
|
id: number
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
age: number
|
||||||
|
visits: number
|
||||||
|
progress: number
|
||||||
|
status: 'relationship' | 'complicated' | 'single'
|
||||||
|
subRows?: Person[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = (len: number) => {
|
||||||
|
const arr: number[] = []
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
arr.push(i)
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
}
|
||||||
|
|
||||||
|
const newPerson = (num: number): Person => {
|
||||||
|
return {
|
||||||
|
id: num,
|
||||||
|
firstName: faker.person.firstName(),
|
||||||
|
lastName: faker.person.lastName(),
|
||||||
|
age: faker.number.int(40),
|
||||||
|
visits: faker.number.int(1000),
|
||||||
|
progress: faker.number.int(100),
|
||||||
|
status: faker.helpers.shuffle<Person['status']>([
|
||||||
|
'relationship',
|
||||||
|
'complicated',
|
||||||
|
'single',
|
||||||
|
])[0]!,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeData(...lens: number[]) {
|
||||||
|
const makeDataLevel = (depth = 0): Person[] => {
|
||||||
|
const len = lens[depth]!
|
||||||
|
return range(len).map((index): Person => {
|
||||||
|
return {
|
||||||
|
...newPerson(index),
|
||||||
|
subRows: lens[depth + 1] ? makeDataLevel(depth + 1) : undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return makeDataLevel()
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import {
|
||||||
|
createCollection,
|
||||||
|
localOnlyCollectionOptions,
|
||||||
|
} from '@tanstack/react-db'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const MessageSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
text: z.string(),
|
||||||
|
user: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type Message = z.infer<typeof MessageSchema>
|
||||||
|
|
||||||
|
export const messagesCollection = createCollection(
|
||||||
|
localOnlyCollectionOptions({
|
||||||
|
getKey: (message) => message.id,
|
||||||
|
schema: MessageSchema,
|
||||||
|
}),
|
||||||
|
)
|
||||||
+39
@@ -0,0 +1,39 @@
|
|||||||
|
import { createEnv } from '@t3-oss/env-core'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
export const env = createEnv({
|
||||||
|
server: {
|
||||||
|
SERVER_URL: z.string().url().optional(),
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The prefix that client-side variables must have. This is enforced both at
|
||||||
|
* a type-level and at runtime.
|
||||||
|
*/
|
||||||
|
clientPrefix: 'VITE_',
|
||||||
|
|
||||||
|
client: {
|
||||||
|
VITE_APP_TITLE: z.string().min(1).optional(),
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What object holds the environment variables at runtime. This is usually
|
||||||
|
* `process.env` or `import.meta.env`.
|
||||||
|
*/
|
||||||
|
runtimeEnv: import.meta.env,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* By default, this library will feed the environment variables directly to
|
||||||
|
* the Zod validator.
|
||||||
|
*
|
||||||
|
* This means that if you have an empty string for a value that is supposed
|
||||||
|
* to be a number (e.g. `PORT=` in a ".env" file), Zod will incorrectly flag
|
||||||
|
* it as a type mismatch violation. Additionally, if you have an empty string
|
||||||
|
* for a value that is supposed to be a string with a default value (e.g.
|
||||||
|
* `DOMAIN=` in an ".env" file), the default value will never be applied.
|
||||||
|
*
|
||||||
|
* In order to solve these issues, we recommend that all new projects
|
||||||
|
* explicitly specify this option as true.
|
||||||
|
*/
|
||||||
|
emptyStringAsUndefined: true,
|
||||||
|
})
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { createFormHookContexts } from '@tanstack/react-form'
|
||||||
|
|
||||||
|
export const { fieldContext, useFieldContext, formContext, useFormContext } =
|
||||||
|
createFormHookContexts()
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { createFormHook } from '@tanstack/react-form'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SubscribeButton,
|
||||||
|
TextArea,
|
||||||
|
TextField,
|
||||||
|
} from '../components/demo.FormComponents'
|
||||||
|
import { fieldContext, formContext } from './demo.form-context'
|
||||||
|
|
||||||
|
export const { useAppForm } = createFormHook({
|
||||||
|
fieldComponents: {
|
||||||
|
TextField,
|
||||||
|
Select,
|
||||||
|
TextArea,
|
||||||
|
},
|
||||||
|
formComponents: {
|
||||||
|
SubscribeButton,
|
||||||
|
},
|
||||||
|
fieldContext,
|
||||||
|
formContext,
|
||||||
|
})
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { useLiveQuery } from '@tanstack/react-db'
|
||||||
|
|
||||||
|
import { messagesCollection, type Message } from '#/db-collections'
|
||||||
|
|
||||||
|
import type { Collection } from '@tanstack/react-db'
|
||||||
|
|
||||||
|
function useStreamConnection(
|
||||||
|
url: string,
|
||||||
|
collection: Collection<any, any, any>,
|
||||||
|
) {
|
||||||
|
const loadedRef = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
if (loadedRef.current) return
|
||||||
|
loadedRef.current = true
|
||||||
|
|
||||||
|
const response = await fetch(url)
|
||||||
|
const reader = response.body?.getReader()
|
||||||
|
if (!reader) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
for (const chunk of decoder
|
||||||
|
.decode(value, { stream: true })
|
||||||
|
.split('\n')
|
||||||
|
.filter((chunk) => chunk.length > 0)) {
|
||||||
|
collection.insert(JSON.parse(chunk))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchData()
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChat() {
|
||||||
|
useStreamConnection('/demo/db-chat-api', messagesCollection)
|
||||||
|
|
||||||
|
const sendMessage = (message: string, user: string) => {
|
||||||
|
fetch('/demo/db-chat-api', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ text: message.trim(), user: user.trim() }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { sendMessage }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMessages() {
|
||||||
|
const { data: messages } = useLiveQuery((q) =>
|
||||||
|
q.from({ message: messagesCollection }).select(({ message }) => ({
|
||||||
|
...message,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
return messages as Message[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import posthog from 'posthog-js'
|
||||||
|
import { PostHogProvider as BasePostHogProvider } from '@posthog/react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && import.meta.env.VITE_POSTHOG_KEY) {
|
||||||
|
posthog.init(import.meta.env.VITE_POSTHOG_KEY, {
|
||||||
|
api_host: import.meta.env.VITE_POSTHOG_HOST || 'https://us.i.posthog.com',
|
||||||
|
person_profiles: 'identified_only',
|
||||||
|
capture_pageview: false,
|
||||||
|
defaults: '2025-11-30',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PostHogProviderProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PostHogProvider({ children }: PostHogProviderProps) {
|
||||||
|
return <BasePostHogProvider client={posthog}>{children}</BasePostHogProvider>
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { ReactQueryDevtoolsPanel } from '@tanstack/react-query-devtools'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Tanstack Query',
|
||||||
|
render: <ReactQueryDevtoolsPanel />,
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { QueryClient } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
export function getContext() {
|
||||||
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
|
return {
|
||||||
|
queryClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export default function TanstackQueryProvider() {}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { EventClient } from '@tanstack/devtools-event-client'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
import { store, fullName } from './demo-store'
|
||||||
|
|
||||||
|
type EventMap = {
|
||||||
|
'store-devtools:state': {
|
||||||
|
firstName: string
|
||||||
|
lastName: string
|
||||||
|
fullName: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class StoreDevtoolsEventClient extends EventClient<EventMap> {
|
||||||
|
constructor() {
|
||||||
|
super({
|
||||||
|
pluginId: 'store-devtools',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sdec = new StoreDevtoolsEventClient()
|
||||||
|
|
||||||
|
store.subscribe(() => {
|
||||||
|
sdec.emit('state', {
|
||||||
|
firstName: store.state.firstName,
|
||||||
|
lastName: store.state.lastName,
|
||||||
|
fullName: fullName.state,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function DevtoolPanel() {
|
||||||
|
const [state, setState] = useState<EventMap['store-devtools:state']>(() => ({
|
||||||
|
firstName: store.state.firstName,
|
||||||
|
lastName: store.state.lastName,
|
||||||
|
fullName: fullName.state,
|
||||||
|
}))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return sdec.on('state', (e) => setState(e.payload))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 grid gap-4 grid-cols-[1fr_10fr]">
|
||||||
|
<div className="text-sm font-bold text-gray-500 whitespace-nowrap">
|
||||||
|
First Name
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">{state?.firstName}</div>
|
||||||
|
<div className="text-sm font-bold text-gray-500 whitespace-nowrap">
|
||||||
|
Last Name
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">{state?.lastName}</div>
|
||||||
|
<div className="text-sm font-bold text-gray-500 whitespace-nowrap">
|
||||||
|
Full Name
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">{state?.fullName}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'TanStack Store',
|
||||||
|
render: <DevtoolPanel />,
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { Store } from '@tanstack/store'
|
||||||
|
|
||||||
|
export const store = new Store({
|
||||||
|
firstName: 'Jane',
|
||||||
|
lastName: 'Smith',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const fullName = new Store(
|
||||||
|
`${store.state.firstName} ${store.state.lastName}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
store.subscribe(() => {
|
||||||
|
fullName.setState(() => `${store.state.firstName} ${store.state.lastName}`)
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
|
||||||
|
// This file was automatically generated by TanStack Router.
|
||||||
|
// You should NOT make any changes in this file as it will be overwritten.
|
||||||
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as AboutRouteImport } from './routes/about'
|
||||||
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
|
|
||||||
|
const AboutRoute = AboutRouteImport.update({
|
||||||
|
id: '/about',
|
||||||
|
path: '/about',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const IndexRoute = IndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
export interface FileRoutesByFullPath {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/about': typeof AboutRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesByTo {
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/about': typeof AboutRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesById {
|
||||||
|
__root__: typeof rootRouteImport
|
||||||
|
'/': typeof IndexRoute
|
||||||
|
'/about': typeof AboutRoute
|
||||||
|
}
|
||||||
|
export interface FileRouteTypes {
|
||||||
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
fullPaths: '/' | '/about'
|
||||||
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
to: '/' | '/about'
|
||||||
|
id: '__root__' | '/' | '/about'
|
||||||
|
fileRoutesById: FileRoutesById
|
||||||
|
}
|
||||||
|
export interface RootRouteChildren {
|
||||||
|
IndexRoute: typeof IndexRoute
|
||||||
|
AboutRoute: typeof AboutRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface FileRoutesByPath {
|
||||||
|
'/about': {
|
||||||
|
id: '/about'
|
||||||
|
path: '/about'
|
||||||
|
fullPath: '/about'
|
||||||
|
preLoaderRoute: typeof AboutRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/': {
|
||||||
|
id: '/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof IndexRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
|
IndexRoute: IndexRoute,
|
||||||
|
AboutRoute: AboutRoute,
|
||||||
|
}
|
||||||
|
export const routeTree = rootRouteImport
|
||||||
|
._addFileChildren(rootRouteChildren)
|
||||||
|
._addFileTypes<FileRouteTypes>()
|
||||||
|
|
||||||
|
import type { getRouter } from './router.tsx'
|
||||||
|
import type { createStart } from '@tanstack/react-start'
|
||||||
|
declare module '@tanstack/react-start' {
|
||||||
|
interface Register {
|
||||||
|
ssr: true
|
||||||
|
router: Awaited<ReturnType<typeof getRouter>>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { createRouter as createTanStackRouter } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
import { getContext } from './integrations/tanstack-query/root-provider';
|
||||||
|
import { routeTree } from './routeTree.gen';
|
||||||
|
|
||||||
|
export function getRouter() {
|
||||||
|
const context = getContext();
|
||||||
|
|
||||||
|
const router = createTanStackRouter({
|
||||||
|
routeTree,
|
||||||
|
context,
|
||||||
|
scrollRestoration: true,
|
||||||
|
defaultPreload: 'intent',
|
||||||
|
defaultPreloadStaleTime: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface Register {
|
||||||
|
router: ReturnType<typeof getRouter>;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { TanStackDevtools } from '@tanstack/react-devtools';
|
||||||
|
import type { QueryClient } from '@tanstack/react-query';
|
||||||
|
import { createRootRouteWithContext, HeadContent, Scripts } from '@tanstack/react-router';
|
||||||
|
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools';
|
||||||
|
|
||||||
|
import { getLocale } from '#/paraglide/runtime';
|
||||||
|
|
||||||
|
import Footer from '../components/Footer';
|
||||||
|
import Header from '../components/Header';
|
||||||
|
import PostHogProvider from '../integrations/posthog/provider';
|
||||||
|
import TanStackQueryDevtools from '../integrations/tanstack-query/devtools';
|
||||||
|
import StoreDevtools from '../lib/demo-store-devtools';
|
||||||
|
import appCss from '../styles.css?url';
|
||||||
|
|
||||||
|
interface MyRouterContext {
|
||||||
|
queryClient: QueryClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
const THEME_INIT_SCRIPT = `(function(){try{var stored=window.localStorage.getItem('theme');var mode=(stored==='light'||stored==='dark'||stored==='auto')?stored:'auto';var prefersDark=window.matchMedia('(prefers-color-scheme: dark)').matches;var resolved=mode==='auto'?(prefersDark?'dark':'light'):mode;var root=document.documentElement;root.classList.remove('light','dark');root.classList.add(resolved);if(mode==='auto'){root.removeAttribute('data-theme')}else{root.setAttribute('data-theme',mode)}root.style.colorScheme=resolved;}catch(e){}})();`;
|
||||||
|
|
||||||
|
export const Route = createRootRouteWithContext<MyRouterContext>()({
|
||||||
|
beforeLoad: async () => {
|
||||||
|
// Other redirect strategies are possible; see
|
||||||
|
// https://github.com/TanStack/router/tree/main/examples/react/i18n-paraglide#offline-redirect
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.setAttribute('lang', getLocale());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
head: () => ({
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
charSet: 'utf-8'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'viewport',
|
||||||
|
content: 'width=device-width, initial-scale=1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Monie'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
rel: 'stylesheet',
|
||||||
|
href: appCss
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
shellComponent: RootDocument
|
||||||
|
});
|
||||||
|
|
||||||
|
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang={getLocale()} suppressHydrationWarning>
|
||||||
|
{/** biome-ignore lint/style/noHeadElement: TanStack Start shell renders the full document structure */}
|
||||||
|
<head>
|
||||||
|
{/** biome-ignore lint/security/noDangerouslySetInnerHtml: theme init script must run before hydration to avoid theme flash */}
|
||||||
|
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
|
||||||
|
<HeadContent />
|
||||||
|
</head>
|
||||||
|
<body className="wrap-anywhere font-sans antialiased">
|
||||||
|
<PostHogProvider>
|
||||||
|
<Header />
|
||||||
|
{children}
|
||||||
|
<Footer />
|
||||||
|
<TanStackDevtools
|
||||||
|
config={{
|
||||||
|
position: 'bottom-right'
|
||||||
|
}}
|
||||||
|
plugins={[
|
||||||
|
{
|
||||||
|
name: 'Tanstack Router',
|
||||||
|
render: <TanStackRouterDevtoolsPanel />
|
||||||
|
},
|
||||||
|
StoreDevtools,
|
||||||
|
TanStackQueryDevtools
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</PostHogProvider>
|
||||||
|
<Scripts />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/about')({
|
||||||
|
component: About
|
||||||
|
});
|
||||||
|
|
||||||
|
function About() {
|
||||||
|
return (
|
||||||
|
<main className="page-wrap px-4 py-12">
|
||||||
|
<section className="island-shell rounded-2xl p-6 sm:p-8">
|
||||||
|
<p className="island-kicker mb-2">About</p>
|
||||||
|
<h1 className="display-title mb-3 font-bold text-(--sea-ink) text-4xl sm:text-5xl">
|
||||||
|
A small starter with room to grow.
|
||||||
|
</h1>
|
||||||
|
<p className="m-0 max-w-3xl text-(--sea-ink-soft) text-base leading-8">
|
||||||
|
TanStack Start gives you type-safe routing, server functions, and modern SSR defaults. Use
|
||||||
|
this as a clean foundation, then layer in your own routes, styling, and add-ons.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/')({ component: App })
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<main className="page-wrap px-4 pb-8 pt-14">
|
||||||
|
<section className="island-shell rise-in relative overflow-hidden rounded-[2rem] px-6 py-10 sm:px-10 sm:py-14">
|
||||||
|
<div className="pointer-events-none absolute -left-20 -top-24 h-56 w-56 rounded-full bg-[radial-gradient(circle,rgba(79,184,178,0.32),transparent_66%)]" />
|
||||||
|
<div className="pointer-events-none absolute -bottom-20 -right-20 h-56 w-56 rounded-full bg-[radial-gradient(circle,rgba(47,106,74,0.18),transparent_66%)]" />
|
||||||
|
<p className="island-kicker mb-3">TanStack Start Base Template</p>
|
||||||
|
<h1 className="display-title mb-5 max-w-3xl text-4xl leading-[1.02] font-bold tracking-tight text-[var(--sea-ink)] sm:text-6xl">
|
||||||
|
Start simple, ship quickly.
|
||||||
|
</h1>
|
||||||
|
<p className="mb-8 max-w-2xl text-base text-[var(--sea-ink-soft)] sm:text-lg">
|
||||||
|
This base starter intentionally keeps things light: two routes, clean
|
||||||
|
structure, and the essentials you need to build from scratch.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{/* <Link
|
||||||
|
href="/about"
|
||||||
|
className="rounded-full border border-[rgba(50,143,151,0.3)] bg-[rgba(79,184,178,0.14)] px-5 py-2.5 text-sm font-semibold text-[var(--lagoon-deep)] no-underline transition hover:-translate-y-0.5 hover:bg-[rgba(79,184,178,0.24)]"
|
||||||
|
>
|
||||||
|
About This Starter
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="https://tanstack.com/router"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-full border border-[rgba(23,58,64,0.2)] bg-white/50 px-5 py-2.5 text-sm font-semibold text-[var(--sea-ink)] no-underline transition hover:-translate-y-0.5 hover:border-[rgba(23,58,64,0.35)]"
|
||||||
|
>
|
||||||
|
Router Guide
|
||||||
|
</Link> */}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{[
|
||||||
|
[
|
||||||
|
'Type-Safe Routing',
|
||||||
|
'Routes and links stay in sync across every page.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Server Functions',
|
||||||
|
'Call server code from your UI without creating API boilerplate.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Streaming by Default',
|
||||||
|
'Ship progressively rendered responses for faster experiences.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Tailwind Native',
|
||||||
|
'Design quickly with utility-first styling and reusable tokens.',
|
||||||
|
],
|
||||||
|
].map(([title, desc], index) => (
|
||||||
|
<article
|
||||||
|
key={title}
|
||||||
|
className="island-shell feature-card rise-in rounded-2xl p-5"
|
||||||
|
style={{ animationDelay: `${index * 90 + 80}ms` }}
|
||||||
|
>
|
||||||
|
<h2 className="mb-2 text-base font-semibold text-[var(--sea-ink)]">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="m-0 text-sm text-[var(--sea-ink-soft)]">{desc}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="island-shell mt-8 rounded-2xl p-6">
|
||||||
|
<p className="island-kicker mb-2">Quick Start</p>
|
||||||
|
<ul className="m-0 list-disc space-y-2 pl-5 text-sm text-[var(--sea-ink-soft)]">
|
||||||
|
<li>
|
||||||
|
Edit <code>src/routes/index.tsx</code> to customize the home page.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Update <code>src/components/Header.tsx</code> and{' '}
|
||||||
|
<code>src/components/Footer.tsx</code> for brand links.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Add routes in <code>src/routes</code> and tweak visual tokens in{' '}
|
||||||
|
<code>src/styles.css</code>.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
+259
@@ -0,0 +1,259 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700&family=Manrope:wght@400;500;600;700;800&display=swap");
|
||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: "Manrope", ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--sea-ink: #173a40;
|
||||||
|
--sea-ink-soft: #416166;
|
||||||
|
--lagoon: #4fb8b2;
|
||||||
|
--lagoon-deep: #328f97;
|
||||||
|
--palm: #2f6a4a;
|
||||||
|
--sand: #e7f0e8;
|
||||||
|
--foam: #f3faf5;
|
||||||
|
--surface: rgba(255, 255, 255, 0.74);
|
||||||
|
--surface-strong: rgba(255, 255, 255, 0.9);
|
||||||
|
--line: rgba(23, 58, 64, 0.14);
|
||||||
|
--inset-glint: rgba(255, 255, 255, 0.82);
|
||||||
|
--kicker: rgba(47, 106, 74, 0.9);
|
||||||
|
--bg-base: #e7f3ec;
|
||||||
|
--header-bg: rgba(251, 255, 248, 0.84);
|
||||||
|
--chip-bg: rgba(255, 255, 255, 0.8);
|
||||||
|
--chip-line: rgba(47, 106, 74, 0.18);
|
||||||
|
--link-bg-hover: rgba(255, 255, 255, 0.9);
|
||||||
|
--hero-a: rgba(79, 184, 178, 0.36);
|
||||||
|
--hero-b: rgba(47, 106, 74, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--sea-ink: #d7ece8;
|
||||||
|
--sea-ink-soft: #afcdc8;
|
||||||
|
--lagoon: #60d7cf;
|
||||||
|
--lagoon-deep: #8de5db;
|
||||||
|
--palm: #6ec89a;
|
||||||
|
--sand: #0f1a1e;
|
||||||
|
--foam: #101d22;
|
||||||
|
--surface: rgba(16, 30, 34, 0.8);
|
||||||
|
--surface-strong: rgba(15, 27, 31, 0.92);
|
||||||
|
--line: rgba(141, 229, 219, 0.18);
|
||||||
|
--inset-glint: rgba(194, 247, 238, 0.14);
|
||||||
|
--kicker: #b8efe5;
|
||||||
|
--bg-base: #0a1418;
|
||||||
|
--header-bg: rgba(10, 20, 24, 0.8);
|
||||||
|
--chip-bg: rgba(13, 28, 32, 0.9);
|
||||||
|
--chip-line: rgba(141, 229, 219, 0.24);
|
||||||
|
--link-bg-hover: rgba(24, 44, 49, 0.8);
|
||||||
|
--hero-a: rgba(96, 215, 207, 0.18);
|
||||||
|
--hero-b: rgba(110, 200, 154, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--sea-ink: #d7ece8;
|
||||||
|
--sea-ink-soft: #afcdc8;
|
||||||
|
--lagoon: #60d7cf;
|
||||||
|
--lagoon-deep: #8de5db;
|
||||||
|
--palm: #6ec89a;
|
||||||
|
--sand: #0f1a1e;
|
||||||
|
--foam: #101d22;
|
||||||
|
--surface: rgba(16, 30, 34, 0.8);
|
||||||
|
--surface-strong: rgba(15, 27, 31, 0.92);
|
||||||
|
--line: rgba(141, 229, 219, 0.18);
|
||||||
|
--inset-glint: rgba(194, 247, 238, 0.14);
|
||||||
|
--kicker: #b8efe5;
|
||||||
|
--bg-base: #0a1418;
|
||||||
|
--header-bg: rgba(10, 20, 24, 0.8);
|
||||||
|
--chip-bg: rgba(13, 28, 32, 0.9);
|
||||||
|
--chip-line: rgba(141, 229, 219, 0.24);
|
||||||
|
--link-bg-hover: rgba(24, 44, 49, 0.8);
|
||||||
|
--hero-a: rgba(96, 215, 207, 0.18);
|
||||||
|
--hero-b: rgba(110, 200, 154, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--sea-ink);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background-color: var(--bg-base);
|
||||||
|
background:
|
||||||
|
radial-gradient(1100px 620px at -8% -10%, var(--hero-a), transparent 58%),
|
||||||
|
radial-gradient(1050px 620px at 112% -12%, var(--hero-b), transparent 62%),
|
||||||
|
radial-gradient(720px 380px at 50% 115%, rgba(79, 184, 178, 0.1), transparent 68%),
|
||||||
|
linear-gradient(180deg, color-mix(in oklab, var(--sand) 68%, white) 0%, var(--foam) 44%, var(--bg-base) 100%);
|
||||||
|
overflow-x: hidden;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0.28;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 20% 15%, rgba(255, 255, 255, 0.8), transparent 34%),
|
||||||
|
radial-gradient(circle at 78% 26%, rgba(79, 184, 178, 0.2), transparent 42%),
|
||||||
|
radial-gradient(circle at 42% 82%, rgba(47, 106, 74, 0.14), transparent 36%);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::after {
|
||||||
|
content: "";
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0.14;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(255, 255, 255, 0.07) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px);
|
||||||
|
background-size: 28px 28px;
|
||||||
|
mask-image: radial-gradient(circle at 50% 30%, black, transparent 78%);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--lagoon-deep);
|
||||||
|
text-decoration-color: rgba(50, 143, 151, 0.4);
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #246f76;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: 0.9em;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: color-mix(in oklab, var(--surface-strong) 82%, white 18%);
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-wrap {
|
||||||
|
width: min(1080px, calc(100% - 2rem));
|
||||||
|
margin-inline: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-title {
|
||||||
|
font-family: "Fraunces", Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-shell {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: linear-gradient(165deg, var(--surface-strong), var(--surface));
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 var(--inset-glint) inset,
|
||||||
|
0 22px 44px rgba(30, 90, 72, 0.1),
|
||||||
|
0 6px 18px rgba(23, 58, 64, 0.08);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card {
|
||||||
|
background: linear-gradient(165deg, color-mix(in oklab, var(--surface-strong) 93%, white 7%), var(--surface));
|
||||||
|
box-shadow:
|
||||||
|
0 1px 0 var(--inset-glint) inset,
|
||||||
|
0 18px 34px rgba(30, 90, 72, 0.1),
|
||||||
|
0 4px 14px rgba(23, 58, 64, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: color-mix(in oklab, var(--lagoon-deep) 35%, var(--line));
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
.island-shell,
|
||||||
|
a {
|
||||||
|
transition: background-color 180ms ease, color 180ms ease, border-color 180ms ease,
|
||||||
|
transform 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.island-kicker {
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.69rem;
|
||||||
|
color: var(--kicker);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--sea-ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: -6px;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
transform: scaleX(0);
|
||||||
|
transform-origin: left;
|
||||||
|
background: linear-gradient(90deg, var(--lagoon), #7ed3bf);
|
||||||
|
transition: transform 170ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover,
|
||||||
|
.nav-link.is-active {
|
||||||
|
color: var(--sea-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover::after,
|
||||||
|
.nav-link.is-active::after {
|
||||||
|
transform: scaleX(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.nav-link::after {
|
||||||
|
bottom: -4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
background: color-mix(in oklab, var(--header-bg) 84%, transparent 16%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rise-in {
|
||||||
|
animation: rise-in 700ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rise-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"include": ["**/*.ts", "**/*.tsx"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"module": "ESNext",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"#/*": ["./src/*"],
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["vite/client"],
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { paraglideVitePlugin } from "@inlang/paraglide-js";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { devtools } from "@tanstack/devtools-vite";
|
||||||
|
import { tanstackStart } from "@tanstack/react-start/plugin/vite";
|
||||||
|
import viteReact from "@vitejs/plugin-react";
|
||||||
|
import { nitro } from "nitro/vite";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import tsconfigPaths from "vite-tsconfig-paths";
|
||||||
|
|
||||||
|
const APP_BASE_PATH = "/app";
|
||||||
|
|
||||||
|
const config = defineConfig(({ command }) => ({
|
||||||
|
base: command === "build" ? `${APP_BASE_PATH}/` : "/",
|
||||||
|
plugins: [
|
||||||
|
nitro(),
|
||||||
|
devtools(),
|
||||||
|
paraglideVitePlugin({
|
||||||
|
project: "./project.inlang",
|
||||||
|
outdir: "./src/paraglide",
|
||||||
|
strategy: ["url", "baseLocale"],
|
||||||
|
}),
|
||||||
|
tsconfigPaths({ projects: ["./tsconfig.json"] }),
|
||||||
|
tailwindcss(),
|
||||||
|
tanstackStart({
|
||||||
|
spa: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
viteReact({
|
||||||
|
babel: {
|
||||||
|
plugins: ["babel-plugin-react-compiler"],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default config;
|
||||||
Reference in New Issue
Block a user