This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
data
|
||||||
|
coverage
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
npm-debug.log*
|
||||||
|
*.tsbuildinfo
|
||||||
|
tasks
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
name: Master Production Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy-production
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ${{ vars.CONTAINER_REGISTRY }}
|
||||||
|
WEB_IMAGE_REPO: ${{ vars.CONTAINER_REGISTRY }}/${{ github.repository_owner }}/${{ github.event.repository.name }}-web
|
||||||
|
RELEASE_ID: ${{ github.sha }}-${{ github.run_number }}
|
||||||
|
NPM_CONFIG_AUDIT: "false"
|
||||||
|
NPM_CONFIG_FUND: "false"
|
||||||
|
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
production:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Setup npm
|
||||||
|
run: npm install -g npm@11.6.4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --prefer-offline --no-audit --no-fund
|
||||||
|
|
||||||
|
- name: Quality gate
|
||||||
|
run: npm run ci:quality
|
||||||
|
|
||||||
|
- name: Setup Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Login to Gitea Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Build and push web image
|
||||||
|
id: build-web
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: deploy/docker/Dockerfile.web
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.WEB_IMAGE_REPO }}:${{ github.sha }}
|
||||||
|
labels: |
|
||||||
|
org.opencontainers.image.revision=${{ github.sha }}
|
||||||
|
org.opencontainers.image.version=${{ env.RELEASE_ID }}
|
||||||
|
|
||||||
|
- name: Production pre-deploy checks
|
||||||
|
env:
|
||||||
|
ENVIRONMENT: production
|
||||||
|
STACK_NAME: ${{ vars.PRODUCTION_STACK_NAME }}
|
||||||
|
RELEASE_ID: ${{ env.RELEASE_ID }}
|
||||||
|
COMMIT_SHA: ${{ github.sha }}
|
||||||
|
WEB_IMAGE: ${{ env.WEB_IMAGE_REPO }}@${{ steps.build-web.outputs.digest }}
|
||||||
|
PUBLIC_HOST: ${{ vars.PRODUCTION_PUBLIC_HOST }}
|
||||||
|
TRAEFIK_NETWORK: ${{ vars.PRODUCTION_TRAEFIK_NETWORK }}
|
||||||
|
SWARM_MANAGER_HOST: ${{ secrets.PRODUCTION_SWARM_MANAGER_HOST }}
|
||||||
|
SWARM_SSH_USER: ${{ secrets.PRODUCTION_SWARM_SSH_USER }}
|
||||||
|
run: node deploy/scripts/predeploy-check.mjs
|
||||||
|
|
||||||
|
- name: Deploy production
|
||||||
|
env:
|
||||||
|
ENVIRONMENT: production
|
||||||
|
STACK_NAME: ${{ vars.PRODUCTION_STACK_NAME }}
|
||||||
|
RELEASE_ID: ${{ env.RELEASE_ID }}
|
||||||
|
COMMIT_SHA: ${{ github.sha }}
|
||||||
|
WEB_IMAGE: ${{ env.WEB_IMAGE_REPO }}@${{ steps.build-web.outputs.digest }}
|
||||||
|
PUBLIC_HOST: ${{ vars.PRODUCTION_PUBLIC_HOST }}
|
||||||
|
TRAEFIK_NETWORK: ${{ vars.PRODUCTION_TRAEFIK_NETWORK }}
|
||||||
|
SWARM_MANAGER_HOST: ${{ secrets.PRODUCTION_SWARM_MANAGER_HOST }}
|
||||||
|
SWARM_SSH_USER: ${{ secrets.PRODUCTION_SWARM_SSH_USER }}
|
||||||
|
SWARM_SSH_PORT: ${{ secrets.PRODUCTION_SWARM_SSH_PORT }}
|
||||||
|
REGISTRY: ${{ env.REGISTRY }}
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
||||||
|
WEB_REPLICAS: ${{ vars.PRODUCTION_WEB_REPLICAS }}
|
||||||
|
WEB_PLACEMENT_CONSTRAINT: ${{ vars.PRODUCTION_WEB_PLACEMENT_CONSTRAINT }}
|
||||||
|
TRAEFIK_ENTRYPOINTS: ${{ vars.TRAEFIK_ENTRYPOINTS }}
|
||||||
|
TRAEFIK_TLS: ${{ vars.TRAEFIK_TLS }}
|
||||||
|
REGION: ${{ vars.REGION }}
|
||||||
|
TEAM_ID: ${{ vars.TEAM_ID }}
|
||||||
|
DEPLOY_MIN_FREE_MB: ${{ vars.DEPLOY_MIN_FREE_MB }}
|
||||||
|
DEPLOY_PRUNE_BEFORE_PULL: ${{ vars.DEPLOY_PRUNE_BEFORE_PULL }}
|
||||||
|
DEPLOY_PRUNE_UNTIL: ${{ vars.DEPLOY_PRUNE_UNTIL }}
|
||||||
|
DEPLOY_CLEANUP_FAILED_IMAGES: ${{ vars.DEPLOY_CLEANUP_FAILED_IMAGES }}
|
||||||
|
VIDEOREG_DB_MEMORY_TTL_MS: ${{ vars.VIDEOREG_DB_MEMORY_TTL_MS }}
|
||||||
|
VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS: ${{ vars.VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS }}
|
||||||
|
run: deploy/scripts/deploy-over-ssh.sh
|
||||||
|
|
||||||
|
- name: Production smoke checks
|
||||||
|
id: smoke
|
||||||
|
continue-on-error: true
|
||||||
|
env:
|
||||||
|
BASE_URL: https://${{ vars.PRODUCTION_PUBLIC_HOST }}
|
||||||
|
EXPECTED_RELEASE_ID: ${{ env.RELEASE_ID }}
|
||||||
|
SMOKE_RESULT_PATH: smoke-result.json
|
||||||
|
run: node deploy/scripts/smoke-check.mjs
|
||||||
|
|
||||||
|
- name: Roll back production after failed smoke checks
|
||||||
|
if: steps.smoke.outcome == 'failure'
|
||||||
|
env:
|
||||||
|
ENVIRONMENT: production
|
||||||
|
STACK_NAME: ${{ vars.PRODUCTION_STACK_NAME }}
|
||||||
|
SWARM_MANAGER_HOST: ${{ secrets.PRODUCTION_SWARM_MANAGER_HOST }}
|
||||||
|
SWARM_SSH_USER: ${{ secrets.PRODUCTION_SWARM_SSH_USER }}
|
||||||
|
SWARM_SSH_PORT: ${{ secrets.PRODUCTION_SWARM_SSH_PORT }}
|
||||||
|
run: deploy/scripts/rollback-over-ssh.sh
|
||||||
|
|
||||||
|
- name: Fail after production smoke rollback
|
||||||
|
if: steps.smoke.outcome == 'failure'
|
||||||
|
run: exit 1
|
||||||
|
|
||||||
|
- name: Write release manifest
|
||||||
|
env:
|
||||||
|
COMMIT_SHA: ${{ github.sha }}
|
||||||
|
RELEASE_ID: ${{ env.RELEASE_ID }}
|
||||||
|
WEB_IMAGE_DIGEST: ${{ env.WEB_IMAGE_REPO }}@${{ steps.build-web.outputs.digest }}
|
||||||
|
SMOKE_RESULT_PATH: smoke-result.json
|
||||||
|
WORKFLOW_RUN_ID: ${{ github.run_id }}
|
||||||
|
run: node deploy/scripts/write-release-manifest.mjs > release-manifest.json
|
||||||
|
|
||||||
|
- name: Upload release manifest
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: release-manifest-${{ env.RELEASE_ID }}
|
||||||
|
path: release-manifest.json
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
name: PR Quality Gate
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: pr-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
NPM_CONFIG_AUDIT: "false"
|
||||||
|
NPM_CONFIG_FUND: "false"
|
||||||
|
NPM_CONFIG_UPDATE_NOTIFIER: "false"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Setup npm
|
||||||
|
run: npm install -g npm@11.6.4
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci --prefer-offline --no-audit --no-fund
|
||||||
|
|
||||||
|
- name: Quality gate
|
||||||
|
run: npm run ci:quality
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
.next/
|
||||||
|
node_modules/
|
||||||
|
data/
|
||||||
|
coverage/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
npm-debug.log*
|
||||||
|
*.tsbuildinfo
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
## videoreg.ru
|
||||||
|
|
||||||
|
Small Next.js video registry using TypeScript, App Router, FSD-oriented folders, and a primitive file-backed JSON database.
|
||||||
|
|
||||||
|
### Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The app runs on [http://localhost:3000](http://localhost:3000).
|
||||||
|
|
||||||
|
### Data storage
|
||||||
|
|
||||||
|
By default records are stored in `data/videoreg.json`. Override the path with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VIDEOREG_DB_FILE=/absolute/path/videoreg.json npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Reads are intentionally cached aggressively:
|
||||||
|
|
||||||
|
- process memory cache for the primitive JSON database;
|
||||||
|
- Next.js cache tag for dashboard/API reads;
|
||||||
|
- long HTTP cache headers on the read API.
|
||||||
|
|
||||||
|
Writes call `revalidateTag` and update the process cache.
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
Gitea Actions runs:
|
||||||
|
|
||||||
|
- `.gitea/workflows/pr.yml`: quality gate for pull requests into `master`;
|
||||||
|
- `.gitea/workflows/master.yml`: quality gate, image build, push, and direct Swarm deploy from `master`.
|
||||||
|
|
||||||
|
The Swarm stack defaults to one replica on a manager node because the primitive database is a mounted JSON file. Use a real database before raising replicas above one or spreading the service across nodes.
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY .npmrc package.json package-lock.json ./
|
||||||
|
RUN npm install -g npm@11.6.4 \
|
||||||
|
&& npm ci --prefer-offline --no-audit --no-fund
|
||||||
|
|
||||||
|
FROM deps AS build
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
COPY next-env.d.ts next.config.ts tsconfig.json vitest.config.ts ./
|
||||||
|
COPY src src
|
||||||
|
COPY public public
|
||||||
|
RUN npm run ci:quality
|
||||||
|
|
||||||
|
FROM node:22-alpine AS runtime
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
APP_ENV=production \
|
||||||
|
NEXT_TELEMETRY_DISABLED=1 \
|
||||||
|
VIDEOREG_DB_FILE=/data/videoreg.json \
|
||||||
|
PORT=3000
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN addgroup -S nodeapp && adduser -S nodeapp -G nodeapp && mkdir -p /data && chown nodeapp:nodeapp /data
|
||||||
|
|
||||||
|
COPY --from=build /app/.next/standalone ./
|
||||||
|
COPY --from=build /app/.next/static ./.next/static
|
||||||
|
COPY --from=build /app/public ./public
|
||||||
|
|
||||||
|
USER nodeapp
|
||||||
|
EXPOSE 3000
|
||||||
|
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=3 \
|
||||||
|
CMD wget -qO- http://127.0.0.1:3000/health/ready >/dev/null || exit 1
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
Executable
+174
@@ -0,0 +1,174 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
log() {
|
||||||
|
printf '{"event":"%s","environment":"%s","stack":"%s","releaseId":"%s","commitSha":"%s"}\n' \
|
||||||
|
"$1" "${ENVIRONMENT:-unknown}" "${STACK_NAME:-unknown}" "${RELEASE_ID:-unknown}" "${COMMIT_SHA:-unknown}"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_var() {
|
||||||
|
local name="$1"
|
||||||
|
if [ -z "${!name:-}" ]; then
|
||||||
|
printf 'Required environment variable %s is missing\n' "$name" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_var ENVIRONMENT
|
||||||
|
require_var STACK_NAME
|
||||||
|
require_var RELEASE_ID
|
||||||
|
require_var COMMIT_SHA
|
||||||
|
require_var WEB_IMAGE
|
||||||
|
require_var PUBLIC_HOST
|
||||||
|
require_var TRAEFIK_NETWORK
|
||||||
|
|
||||||
|
REMOTE_DIR="${REMOTE_DEPLOY_DIR:-/tmp/videoreg-deploy}"
|
||||||
|
LOCK_FILE="${RELEASE_LOCK_FILE:-/tmp/${STACK_NAME}-${ENVIRONMENT}.lock}"
|
||||||
|
|
||||||
|
exec 9>"$LOCK_FILE"
|
||||||
|
if ! flock -n 9; then
|
||||||
|
echo "Another deployment is already running for ${STACK_NAME}/${ENVIRONMENT}" >&2
|
||||||
|
exit 75
|
||||||
|
fi
|
||||||
|
|
||||||
|
cleanup_release_images() {
|
||||||
|
if [ "${DEPLOY_CLEANUP_FAILED_IMAGES:-true}" != "true" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Cleaning up unused release image after failed deploy." >&2
|
||||||
|
docker image rm "$WEB_IMAGE" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
if [ -n "${COMMIT_SHA:-}" ]; then
|
||||||
|
docker image prune -af --filter "label=org.opencontainers.image.revision=${COMMIT_SHA}" >&2 || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_on_exit() {
|
||||||
|
local status=$?
|
||||||
|
trap - EXIT
|
||||||
|
|
||||||
|
if [ "$status" -ne 0 ]; then
|
||||||
|
cleanup_release_images
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit "$status"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap cleanup_on_exit EXIT
|
||||||
|
|
||||||
|
docker_disk_paths() {
|
||||||
|
local docker_root
|
||||||
|
docker_root="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)"
|
||||||
|
|
||||||
|
if [ -n "$docker_root" ] && [ -d "$docker_root" ]; then
|
||||||
|
printf '%s\n' "$docker_root"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d /var/lib/containerd ]; then
|
||||||
|
printf '%s\n' /var/lib/containerd
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
prune_docker_before_pull() {
|
||||||
|
if [ "${DEPLOY_PRUNE_BEFORE_PULL:-false}" != "true" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local prune_until="${DEPLOY_PRUNE_UNTIL:-168h}"
|
||||||
|
echo "Pruning unused Docker images older than ${prune_until} before pulling release image." >&2
|
||||||
|
docker image prune -af --filter "until=${prune_until}" >&2 || true
|
||||||
|
docker builder prune -af --filter "until=${prune_until}" >&2 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
check_docker_disk_space() {
|
||||||
|
local min_mb="${DEPLOY_MIN_FREE_MB:-2048}"
|
||||||
|
if [[ ! "$min_mb" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "DEPLOY_MIN_FREE_MB must be an integer MiB value, got: ${min_mb}" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
local min_kb=$((min_mb * 1024))
|
||||||
|
local failed=0
|
||||||
|
local path available_kb available_mb
|
||||||
|
|
||||||
|
while IFS= read -r path; do
|
||||||
|
available_kb="$(df -Pk "$path" | awk 'NR == 2 { print $4 }')"
|
||||||
|
if [ -z "$available_kb" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$available_kb" -lt "$min_kb" ]; then
|
||||||
|
available_mb=$((available_kb / 1024))
|
||||||
|
echo "Docker storage path ${path} has ${available_mb} MiB free; need at least ${min_mb} MiB before pulling release image." >&2
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
done < <(docker_disk_paths | awk '!seen[$0]++')
|
||||||
|
|
||||||
|
if [ "$failed" -ne 0 ]; then
|
||||||
|
docker system df >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_service() {
|
||||||
|
local service="$1"
|
||||||
|
local timeout="${DEPLOY_WAIT_TIMEOUT_SECONDS:-300}"
|
||||||
|
local started_at
|
||||||
|
started_at="$(date +%s)"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
local update_state
|
||||||
|
update_state="$(docker service inspect --format '{{if .UpdateStatus}}{{.UpdateStatus.State}}{{end}}' "$service" 2>/dev/null || true)"
|
||||||
|
if [ "$update_state" = "rollback_started" ] || [ "$update_state" = "rollback_completed" ] || [ "$update_state" = "paused" ]; then
|
||||||
|
docker service ps --no-trunc "$service" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local desired running failed
|
||||||
|
desired="$(docker service inspect --format '{{if .Spec.Mode.Replicated}}{{.Spec.Mode.Replicated.Replicas}}{{else}}1{{end}}' "$service")"
|
||||||
|
running="$(docker service ps "$service" --filter desired-state=running --format '{{.CurrentState}}' | grep -c '^Running' || true)"
|
||||||
|
failed="$(docker service ps "$service" --no-trunc --format '{{.CurrentState}} {{.Error}}' | grep -Ec 'Rejected|Failed|No such image|unhealthy' || true)"
|
||||||
|
|
||||||
|
if [ "$failed" -gt 0 ]; then
|
||||||
|
docker service ps --no-trunc "$service" >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$running" -ge "$desired" ]; then
|
||||||
|
log "service_converged"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$(( $(date +%s) - started_at ))" -ge "$timeout" ]; then
|
||||||
|
docker service ps --no-trunc "$service" >&2 || true
|
||||||
|
echo "Timed out waiting for ${service} convergence" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
log deploy_start
|
||||||
|
|
||||||
|
if [ -n "${REGISTRY:-}" ] && [ -n "${REGISTRY_USERNAME:-}" ] && [ -n "${REGISTRY_PASSWORD:-}" ]; then
|
||||||
|
printf '%s' "$REGISTRY_PASSWORD" | docker login "$REGISTRY" --username "$REGISTRY_USERNAME" --password-stdin
|
||||||
|
fi
|
||||||
|
|
||||||
|
prune_docker_before_pull
|
||||||
|
check_docker_disk_space
|
||||||
|
|
||||||
|
docker image pull "$WEB_IMAGE"
|
||||||
|
|
||||||
|
export ENVIRONMENT STACK_NAME RELEASE_ID COMMIT_SHA WEB_IMAGE PUBLIC_HOST TRAEFIK_NETWORK
|
||||||
|
export REGION="${REGION:-}" TEAM_ID="${TEAM_ID:-}" WEB_REPLICAS="${WEB_REPLICAS:-1}"
|
||||||
|
export WEB_PLACEMENT_CONSTRAINT="${WEB_PLACEMENT_CONSTRAINT:-node.role == manager}"
|
||||||
|
export TRAEFIK_ENTRYPOINTS="${TRAEFIK_ENTRYPOINTS:-websecure}" TRAEFIK_TLS="${TRAEFIK_TLS:-true}"
|
||||||
|
export VIDEOREG_DB_MEMORY_TTL_MS="${VIDEOREG_DB_MEMORY_TTL_MS:-3600000}"
|
||||||
|
export VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS="${VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS:-3600}"
|
||||||
|
|
||||||
|
docker stack deploy --with-registry-auth --prune -c "$REMOTE_DIR/docker-stack.yml" "$STACK_NAME"
|
||||||
|
wait_service "${STACK_NAME}_web"
|
||||||
|
|
||||||
|
log deploy_converged
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
require_var() {
|
||||||
|
local name="$1"
|
||||||
|
if [ -z "${!name:-}" ]; then
|
||||||
|
printf 'Required environment variable %s is missing\n' "$name" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_var ENVIRONMENT
|
||||||
|
require_var STACK_NAME
|
||||||
|
|
||||||
|
LOCK_FILE="${RELEASE_LOCK_FILE:-/tmp/${STACK_NAME}-${ENVIRONMENT}.lock}"
|
||||||
|
exec 9>"$LOCK_FILE"
|
||||||
|
flock -n 9 || {
|
||||||
|
echo "Another deployment operation is already running for ${STACK_NAME}/${ENVIRONMENT}" >&2
|
||||||
|
exit 75
|
||||||
|
}
|
||||||
|
|
||||||
|
printf '{"event":"rollback_start","environment":"%s","stack":"%s"}\n' "$ENVIRONMENT" "$STACK_NAME"
|
||||||
|
docker service rollback "${STACK_NAME}_web" || true
|
||||||
|
printf '{"event":"rollback_requested","environment":"%s","stack":"%s"}\n' "$ENVIRONMENT" "$STACK_NAME"
|
||||||
Executable
+49
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
require_var() {
|
||||||
|
local name="$1"
|
||||||
|
if [ -z "${!name:-}" ]; then
|
||||||
|
printf 'Required environment variable %s is missing\n' "$name" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
quote() {
|
||||||
|
printf '%q' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_var SWARM_MANAGER_HOST
|
||||||
|
require_var SWARM_SSH_USER
|
||||||
|
require_var ENVIRONMENT
|
||||||
|
require_var STACK_NAME
|
||||||
|
require_var RELEASE_ID
|
||||||
|
require_var COMMIT_SHA
|
||||||
|
require_var WEB_IMAGE
|
||||||
|
require_var PUBLIC_HOST
|
||||||
|
require_var TRAEFIK_NETWORK
|
||||||
|
|
||||||
|
SSH_PORT="${SWARM_SSH_PORT:-22}"
|
||||||
|
SSH_TARGET="${SWARM_SSH_USER}@${SWARM_MANAGER_HOST}"
|
||||||
|
REMOTE_DEPLOY_DIR="${REMOTE_DEPLOY_DIR:-/tmp/videoreg-deploy}"
|
||||||
|
|
||||||
|
ssh -p "$SSH_PORT" "$SSH_TARGET" "mkdir -p $(quote "$REMOTE_DEPLOY_DIR")"
|
||||||
|
scp -P "$SSH_PORT" deploy/swarm/docker-stack.yml "$SSH_TARGET:$REMOTE_DEPLOY_DIR/docker-stack.yml"
|
||||||
|
scp -P "$SSH_PORT" deploy/remote/deploy-stack.sh "$SSH_TARGET:$REMOTE_DEPLOY_DIR/deploy-stack.sh"
|
||||||
|
|
||||||
|
remote_vars=(
|
||||||
|
ENVIRONMENT STACK_NAME RELEASE_ID COMMIT_SHA WEB_IMAGE PUBLIC_HOST TRAEFIK_NETWORK
|
||||||
|
REGION TEAM_ID WEB_REPLICAS WEB_PLACEMENT_CONSTRAINT TRAEFIK_ENTRYPOINTS TRAEFIK_TLS REGISTRY REGISTRY_USERNAME REGISTRY_PASSWORD
|
||||||
|
RELEASE_LOCK_FILE REMOTE_DEPLOY_DIR DEPLOY_MIN_FREE_MB DEPLOY_PRUNE_BEFORE_PULL DEPLOY_PRUNE_UNTIL
|
||||||
|
DEPLOY_CLEANUP_FAILED_IMAGES VIDEOREG_DB_MEMORY_TTL_MS VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS
|
||||||
|
)
|
||||||
|
|
||||||
|
remote_command=""
|
||||||
|
for name in "${remote_vars[@]}"; do
|
||||||
|
if [ -n "${!name:-}" ]; then
|
||||||
|
remote_command+="$name=$(quote "${!name}") "
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
remote_command+="bash $(quote "$REMOTE_DEPLOY_DIR/deploy-stack.sh")"
|
||||||
|
|
||||||
|
ssh -p "$SSH_PORT" "$SSH_TARGET" "$remote_command"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const required = [
|
||||||
|
'ENVIRONMENT',
|
||||||
|
'STACK_NAME',
|
||||||
|
'RELEASE_ID',
|
||||||
|
'COMMIT_SHA',
|
||||||
|
'WEB_IMAGE',
|
||||||
|
'PUBLIC_HOST',
|
||||||
|
'TRAEFIK_NETWORK',
|
||||||
|
'SWARM_MANAGER_HOST',
|
||||||
|
'SWARM_SSH_USER',
|
||||||
|
];
|
||||||
|
|
||||||
|
const missing = required.filter((name) => !process.env[name]);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.error(`Missing required deploy variables: ${missing.join(', ')}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/@sha256:[a-f0-9]{64}$/i.test(process.env.WEB_IMAGE ?? '')) {
|
||||||
|
console.error(`WEB_IMAGE must be pinned by immutable digest, got: ${process.env.WEB_IMAGE}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
event: 'predeploy_check_passed',
|
||||||
|
environment: process.env.ENVIRONMENT,
|
||||||
|
stack: process.env.STACK_NAME,
|
||||||
|
releaseId: process.env.RELEASE_ID,
|
||||||
|
commitSha: process.env.COMMIT_SHA,
|
||||||
|
}),
|
||||||
|
);
|
||||||
Executable
+34
@@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
require_var() {
|
||||||
|
local name="$1"
|
||||||
|
if [ -z "${!name:-}" ]; then
|
||||||
|
printf 'Required environment variable %s is missing\n' "$name" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
quote() {
|
||||||
|
printf '%q' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_var SWARM_MANAGER_HOST
|
||||||
|
require_var SWARM_SSH_USER
|
||||||
|
require_var ENVIRONMENT
|
||||||
|
require_var STACK_NAME
|
||||||
|
|
||||||
|
SSH_PORT="${SWARM_SSH_PORT:-22}"
|
||||||
|
SSH_TARGET="${SWARM_SSH_USER}@${SWARM_MANAGER_HOST}"
|
||||||
|
REMOTE_DEPLOY_DIR="${REMOTE_DEPLOY_DIR:-/tmp/videoreg-deploy}"
|
||||||
|
|
||||||
|
ssh -p "$SSH_PORT" "$SSH_TARGET" "mkdir -p $(quote "$REMOTE_DEPLOY_DIR")"
|
||||||
|
scp -P "$SSH_PORT" deploy/remote/rollback-stack.sh "$SSH_TARGET:$REMOTE_DEPLOY_DIR/rollback-stack.sh"
|
||||||
|
|
||||||
|
remote_command="ENVIRONMENT=$(quote "$ENVIRONMENT") STACK_NAME=$(quote "$STACK_NAME") "
|
||||||
|
if [ -n "${RELEASE_LOCK_FILE:-}" ]; then
|
||||||
|
remote_command+="RELEASE_LOCK_FILE=$(quote "$RELEASE_LOCK_FILE") "
|
||||||
|
fi
|
||||||
|
remote_command+="bash $(quote "$REMOTE_DEPLOY_DIR/rollback-stack.sh")"
|
||||||
|
|
||||||
|
ssh -p "$SSH_PORT" "$SSH_TARGET" "$remote_command"
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const baseUrl = process.env.BASE_URL?.replace(/\/$/, '');
|
||||||
|
const expectedReleaseId = process.env.EXPECTED_RELEASE_ID;
|
||||||
|
const smokeWrite = process.env.SMOKE_WRITE === 'true';
|
||||||
|
const outputPath = process.env.SMOKE_RESULT_PATH;
|
||||||
|
const timeoutMs = Number(process.env.SMOKE_TIMEOUT_MS ?? 5000);
|
||||||
|
const retryAttempts = Number(process.env.SMOKE_RETRY_ATTEMPTS ?? 12);
|
||||||
|
const retryDelayMs = Number(process.env.SMOKE_RETRY_DELAY_MS ?? 5000);
|
||||||
|
const retryableStatuses = new Set([404, 408, 425, 429, 500, 502, 503, 504]);
|
||||||
|
|
||||||
|
if (!baseUrl) {
|
||||||
|
console.error('BASE_URL is required for smoke checks');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestOnce(path, options = {}) {
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
signal: AbortSignal.timeout(timeoutMs),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = text ? JSON.parse(text) : undefined;
|
||||||
|
} catch {
|
||||||
|
body = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = new Error(`${path} returned ${response.status}: ${text}`);
|
||||||
|
error.status = response.status;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
let lastError;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= retryAttempts; attempt += 1) {
|
||||||
|
try {
|
||||||
|
return await requestOnce(path, options);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
const status = error?.status;
|
||||||
|
const retryableStatus = typeof status === 'number' && retryableStatuses.has(status);
|
||||||
|
const retryableNetworkError = error?.name === 'TimeoutError' || error?.cause?.code;
|
||||||
|
|
||||||
|
if (attempt >= retryAttempts || (!retryableStatus && !retryableNetworkError)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sleep(retryDelayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertRelease(body) {
|
||||||
|
if (!expectedReleaseId) return;
|
||||||
|
const actual = body?.metadata?.releaseId;
|
||||||
|
if (actual !== expectedReleaseId) {
|
||||||
|
throw new Error(`service reports release ${actual}, expected ${expectedReleaseId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const checks = [];
|
||||||
|
|
||||||
|
const live = await request('/health/live');
|
||||||
|
assertRelease(live);
|
||||||
|
checks.push({ name: 'web_live', status: 'ok' });
|
||||||
|
|
||||||
|
const ready = await request('/health/ready');
|
||||||
|
assertRelease(ready);
|
||||||
|
checks.push({ name: 'web_ready', status: 'ok' });
|
||||||
|
|
||||||
|
const registry = await request('/api/videos');
|
||||||
|
if (!registry.stats || !Array.isArray(registry.videos)) {
|
||||||
|
throw new Error('Video registry API returned an invalid shape');
|
||||||
|
}
|
||||||
|
checks.push({ name: 'registry_read', status: 'ok' });
|
||||||
|
|
||||||
|
if (smokeWrite) {
|
||||||
|
await request('/api/videos', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: `Smoke video ${Date.now()}`,
|
||||||
|
owner: 'Deploy smoke',
|
||||||
|
sourceUrl: 'https://example.com/smoke',
|
||||||
|
status: 'draft',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
checks.push({ name: 'registry_write', status: 'ok' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
status: 'ok',
|
||||||
|
baseUrl,
|
||||||
|
expectedReleaseId,
|
||||||
|
checks,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (outputPath) {
|
||||||
|
writeFileSync(outputPath, `${JSON.stringify(result, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify({ event: 'smoke_check_success', ...result }));
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch((error) => {
|
||||||
|
const result = {
|
||||||
|
status: 'error',
|
||||||
|
baseUrl,
|
||||||
|
expectedReleaseId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
if (outputPath) {
|
||||||
|
writeFileSync(outputPath, `${JSON.stringify(result, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
console.error(JSON.stringify({ event: 'smoke_check_failure', ...result }));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
function requireEnv(name) {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`${name} is required`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readJson(path) {
|
||||||
|
return JSON.parse(readFileSync(path, 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const smoke = readJson(requireEnv('SMOKE_RESULT_PATH'));
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
commitSha: requireEnv('COMMIT_SHA'),
|
||||||
|
releaseId: requireEnv('RELEASE_ID'),
|
||||||
|
webImageDigest: requireEnv('WEB_IMAGE_DIGEST'),
|
||||||
|
deployTimestamp: new Date().toISOString(),
|
||||||
|
smokeResult: smoke,
|
||||||
|
workflowRunId: requireEnv('WORKFLOW_RUN_ID'),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!/@sha256:[a-f0-9]{64}$/i.test(manifest.webImageDigest)) {
|
||||||
|
throw new Error('webImageDigest must be digest-pinned');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(JSON.stringify(manifest, null, 2));
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
version: "3.9"
|
||||||
|
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: ${WEB_IMAGE}
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
APP_ENV: ${ENVIRONMENT}
|
||||||
|
PORT: "3000"
|
||||||
|
VIDEOREG_DB_FILE: /data/videoreg.json
|
||||||
|
VIDEOREG_DB_MEMORY_TTL_MS: ${VIDEOREG_DB_MEMORY_TTL_MS:-3600000}
|
||||||
|
VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS: ${VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS:-3600}
|
||||||
|
COMMIT_SHA: ${COMMIT_SHA}
|
||||||
|
RELEASE_ID: ${RELEASE_ID}
|
||||||
|
REGION: ${REGION:-}
|
||||||
|
TEAM_ID: ${TEAM_ID:-}
|
||||||
|
volumes:
|
||||||
|
- videoreg_data:/data
|
||||||
|
networks:
|
||||||
|
traefik: {}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/health/ready >/dev/null || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
start_period: 20s
|
||||||
|
deploy:
|
||||||
|
replicas: ${WEB_REPLICAS:-1}
|
||||||
|
placement:
|
||||||
|
constraints:
|
||||||
|
- ${WEB_PLACEMENT_CONSTRAINT:-node.role == manager}
|
||||||
|
update_config:
|
||||||
|
order: start-first
|
||||||
|
parallelism: 1
|
||||||
|
failure_action: rollback
|
||||||
|
monitor: 45s
|
||||||
|
rollback_config:
|
||||||
|
order: start-first
|
||||||
|
parallelism: 1
|
||||||
|
monitor: 45s
|
||||||
|
restart_policy:
|
||||||
|
condition: on-failure
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=${TRAEFIK_NETWORK}"
|
||||||
|
- "traefik.http.routers.${STACK_NAME}-web.rule=Host(`${PUBLIC_HOST}`) && PathPrefix(`/`)"
|
||||||
|
- "traefik.http.routers.${STACK_NAME}-web.entrypoints=${TRAEFIK_ENTRYPOINTS:-websecure}"
|
||||||
|
- "traefik.http.routers.${STACK_NAME}-web.tls=${TRAEFIK_TLS:-true}"
|
||||||
|
- "traefik.http.services.${STACK_NAME}-web.loadbalancer.server.port=3000"
|
||||||
|
stop_grace_period: 30s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
videoreg_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik:
|
||||||
|
external: true
|
||||||
|
name: ${TRAEFIK_NETWORK}
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { NextConfig } from 'next';
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
turbopack: {
|
||||||
|
root: process.cwd(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+2587
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "videoreg",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"test": "vitest run",
|
||||||
|
"ci:quality": "npm run typecheck && npm test && npm run build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "16.2.4",
|
||||||
|
"react": "19.2.5",
|
||||||
|
"react-dom": "19.2.5"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@next/swc-darwin-arm64": "16.2.4",
|
||||||
|
"@next/swc-darwin-x64": "16.2.4",
|
||||||
|
"@next/swc-linux-arm64-gnu": "16.2.4",
|
||||||
|
"@next/swc-linux-arm64-musl": "16.2.4",
|
||||||
|
"@next/swc-linux-x64-gnu": "16.2.4",
|
||||||
|
"@next/swc-linux-x64-musl": "16.2.4",
|
||||||
|
"@next/swc-win32-arm64-msvc": "16.2.4",
|
||||||
|
"@next/swc-win32-x64-msvc": "16.2.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@emnapi/core": "1.10.0",
|
||||||
|
"@emnapi/runtime": "1.10.0",
|
||||||
|
"@tailwindcss/postcss": "4.2.4",
|
||||||
|
"@types/node": "24.10.1",
|
||||||
|
"@types/react": "19.2.14",
|
||||||
|
"@types/react-dom": "19.2.3",
|
||||||
|
"tailwindcss": "4.2.4",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"vitest": "4.1.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { revalidatePath, revalidateTag } from 'next/cache';
|
||||||
|
import { getCachedVideoDashboard, VIDEO_COLLECTION_TAG } from '@/entities/video/api/videoCache';
|
||||||
|
import { addVideo } from '@/entities/video/api/videoRepository';
|
||||||
|
import { normalizeVideoDraft } from '@/entities/video/model/video';
|
||||||
|
|
||||||
|
export const revalidate = 3600;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const dashboard = await getCachedVideoDashboard();
|
||||||
|
|
||||||
|
return NextResponse.json(dashboard, {
|
||||||
|
headers: {
|
||||||
|
'cache-control': 'public, max-age=60, s-maxage=3600, stale-while-revalidate=86400',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const body = (await request.json().catch(() => undefined)) as
|
||||||
|
| { title?: string; owner?: string; sourceUrl?: string; status?: string }
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
const video = await addVideo(normalizeVideoDraft(body ?? {}));
|
||||||
|
revalidateTag(VIDEO_COLLECTION_TAG, { expire: 0 });
|
||||||
|
revalidatePath('/');
|
||||||
|
|
||||||
|
return NextResponse.json({ video }, { status: 201 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--bg: #f6f6f3;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--panel-muted: #efede7;
|
||||||
|
--text: #1f2528;
|
||||||
|
--muted: #667075;
|
||||||
|
--line: #d8d5cc;
|
||||||
|
--accent: #156f5b;
|
||||||
|
--accent-strong: #0c4f41;
|
||||||
|
--warning: #9a5c12;
|
||||||
|
--danger: #a33a3a;
|
||||||
|
--info: #345f9f;
|
||||||
|
--shadow: 0 10px 24px rgb(31 37 40 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family:
|
||||||
|
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-shell {
|
||||||
|
width: min(1180px, calc(100% - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px 0 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
p {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 2rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
align-items: start;
|
||||||
|
padding-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
min-height: 42px;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
outline: 3px solid rgb(21 111 91 / 16%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button {
|
||||||
|
min-height: 42px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-button:hover {
|
||||||
|
background: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(110px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
min-height: 76px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel-muted);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric span {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 7px;
|
||||||
|
font-size: 1.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 14px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
min-height: 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--panel-muted);
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 4px 9px;
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.status-draft {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.status-published {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill.status-archived {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, auto);
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-item {
|
||||||
|
min-height: 42px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
padding: 7px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-item span {
|
||||||
|
display: block;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-item strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
border: 1px dashed var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 22px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.topbar,
|
||||||
|
.workbench {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-panel {
|
||||||
|
justify-content: stretch;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.page-shell {
|
||||||
|
width: min(100% - 20px, 1180px);
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-panel,
|
||||||
|
.stats-grid,
|
||||||
|
.video-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return NextResponse.json({
|
||||||
|
status: 'ok',
|
||||||
|
service: 'videoreg-web',
|
||||||
|
metadata: {
|
||||||
|
commitSha: process.env.COMMIT_SHA ?? 'local',
|
||||||
|
releaseId: process.env.RELEASE_ID ?? 'local',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { listVideos } from '@/entities/video/api/videoRepository';
|
||||||
|
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
await listVideos();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
status: 'ok',
|
||||||
|
service: 'videoreg-web',
|
||||||
|
metadata: {
|
||||||
|
commitSha: process.env.COMMIT_SHA ?? 'local',
|
||||||
|
releaseId: process.env.RELEASE_ID ?? 'local',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Video Registry',
|
||||||
|
description: 'Operational video registry with primitive storage and aggressive caching.',
|
||||||
|
};
|
||||||
|
|
||||||
|
type RootLayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: RootLayoutProps) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { getCachedVideoDashboard } from '@/entities/video/api/videoCache';
|
||||||
|
import { RegisterVideoForm } from '@/features/register-video/ui/RegisterVideoForm';
|
||||||
|
import { CachePanel } from '@/widgets/cache-panel/ui/CachePanel';
|
||||||
|
import { VideoDashboard } from '@/widgets/video-dashboard/ui/VideoDashboard';
|
||||||
|
|
||||||
|
export const revalidate = 3600;
|
||||||
|
|
||||||
|
export default async function Home() {
|
||||||
|
const dashboard = await getCachedVideoDashboard();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="page-shell">
|
||||||
|
<header className="topbar">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">videoreg.ru</p>
|
||||||
|
<h1>Video Registry</h1>
|
||||||
|
</div>
|
||||||
|
<CachePanel metadata={dashboard.cache} />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="workbench">
|
||||||
|
<RegisterVideoForm />
|
||||||
|
<VideoDashboard dashboard={dashboard} />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import 'server-only';
|
||||||
|
|
||||||
|
import { unstable_cache } from 'next/cache';
|
||||||
|
import { DB_MEMORY_TTL_MS, NEXT_CACHE_REVALIDATE_SECONDS } from '@/shared/config/env';
|
||||||
|
import { calculateVideoStats } from '@/entities/video/model/video';
|
||||||
|
import { listVideos } from './videoRepository';
|
||||||
|
|
||||||
|
export const VIDEO_COLLECTION_TAG = 'video-collection';
|
||||||
|
|
||||||
|
export const getCachedVideoDashboard = unstable_cache(
|
||||||
|
async () => {
|
||||||
|
const videos = await listVideos();
|
||||||
|
|
||||||
|
return {
|
||||||
|
videos,
|
||||||
|
stats: calculateVideoStats(videos),
|
||||||
|
cache: {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
nextRevalidateSeconds: NEXT_CACHE_REVALIDATE_SECONDS,
|
||||||
|
memoryTtlMs: DB_MEMORY_TTL_MS,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
['video-dashboard-v1'],
|
||||||
|
{
|
||||||
|
revalidate: NEXT_CACHE_REVALIDATE_SECONDS,
|
||||||
|
tags: [VIDEO_COLLECTION_TAG],
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import 'server-only';
|
||||||
|
|
||||||
|
import { createVideo } from '@/entities/video/model/video';
|
||||||
|
import type { VideoDraft } from '@/entities/video/model/types';
|
||||||
|
import { readPrimitiveDb, writePrimitiveDb } from '@/shared/db/primitiveJsonDb';
|
||||||
|
|
||||||
|
export async function listVideos() {
|
||||||
|
const db = await readPrimitiveDb();
|
||||||
|
return [...db.videos].sort((left, right) => right.createdAt.localeCompare(left.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addVideo(draft: VideoDraft) {
|
||||||
|
const video = createVideo(draft);
|
||||||
|
|
||||||
|
await writePrimitiveDb((current) => ({
|
||||||
|
...current,
|
||||||
|
videos: [video, ...current.videos],
|
||||||
|
}));
|
||||||
|
|
||||||
|
return video;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
export const videoStatuses = ['draft', 'published', 'archived'] as const;
|
||||||
|
|
||||||
|
export type VideoStatus = (typeof videoStatuses)[number];
|
||||||
|
|
||||||
|
export type Video = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
owner: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
status: VideoStatus;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VideoDraft = {
|
||||||
|
title: string;
|
||||||
|
owner: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
status: VideoStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VideoDashboardStats = {
|
||||||
|
total: number;
|
||||||
|
draft: number;
|
||||||
|
published: number;
|
||||||
|
archived: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type VideoDashboardData = {
|
||||||
|
videos: Video[];
|
||||||
|
stats: VideoDashboardStats;
|
||||||
|
cache: {
|
||||||
|
generatedAt: string;
|
||||||
|
nextRevalidateSeconds: number;
|
||||||
|
memoryTtlMs: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { calculateVideoStats, normalizeVideoDraft } from './video';
|
||||||
|
import type { Video } from './types';
|
||||||
|
|
||||||
|
describe('video model', () => {
|
||||||
|
it('normalizes sparse form input', () => {
|
||||||
|
expect(
|
||||||
|
normalizeVideoDraft({
|
||||||
|
title: ' Demo reel ',
|
||||||
|
owner: '',
|
||||||
|
sourceUrl: 'not a url',
|
||||||
|
status: 'unexpected',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
title: 'Demo reel',
|
||||||
|
owner: 'Unassigned',
|
||||||
|
sourceUrl: 'https://example.com/video',
|
||||||
|
status: 'draft',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts videos by status', () => {
|
||||||
|
const base = {
|
||||||
|
id: 'id',
|
||||||
|
title: 'Title',
|
||||||
|
owner: 'Ops',
|
||||||
|
sourceUrl: 'https://example.com/video',
|
||||||
|
createdAt: '2026-05-16T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-05-16T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
const videos: Video[] = [
|
||||||
|
{ ...base, id: 'draft', status: 'draft' },
|
||||||
|
{ ...base, id: 'published', status: 'published' },
|
||||||
|
{ ...base, id: 'archived', status: 'archived' },
|
||||||
|
{ ...base, id: 'published-2', status: 'published' },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(calculateVideoStats(videos)).toEqual({
|
||||||
|
total: 4,
|
||||||
|
draft: 1,
|
||||||
|
published: 2,
|
||||||
|
archived: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { videoStatuses, type Video, type VideoDashboardStats, type VideoDraft, type VideoStatus } from './types';
|
||||||
|
|
||||||
|
const fallbackUrl = 'https://example.com/video';
|
||||||
|
|
||||||
|
function normalizeText(value: string, fallback: string) {
|
||||||
|
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||||
|
return normalized.length > 0 ? normalized : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUrl(value: string) {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (normalized.length === 0) return fallbackUrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(normalized).toString();
|
||||||
|
} catch {
|
||||||
|
return fallbackUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isVideoStatus(value: string): value is VideoStatus {
|
||||||
|
return videoStatuses.includes(value as VideoStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeVideoDraft(input: {
|
||||||
|
title?: string;
|
||||||
|
owner?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
|
status?: string;
|
||||||
|
}): VideoDraft {
|
||||||
|
return {
|
||||||
|
title: normalizeText(input.title ?? '', 'Untitled video'),
|
||||||
|
owner: normalizeText(input.owner ?? '', 'Unassigned'),
|
||||||
|
sourceUrl: normalizeUrl(input.sourceUrl ?? ''),
|
||||||
|
status: input.status && isVideoStatus(input.status) ? input.status : 'draft',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVideoId(now = new Date()) {
|
||||||
|
const suffix = Math.random().toString(36).slice(2, 8);
|
||||||
|
return `video_${now.getTime().toString(36)}_${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createVideo(draft: VideoDraft, now = new Date()): Video {
|
||||||
|
const timestamp = now.toISOString();
|
||||||
|
return {
|
||||||
|
...draft,
|
||||||
|
id: createVideoId(now),
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateVideoStats(videos: Video[]): VideoDashboardStats {
|
||||||
|
return videos.reduce<VideoDashboardStats>(
|
||||||
|
(stats, video) => ({
|
||||||
|
...stats,
|
||||||
|
total: stats.total + 1,
|
||||||
|
[video.status]: stats[video.status] + 1,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
total: 0,
|
||||||
|
draft: 0,
|
||||||
|
published: 0,
|
||||||
|
archived: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { revalidatePath, updateTag } from 'next/cache';
|
||||||
|
import { VIDEO_COLLECTION_TAG } from '@/entities/video/api/videoCache';
|
||||||
|
import { addVideo } from '@/entities/video/api/videoRepository';
|
||||||
|
import { normalizeVideoDraft } from '@/entities/video/model/video';
|
||||||
|
|
||||||
|
export async function registerVideoAction(formData: FormData) {
|
||||||
|
const draft = normalizeVideoDraft({
|
||||||
|
title: String(formData.get('title') ?? ''),
|
||||||
|
owner: String(formData.get('owner') ?? ''),
|
||||||
|
sourceUrl: String(formData.get('sourceUrl') ?? ''),
|
||||||
|
status: String(formData.get('status') ?? ''),
|
||||||
|
});
|
||||||
|
|
||||||
|
await addVideo(draft);
|
||||||
|
|
||||||
|
updateTag(VIDEO_COLLECTION_TAG);
|
||||||
|
revalidatePath('/');
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { registerVideoAction } from '../model/actions';
|
||||||
|
|
||||||
|
export function RegisterVideoForm() {
|
||||||
|
return (
|
||||||
|
<section className="panel" aria-labelledby="register-video-title">
|
||||||
|
<div className="panel-header">
|
||||||
|
<h2 id="register-video-title">Register video</h2>
|
||||||
|
</div>
|
||||||
|
<form className="panel-body form-grid" action={registerVideoAction}>
|
||||||
|
<label className="field">
|
||||||
|
<span>Title</span>
|
||||||
|
<input name="title" placeholder="Q2 product demo" required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Owner</span>
|
||||||
|
<input name="owner" placeholder="Media ops" required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Source URL</span>
|
||||||
|
<input name="sourceUrl" type="url" placeholder="https://example.com/video" required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Status</span>
|
||||||
|
<select name="status" defaultValue="draft">
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="published">Published</option>
|
||||||
|
<option value="archived">Archived</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button className="submit-button" type="submit">
|
||||||
|
Add video
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export const APP_NAME = 'videoreg';
|
||||||
|
export const DB_MEMORY_TTL_MS = Number(process.env.VIDEOREG_DB_MEMORY_TTL_MS ?? 60 * 60 * 1000);
|
||||||
|
export const NEXT_CACHE_REVALIDATE_SECONDS = Number(
|
||||||
|
process.env.VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS ?? 60 * 60,
|
||||||
|
);
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import 'server-only';
|
||||||
|
|
||||||
|
import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { DB_MEMORY_TTL_MS } from '@/shared/config/env';
|
||||||
|
import type { Video } from '@/entities/video/model/types';
|
||||||
|
|
||||||
|
type PrimitiveDbSchema = {
|
||||||
|
videos: Video[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type MemorySnapshot = {
|
||||||
|
loadedAt: number;
|
||||||
|
fileMtimeMs: number;
|
||||||
|
data: PrimitiveDbSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultSeed: PrimitiveDbSchema = {
|
||||||
|
videos: [
|
||||||
|
{
|
||||||
|
id: 'video_seed_launch',
|
||||||
|
title: 'Launch recording',
|
||||||
|
owner: 'Ops',
|
||||||
|
sourceUrl: 'https://example.com/launch-recording',
|
||||||
|
status: 'published',
|
||||||
|
createdAt: '2026-05-16T09:00:00.000Z',
|
||||||
|
updatedAt: '2026-05-16T09:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'video_seed_cut',
|
||||||
|
title: 'Editing backlog',
|
||||||
|
owner: 'Media',
|
||||||
|
sourceUrl: 'https://example.com/editing-backlog',
|
||||||
|
status: 'draft',
|
||||||
|
createdAt: '2026-05-16T10:00:00.000Z',
|
||||||
|
updatedAt: '2026-05-16T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
let memorySnapshot: MemorySnapshot | undefined;
|
||||||
|
let writeQueue = Promise.resolve();
|
||||||
|
|
||||||
|
function getDbFilePath() {
|
||||||
|
if (process.env.VIDEOREG_DB_FILE) {
|
||||||
|
return process.env.VIDEOREG_DB_FILE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(/* turbopackIgnore: true */ process.cwd(), 'data', 'videoreg.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureDbFile(filePath: string) {
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stat(filePath);
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
await writeFile(filePath, `${JSON.stringify(defaultSeed, null, 2)}\n`, 'utf8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSchema(raw: string): PrimitiveDbSchema {
|
||||||
|
const parsed = JSON.parse(raw) as Partial<PrimitiveDbSchema>;
|
||||||
|
return {
|
||||||
|
videos: Array.isArray(parsed.videos) ? parsed.videos : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFromDisk(filePath: string): Promise<MemorySnapshot> {
|
||||||
|
await ensureDbFile(filePath);
|
||||||
|
const [fileStat, raw] = await Promise.all([stat(filePath), readFile(filePath, 'utf8')]);
|
||||||
|
const snapshot = {
|
||||||
|
loadedAt: Date.now(),
|
||||||
|
fileMtimeMs: fileStat.mtimeMs,
|
||||||
|
data: parseSchema(raw),
|
||||||
|
};
|
||||||
|
memorySnapshot = snapshot;
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readPrimitiveDb() {
|
||||||
|
const filePath = getDbFilePath();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (memorySnapshot && now - memorySnapshot.loadedAt < DB_MEMORY_TTL_MS) {
|
||||||
|
return memorySnapshot.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileStat = await stat(filePath).catch((error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code === 'ENOENT') return undefined;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (memorySnapshot && fileStat && fileStat.mtimeMs === memorySnapshot.fileMtimeMs) {
|
||||||
|
memorySnapshot.loadedAt = now;
|
||||||
|
return memorySnapshot.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await loadFromDisk(filePath)).data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writePrimitiveDb(
|
||||||
|
updater: (current: PrimitiveDbSchema) => PrimitiveDbSchema | Promise<PrimitiveDbSchema>,
|
||||||
|
) {
|
||||||
|
const operation = writeQueue.then(async () => {
|
||||||
|
const filePath = getDbFilePath();
|
||||||
|
const current = await readPrimitiveDb();
|
||||||
|
const next = await updater(structuredClone(current));
|
||||||
|
const temporaryPath = `${filePath}.${process.pid}.tmp`;
|
||||||
|
|
||||||
|
await mkdir(path.dirname(filePath), { recursive: true });
|
||||||
|
await writeFile(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
||||||
|
await rename(temporaryPath, filePath);
|
||||||
|
|
||||||
|
const fileStat = await stat(filePath);
|
||||||
|
memorySnapshot = {
|
||||||
|
loadedAt: Date.now(),
|
||||||
|
fileMtimeMs: fileStat.mtimeMs,
|
||||||
|
data: next,
|
||||||
|
};
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
writeQueue = operation.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
return operation;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export function formatDateTime(value: string) {
|
||||||
|
return new Intl.DateTimeFormat('en', {
|
||||||
|
dateStyle: 'medium',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(new Date(value));
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { VideoDashboardData } from '@/entities/video/model/types';
|
||||||
|
import { formatDateTime } from '@/shared/lib/date';
|
||||||
|
|
||||||
|
type CachePanelProps = {
|
||||||
|
metadata: VideoDashboardData['cache'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CachePanel({ metadata }: CachePanelProps) {
|
||||||
|
return (
|
||||||
|
<aside className="cache-panel" aria-label="Cache status">
|
||||||
|
<div className="cache-item">
|
||||||
|
<span>Next cache</span>
|
||||||
|
<strong>{metadata.nextRevalidateSeconds}s</strong>
|
||||||
|
</div>
|
||||||
|
<div className="cache-item">
|
||||||
|
<span>Memory TTL</span>
|
||||||
|
<strong>{Math.round(metadata.memoryTtlMs / 1000)}s</strong>
|
||||||
|
</div>
|
||||||
|
<div className="cache-item">
|
||||||
|
<span>Generated</span>
|
||||||
|
<strong>{formatDateTime(metadata.generatedAt)}</strong>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { VideoDashboardData } from '@/entities/video/model/types';
|
||||||
|
import { formatDateTime } from '@/shared/lib/date';
|
||||||
|
|
||||||
|
type VideoDashboardProps = {
|
||||||
|
dashboard: VideoDashboardData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VideoDashboard({ dashboard }: VideoDashboardProps) {
|
||||||
|
return (
|
||||||
|
<section className="panel" aria-labelledby="video-dashboard-title">
|
||||||
|
<div className="panel-header">
|
||||||
|
<div>
|
||||||
|
<h2 id="video-dashboard-title">Registry</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="panel-body">
|
||||||
|
<div className="stats-grid">
|
||||||
|
<Metric label="Total" value={dashboard.stats.total} />
|
||||||
|
<Metric label="Draft" value={dashboard.stats.draft} />
|
||||||
|
<Metric label="Published" value={dashboard.stats.published} />
|
||||||
|
<Metric label="Archived" value={dashboard.stats.archived} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="video-list">
|
||||||
|
{dashboard.videos.length > 0 ? (
|
||||||
|
dashboard.videos.map((video) => (
|
||||||
|
<article className="video-card" key={video.id}>
|
||||||
|
<div>
|
||||||
|
<h3>{video.title}</h3>
|
||||||
|
<div className="video-meta">
|
||||||
|
<span className={`pill status-${video.status}`}>{video.status}</span>
|
||||||
|
<span className="pill">{video.owner}</span>
|
||||||
|
<span className="pill">{formatDateTime(video.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a className="pill" href={video.sourceUrl} rel="noreferrer" target="_blank">
|
||||||
|
Source
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="empty-state">No videos registered</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Metric({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="metric">
|
||||||
|
<span>{label}</span>
|
||||||
|
<strong>{value}</strong>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,696 @@
|
|||||||
|
# Design System Task for Car Dashcam Website
|
||||||
|
|
||||||
|
For: Designer + Frontend Design System Implementation Engineer
|
||||||
|
Stack: React + Next.js + Tailwind CSS
|
||||||
|
Architecture: Atomic Design + Design Tokens + Component-Driven Development
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Product Context
|
||||||
|
|
||||||
|
The website is focused on car video registrators (dashcams), accessories, comparisons, reviews, installation guides, and purchase flows.
|
||||||
|
|
||||||
|
The product positioning is:
|
||||||
|
|
||||||
|
* technologically advanced
|
||||||
|
* reliable in emergencies
|
||||||
|
* automotive premium feel
|
||||||
|
* minimal cognitive overload
|
||||||
|
* optimized for mobile-first browsing
|
||||||
|
* fast perceived loading
|
||||||
|
* visually trustworthy for expensive electronics
|
||||||
|
|
||||||
|
The visual language should combine:
|
||||||
|
|
||||||
|
* modern automotive UI
|
||||||
|
* Japanese/Korean automotive electronics aesthetics
|
||||||
|
* dashboard HUD inspiration
|
||||||
|
* premium marketplace clarity
|
||||||
|
* clean editorial layouts
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
|
||||||
|
* aggressive gaming aesthetics
|
||||||
|
* neon cyberpunk overload
|
||||||
|
* cheap AliExpress visual style
|
||||||
|
* overly corporate SaaS look
|
||||||
|
* generic ecommerce templates
|
||||||
|
|
||||||
|
The design should feel somewhere between:
|
||||||
|
|
||||||
|
* Tesla interface
|
||||||
|
* Blackmagic Design product pages
|
||||||
|
* Sony camera product pages
|
||||||
|
* premium automotive accessories catalog
|
||||||
|
* modern editorial tech magazine
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Resolved Direction
|
||||||
|
|
||||||
|
## Product Strategy
|
||||||
|
|
||||||
|
Phase 1 should optimize for editorial trust and comparison-first decision making, not direct ecommerce conversion.
|
||||||
|
|
||||||
|
The first proof artifact is a visual direction brief for a “Best Dashcams 2026” comparison page. Code implementation comes later, after the visual language is approved.
|
||||||
|
|
||||||
|
The page should use:
|
||||||
|
|
||||||
|
* one overall winner
|
||||||
|
* use-case winners for different buyer needs
|
||||||
|
* transparent scoring
|
||||||
|
* editorial override notes when real-world testing contradicts a raw score
|
||||||
|
|
||||||
|
Default score dimensions:
|
||||||
|
|
||||||
|
* video
|
||||||
|
* night
|
||||||
|
* parking
|
||||||
|
* reliability
|
||||||
|
* usability
|
||||||
|
* value
|
||||||
|
|
||||||
|
Use the same six dimensions across categories, but allow category-specific weights.
|
||||||
|
|
||||||
|
## Trust Model
|
||||||
|
|
||||||
|
Product facts and claims must expose three public trust states:
|
||||||
|
|
||||||
|
* verified
|
||||||
|
* claimed
|
||||||
|
* unknown
|
||||||
|
|
||||||
|
The public UI should stay simple, but the internal data model should preserve verification method, for example:
|
||||||
|
|
||||||
|
* measured in review
|
||||||
|
* confirmed from firmware docs
|
||||||
|
* confirmed from teardown
|
||||||
|
* confirmed by multiple sources
|
||||||
|
|
||||||
|
Trust labels should be quiet evidence chips with an icon and short label. Details such as method, source, and date should live in tooltips, detail panels, or source notes.
|
||||||
|
|
||||||
|
## Visual Anchor
|
||||||
|
|
||||||
|
The primary visual anchor is premium camera/product editorial, with automotive HUD cues used secondarily.
|
||||||
|
|
||||||
|
Real product and media photography should dominate. UI, HUD, and camera-interface details should appear only as restrained overlays or supporting metadata.
|
||||||
|
|
||||||
|
The default theme is light-first titanium brochure with dark product/media modules and full dark-mode parity.
|
||||||
|
|
||||||
|
## First Viewport
|
||||||
|
|
||||||
|
The first viewport of “Best Dashcams 2026” should prioritize trust and decision shortcuts:
|
||||||
|
|
||||||
|
* overall pick
|
||||||
|
* 3-5 use-case winners
|
||||||
|
* methodology access
|
||||||
|
* evidence labels
|
||||||
|
* concise scoring context
|
||||||
|
|
||||||
|
Avoid leading with a purely cinematic product hero if it delays comparison intent.
|
||||||
|
|
||||||
|
## Comparison UX
|
||||||
|
|
||||||
|
Mobile comparison should use stacked winner cards first, followed by a horizontal comparison table with sticky spec labels.
|
||||||
|
|
||||||
|
The comparison table should lead with a compact hardware block before outcome rows.
|
||||||
|
|
||||||
|
Core first rows:
|
||||||
|
|
||||||
|
* sensor
|
||||||
|
* resolution/FPS
|
||||||
|
* bitrate
|
||||||
|
* HDR
|
||||||
|
* lens/FOV
|
||||||
|
* storage
|
||||||
|
* capacitor/battery
|
||||||
|
* channels
|
||||||
|
|
||||||
|
Then show verdict, score dimensions, known issues, price, and seller information.
|
||||||
|
|
||||||
|
## Ecommerce Role
|
||||||
|
|
||||||
|
Purchase CTAs should be secondary and evidence-adjacent. They should sit near price and seller information, but must not dominate winner cards or turn the page into an affiliate marketplace.
|
||||||
|
|
||||||
|
## Visual Taste Guardrails
|
||||||
|
|
||||||
|
Use muted, pastel, old-money color behavior. Avoid raw bright, screaming, neon, or cheap saturated colors.
|
||||||
|
|
||||||
|
The lead accent family is desaturated petrol blue / steel blue.
|
||||||
|
|
||||||
|
Keep semantic amber and recording red, but make them muted and low-chroma.
|
||||||
|
|
||||||
|
Use tight geometry:
|
||||||
|
|
||||||
|
* 4-8px radii
|
||||||
|
* hairline borders
|
||||||
|
* mostly flat surfaces
|
||||||
|
* subtle tonal layering
|
||||||
|
* restrained shadows
|
||||||
|
|
||||||
|
Do not use:
|
||||||
|
|
||||||
|
* neon accents
|
||||||
|
* heavy glassmorphism
|
||||||
|
* big rounded SaaS cards
|
||||||
|
* stock gradients
|
||||||
|
* loud affiliate blocks
|
||||||
|
* crypto-dashboard styling
|
||||||
|
* gaming HUD overload
|
||||||
|
* generic ecommerce templates
|
||||||
|
|
||||||
|
## Typography Decision
|
||||||
|
|
||||||
|
Use an editorial sans for reading and UI, with technical monospace reserved for specs.
|
||||||
|
|
||||||
|
Name concrete font candidates in the spec:
|
||||||
|
|
||||||
|
* UI/editorial: Geist or Inter, with system sans fallback
|
||||||
|
* technical fragments: IBM Plex Mono or Iosevka, with system monospace fallback
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Primary Goals
|
||||||
|
|
||||||
|
## UX Goals
|
||||||
|
|
||||||
|
1. Make product comparison extremely easy
|
||||||
|
2. Build trust quickly
|
||||||
|
3. Reduce noise in specification-heavy interfaces
|
||||||
|
4. Support long-form technical reading
|
||||||
|
5. Make mobile usage first-class
|
||||||
|
6. Allow users to scan specs very fast
|
||||||
|
7. Visually separate verified information from marketing claims
|
||||||
|
8. Create reusable layouts for:
|
||||||
|
|
||||||
|
* reviews
|
||||||
|
* comparisons
|
||||||
|
* guides
|
||||||
|
* catalogs
|
||||||
|
* landing pages
|
||||||
|
* product detail pages
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Design Principles
|
||||||
|
|
||||||
|
## 1. Information Density Without Chaos
|
||||||
|
|
||||||
|
The system should support large amounts of technical data while remaining readable.
|
||||||
|
|
||||||
|
Dashcam users compare:
|
||||||
|
|
||||||
|
* bitrate
|
||||||
|
* sensor model
|
||||||
|
* HDR support
|
||||||
|
* parking mode
|
||||||
|
* CPL filters
|
||||||
|
* heat resistance
|
||||||
|
* night quality
|
||||||
|
* codecs
|
||||||
|
* Wi-Fi support
|
||||||
|
* cloud integrations
|
||||||
|
|
||||||
|
The UI must help scanning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Automotive Precision
|
||||||
|
|
||||||
|
Spacing, typography, borders, and interactions should feel engineered rather than decorative.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
* precise spacing scales
|
||||||
|
* subtle contrast
|
||||||
|
* restrained shadows
|
||||||
|
* clean hierarchy
|
||||||
|
* predictable interaction patterns
|
||||||
|
|
||||||
|
Avoid random rounded cards everywhere.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. “Camera Hardware” Visual Language
|
||||||
|
|
||||||
|
Visual references:
|
||||||
|
|
||||||
|
* camera UI
|
||||||
|
* automotive instrument clusters
|
||||||
|
* photography gear
|
||||||
|
* black anodized aluminum
|
||||||
|
* dark matte surfaces
|
||||||
|
* glass reflections
|
||||||
|
* OLED dashboard typography
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Technical Requirements
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
Implementation must use:
|
||||||
|
|
||||||
|
* Tailwind CSS
|
||||||
|
* CSS variables for tokens
|
||||||
|
* React compound components
|
||||||
|
* atomic architecture
|
||||||
|
* accessibility-first approach
|
||||||
|
* responsive-first approach
|
||||||
|
* light-first default with full dark-mode parity
|
||||||
|
* SSR-safe patterns for Next.js
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Atomic Architecture Structure
|
||||||
|
|
||||||
|
## Atoms
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
* Button
|
||||||
|
* Badge
|
||||||
|
* Chip
|
||||||
|
* Tag
|
||||||
|
* Typography
|
||||||
|
* PriceLabel
|
||||||
|
* RatingStars
|
||||||
|
* Divider
|
||||||
|
* Input
|
||||||
|
* Select
|
||||||
|
* Checkbox
|
||||||
|
* IconButton
|
||||||
|
* Tooltip
|
||||||
|
* StatusIndicator
|
||||||
|
* BatteryIndicator
|
||||||
|
* SensorLabel
|
||||||
|
* RecordingQualityBadge
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
* variants
|
||||||
|
* sizes
|
||||||
|
* semantic states
|
||||||
|
* keyboard accessibility
|
||||||
|
* token-driven styling only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Molecules
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
* ProductCard
|
||||||
|
* ReviewCard
|
||||||
|
* SpecRow
|
||||||
|
* ComparisonRow
|
||||||
|
* VideoPreview
|
||||||
|
* PriceBlock
|
||||||
|
* SellerBadge
|
||||||
|
* NavigationItem
|
||||||
|
* Breadcrumbs
|
||||||
|
* SearchInput
|
||||||
|
* FilterDropdown
|
||||||
|
* StickyBuyPanel
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Organisms
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
* ProductComparisonTable
|
||||||
|
* ReviewHero
|
||||||
|
* ProductGallery
|
||||||
|
* SpecificationSection
|
||||||
|
* VideoSamplesCarousel
|
||||||
|
* ReviewSummary
|
||||||
|
* InstallationGuide
|
||||||
|
* ProductGrid
|
||||||
|
* RecommendationRail
|
||||||
|
* StickyMobileActions
|
||||||
|
* ReviewTOC
|
||||||
|
* Header
|
||||||
|
* Footer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
* Product Page
|
||||||
|
* Review Article
|
||||||
|
* Comparison Page
|
||||||
|
* Category Catalog
|
||||||
|
* Brand Page
|
||||||
|
* Landing Page
|
||||||
|
* Blog Article
|
||||||
|
* Search Results
|
||||||
|
* “Best Dashcams 2026” page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Visual Direction
|
||||||
|
|
||||||
|
## Color System
|
||||||
|
|
||||||
|
### Base Palette
|
||||||
|
|
||||||
|
Primary colors should feel:
|
||||||
|
|
||||||
|
* automotive
|
||||||
|
* metallic
|
||||||
|
* technical
|
||||||
|
* trustworthy
|
||||||
|
|
||||||
|
Suggested direction:
|
||||||
|
|
||||||
|
* graphite
|
||||||
|
* gunmetal
|
||||||
|
* matte black
|
||||||
|
* titanium gray
|
||||||
|
* cold white
|
||||||
|
* deep navy accents
|
||||||
|
|
||||||
|
Accent colors:
|
||||||
|
|
||||||
|
* desaturated petrol blue / steel blue as the lead accent family
|
||||||
|
* muted low-chroma amber for warnings, heat, caution, and parking-related states
|
||||||
|
* muted low-chroma red for recording and critical states
|
||||||
|
* success green only where meaningful
|
||||||
|
|
||||||
|
Avoid rainbow interfaces and raw bright accents.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Surface System
|
||||||
|
|
||||||
|
Need layered surfaces:
|
||||||
|
|
||||||
|
| Level | Usage |
|
||||||
|
| -------------- | -------------------------- |
|
||||||
|
| Surface 0 | Light titanium app background |
|
||||||
|
| Surface 1 | Main editorial cards and comparison blocks |
|
||||||
|
| Surface 2 | Elevated product/media modules |
|
||||||
|
| Surface 3 | Interactive hover surfaces |
|
||||||
|
| Surface Accent | Dark product/media blocks and premium highlights |
|
||||||
|
|
||||||
|
Light mode is the primary default.
|
||||||
|
|
||||||
|
Light mode should feel like:
|
||||||
|
|
||||||
|
* premium automotive brochure
|
||||||
|
* not plain white SaaS
|
||||||
|
|
||||||
|
Dark mode must have full parity and should preserve the same hierarchy, contrast, and trust labeling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Typography System
|
||||||
|
|
||||||
|
Typography is extremely important.
|
||||||
|
|
||||||
|
The interface must support:
|
||||||
|
|
||||||
|
* long-form reading
|
||||||
|
* technical specs
|
||||||
|
* comparisons
|
||||||
|
* editorial content
|
||||||
|
* ecommerce CTAs
|
||||||
|
|
||||||
|
Recommended direction:
|
||||||
|
|
||||||
|
Headings:
|
||||||
|
|
||||||
|
* geometric sans
|
||||||
|
* condensed possible for large titles
|
||||||
|
|
||||||
|
Body:
|
||||||
|
|
||||||
|
* highly readable
|
||||||
|
* neutral
|
||||||
|
* strong numeric clarity
|
||||||
|
|
||||||
|
Monospace:
|
||||||
|
|
||||||
|
* used for technical specs
|
||||||
|
* bitrate
|
||||||
|
* firmware
|
||||||
|
* codecs
|
||||||
|
|
||||||
|
Potential inspirations:
|
||||||
|
|
||||||
|
* Inter
|
||||||
|
* Geist
|
||||||
|
* IBM Plex Sans
|
||||||
|
* Iosevka for technical fragments
|
||||||
|
|
||||||
|
Resolved direction:
|
||||||
|
|
||||||
|
* UI/editorial: Geist or Inter, with system sans fallback
|
||||||
|
* technical fragments: IBM Plex Mono or Iosevka, with system monospace fallback
|
||||||
|
* monospace should be reserved for specs, firmware, codecs, bitrate, and compact technical metadata
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Layout System
|
||||||
|
|
||||||
|
## Grid
|
||||||
|
|
||||||
|
Need:
|
||||||
|
|
||||||
|
* 4px spacing base
|
||||||
|
* predictable responsive grid
|
||||||
|
* container system
|
||||||
|
* content widths for:
|
||||||
|
|
||||||
|
* article reading
|
||||||
|
* comparison tables
|
||||||
|
* dense catalogs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responsive Targets
|
||||||
|
|
||||||
|
Must support:
|
||||||
|
|
||||||
|
| Device | Priority |
|
||||||
|
| ----------------- | -------- |
|
||||||
|
| Mobile portrait | Highest |
|
||||||
|
| Mobile landscape | High |
|
||||||
|
| Tablet portrait | High |
|
||||||
|
| Desktop ultrawide | Medium |
|
||||||
|
|
||||||
|
Special focus:
|
||||||
|
|
||||||
|
* sticky bottom actions on mobile
|
||||||
|
* comparison tables on narrow screens
|
||||||
|
* thumb-friendly navigation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Motion System
|
||||||
|
|
||||||
|
Motion should feel:
|
||||||
|
|
||||||
|
* mechanical
|
||||||
|
* precise
|
||||||
|
* responsive
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
|
||||||
|
* playful animations
|
||||||
|
* bouncing
|
||||||
|
* exaggerated easing
|
||||||
|
|
||||||
|
Allowed:
|
||||||
|
|
||||||
|
* subtle fades
|
||||||
|
* sliding panels
|
||||||
|
* controlled transitions
|
||||||
|
* progressive disclosure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Icons
|
||||||
|
|
||||||
|
Need unified icon language.
|
||||||
|
|
||||||
|
Possible direction:
|
||||||
|
|
||||||
|
* outline icons
|
||||||
|
* automotive dashboard inspiration
|
||||||
|
* camera hardware icons
|
||||||
|
* signal indicators
|
||||||
|
* storage/media indicators
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Content Components
|
||||||
|
|
||||||
|
Must support:
|
||||||
|
|
||||||
|
## Review Components
|
||||||
|
|
||||||
|
* pros/cons
|
||||||
|
* verdict block
|
||||||
|
* sample footage embeds
|
||||||
|
* firmware history
|
||||||
|
* heat test results
|
||||||
|
* night comparison sliders
|
||||||
|
* speed readability examples
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comparison Components
|
||||||
|
|
||||||
|
Need highly optimized comparison UX:
|
||||||
|
|
||||||
|
* sticky headers
|
||||||
|
* horizontal scroll with snap
|
||||||
|
* column pinning
|
||||||
|
* spec highlighting
|
||||||
|
* “best value” indicators
|
||||||
|
|
||||||
|
Resolved Phase 1 behavior:
|
||||||
|
|
||||||
|
* stacked winner cards first on mobile
|
||||||
|
* horizontal comparison table after cards
|
||||||
|
* sticky spec labels on narrow screens
|
||||||
|
* first visible table rows should be the compact hardware block: sensor, resolution/FPS, bitrate, HDR, lens/FOV, storage, capacitor/battery, channels
|
||||||
|
* outcome rows should follow the hardware block
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ecommerce Components
|
||||||
|
|
||||||
|
Need:
|
||||||
|
|
||||||
|
* affiliate blocks
|
||||||
|
* seller comparison
|
||||||
|
* availability indicators
|
||||||
|
* price history blocks
|
||||||
|
* CTA hierarchy
|
||||||
|
* coupon presentation
|
||||||
|
|
||||||
|
Must avoid spammy affiliate aesthetics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Accessibility Requirements
|
||||||
|
|
||||||
|
Minimum:
|
||||||
|
|
||||||
|
* WCAG AA
|
||||||
|
* keyboard navigation
|
||||||
|
* visible focus states
|
||||||
|
* semantic HTML
|
||||||
|
* proper contrast
|
||||||
|
* reduced motion support
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tailwind Requirements
|
||||||
|
|
||||||
|
## Must Produce
|
||||||
|
|
||||||
|
1. Tailwind token structure
|
||||||
|
2. Design token naming convention
|
||||||
|
3. Component API documentation
|
||||||
|
4. Variant strategy
|
||||||
|
5. Responsive rules
|
||||||
|
6. Dark/light theming system
|
||||||
|
7. Atomic folder structure
|
||||||
|
8. Figma component parity with React components
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Deliverables
|
||||||
|
|
||||||
|
## Design Deliverables
|
||||||
|
|
||||||
|
* Figma design system
|
||||||
|
* typography scale
|
||||||
|
* spacing system
|
||||||
|
* icon system
|
||||||
|
* color tokens
|
||||||
|
* component library
|
||||||
|
* responsive examples
|
||||||
|
* interaction states
|
||||||
|
* accessibility annotations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend Deliverables
|
||||||
|
|
||||||
|
Implementation in:
|
||||||
|
|
||||||
|
* React
|
||||||
|
* Next.js
|
||||||
|
* Tailwind CSS
|
||||||
|
|
||||||
|
Including:
|
||||||
|
|
||||||
|
* token-based theme
|
||||||
|
* reusable atomic components
|
||||||
|
* Storybook
|
||||||
|
* dark/light support
|
||||||
|
* mobile-first responsiveness
|
||||||
|
* typed component APIs
|
||||||
|
* zero hardcoded colors in components
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Important Constraints
|
||||||
|
|
||||||
|
The final result must NOT look like:
|
||||||
|
|
||||||
|
* generic Tailwind template
|
||||||
|
* Bootstrap ecommerce
|
||||||
|
* crypto dashboard
|
||||||
|
* gaming UI
|
||||||
|
* cheap gadget marketplace
|
||||||
|
|
||||||
|
The interface should feel:
|
||||||
|
|
||||||
|
* premium
|
||||||
|
* calm
|
||||||
|
* technical
|
||||||
|
* engineered
|
||||||
|
* trustworthy
|
||||||
|
* fast
|
||||||
|
|
||||||
|
Users should subconsciously feel:
|
||||||
|
“This is a serious automotive electronics platform.”
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Suggested Initial Priority
|
||||||
|
|
||||||
|
Phase 1:
|
||||||
|
|
||||||
|
* typography
|
||||||
|
* colors
|
||||||
|
* spacing
|
||||||
|
* visual direction brief for “Best Dashcams 2026”
|
||||||
|
* buttons
|
||||||
|
* cards
|
||||||
|
* navigation
|
||||||
|
* product cards
|
||||||
|
* comparison table
|
||||||
|
|
||||||
|
Phase 2:
|
||||||
|
|
||||||
|
* article system
|
||||||
|
* review system
|
||||||
|
* ecommerce modules
|
||||||
|
* filtering system
|
||||||
|
|
||||||
|
Phase 3:
|
||||||
|
|
||||||
|
* motion
|
||||||
|
* advanced comparison UX
|
||||||
|
* personalization
|
||||||
|
* saved comparisons
|
||||||
|
* dashboard-like features
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"es2022"
|
||||||
|
],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import path from 'node:path';
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user