mirror of
https://github.com/TheRealOwenRees/chess-scribe-website.git
synced 2026-07-23 01:46:57 +00:00
Compare commits
13 Commits
fbc704dc30
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
1245bf74c2
|
|||
|
908dd9b57b
|
|||
|
18f86125da
|
|||
|
f4ff9d8c68
|
|||
|
7994f79e02
|
|||
|
d11d360cbf
|
|||
|
573d063d55
|
|||
|
2dd006f14e
|
|||
|
072e24b227
|
|||
|
0795daba4b
|
|||
|
f139449d9a
|
|||
|
f9d02efe49
|
|||
| 2c657c7598 |
@@ -1,6 +1,8 @@
|
|||||||
VITE_API_BASE_URL=
|
VITE_API_BASE_URL=
|
||||||
VITE_MATOMO_URL=
|
VITE_MATOMO_URL=
|
||||||
VITE_MATOMO_SITE_ID=
|
VITE_MATOMO_SITE_ID=
|
||||||
|
VITE_MAINTENANCE_MODE= # nothing = false, anything else = true
|
||||||
|
VITE_MAINTENANCE_MODE_MESSAGE="ChessScribe is currently undergoing maintenance. Some features may be unavailable."
|
||||||
|
|
||||||
DISCORD_CONTACT_WEBHOOK=
|
DISCORD_CONTACT_WEBHOOK=
|
||||||
DISCORD_ERROR_LOG_WEBHOOK=
|
DISCORD_ERROR_LOG_WEBHOOK=
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
name: Production Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: ghcr.io
|
||||||
|
GH_USER: therealowenrees
|
||||||
|
IMAGE_NAME: therealowenrees/chess-scribe:latest
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: publish image
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Login to Github Container Registry
|
||||||
|
run: echo "${{ secrets.GHCR_SECRET }}" | docker login ghcr.io -u ${{ env.GH_USER }} --password-stdin
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build image and publish with Cache
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
build-args: |
|
||||||
|
VITE_API_BASE_URL=https://api.chess-scribe.org/api/v1
|
||||||
|
VITE_MATOMO_URL=https://analytics.owenrees.eu
|
||||||
|
VITE_MATOMO_SITE_ID="3"
|
||||||
|
VITE_MAINTENANCE_MODE=
|
||||||
|
VITE_MAINTENANCE_MODE_MESSAGE="ChessScribe is currently undergoing maintenance. Some features may be unavailable."
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: publish
|
||||||
|
name: deploy image
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Connect and pull image from GHCR
|
||||||
|
uses: appleboy/ssh-action@v1.2.5
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.HOST }}
|
||||||
|
username: ${{ secrets.USERNAME }}
|
||||||
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
port: ${{ secrets.PORT }}
|
||||||
|
script: |
|
||||||
|
cd /home/${{ secrets.USERNAME }}
|
||||||
|
echo "${{ secrets.GHCR_SECRET }}" | docker login ghcr.io -u ${{ env.GH_USER }} --password-stdin
|
||||||
|
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
docker compose up -d
|
||||||
@@ -11,3 +11,4 @@ dist-ssr
|
|||||||
.vinxi
|
.vinxi
|
||||||
__unconfig*
|
__unconfig*
|
||||||
todos.json
|
todos.json
|
||||||
|
data
|
||||||
|
|||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# --- Stage 1: Build Stage ---
|
||||||
|
FROM node:24-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 1. Declare the build-time arguments Vite needs
|
||||||
|
ARG VITE_API_BASE_URL
|
||||||
|
ARG VITE_MATOMO_URL
|
||||||
|
ARG VITE_MATOMO_SITE_ID
|
||||||
|
ARG VITE_MAINTENANCE_MODE
|
||||||
|
ARG VITE_MAINTENANCE_MODE_MESSAGE
|
||||||
|
|
||||||
|
# 2. Assign them to environment variables so Vite detects them
|
||||||
|
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||||
|
ENV VITE_MATOMO_URL=$VITE_MATOMO_URL
|
||||||
|
ENV VITE_MATOMO_SITE_ID=$VITE_MATOMO_SITE_ID
|
||||||
|
ENV VITE_MAINTENANCE_MODE=$VITE_MAINTENANCE_MODE
|
||||||
|
ENV VITE_MAINTENANCE_MODE_MESSAGE=$VITE_MAINTENANCE_MODE_MESSAGE
|
||||||
|
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
RUN corepack enable pnpm && pnpm i --frozen-lockfile
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm run build
|
||||||
|
|
||||||
|
# --- Stage 2: Production Stage ---
|
||||||
|
FROM node:24-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
|
||||||
|
# Nitro compiles everything needed into the .output directory
|
||||||
|
COPY --from=builder /app/.output ./.output
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Run the compiled Nitro server
|
||||||
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
@@ -9,3 +9,5 @@
|
|||||||
- checkbox and toggle disabled states
|
- checkbox and toggle disabled states
|
||||||
- react query on for study and chapter load, to allow caching
|
- react query on for study and chapter load, to allow caching
|
||||||
- https://specification.website/checklist/
|
- https://specification.website/checklist/
|
||||||
|
|
||||||
|
- react query to cache the study and chapters
|
||||||
+6
-2
@@ -13,7 +13,12 @@
|
|||||||
"**/index.html",
|
"**/index.html",
|
||||||
"**/vite.config.ts",
|
"**/vite.config.ts",
|
||||||
"!**/src/routeTree.gen.ts",
|
"!**/src/routeTree.gen.ts",
|
||||||
"!**/src/styles.css"
|
"!**/src/styles.css",
|
||||||
|
"!**/.tanstack/*",
|
||||||
|
"!**/.output/*",
|
||||||
|
"!**/.nitro/*",
|
||||||
|
"!**/node_modules/*",
|
||||||
|
"!**/styles/vendor/*"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"formatter": {
|
"formatter": {
|
||||||
@@ -33,4 +38,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
services:
|
||||||
|
chess-scribe-frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=3000
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
restart: unless-stopped
|
||||||
+3
-6
@@ -9,8 +9,11 @@
|
|||||||
"dev": "vite dev --port 3000 --host",
|
"dev": "vite dev --port 3000 --host",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"start": "node .output/server/index.mjs",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"lint:fix": "biome lint --write",
|
||||||
"format": "biome format",
|
"format": "biome format",
|
||||||
|
"format:fix": "biome format --write",
|
||||||
"lint": "biome lint",
|
"lint": "biome lint",
|
||||||
"check": "biome check"
|
"check": "biome check"
|
||||||
},
|
},
|
||||||
@@ -48,11 +51,5 @@
|
|||||||
"typescript": "^6.0.2",
|
"typescript": "^6.0.2",
|
||||||
"vite": "^8.0.0",
|
"vite": "^8.0.0",
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.5"
|
||||||
},
|
|
||||||
"pnpm": {
|
|
||||||
"onlyBuiltDependencies": [
|
|
||||||
"esbuild",
|
|
||||||
"lightningcss"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+13
-5
@@ -315,6 +315,9 @@ packages:
|
|||||||
'@emnapi/runtime@1.10.0':
|
'@emnapi/runtime@1.10.0':
|
||||||
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
|
resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.11.1':
|
||||||
|
resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==}
|
||||||
|
|
||||||
'@emnapi/wasi-threads@1.2.1':
|
'@emnapi/wasi-threads@1.2.1':
|
||||||
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
|
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
|
||||||
|
|
||||||
@@ -1903,8 +1906,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
semver@7.8.1:
|
semver@7.8.5:
|
||||||
resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==}
|
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
@@ -2489,6 +2492,11 @@ snapshots:
|
|||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.11.1':
|
||||||
|
dependencies:
|
||||||
|
tslib: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@emnapi/wasi-threads@1.2.1':
|
'@emnapi/wasi-threads@1.2.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
tslib: 2.8.1
|
||||||
@@ -2583,7 +2591,7 @@ snapshots:
|
|||||||
|
|
||||||
'@img/sharp-wasm32@0.34.5':
|
'@img/sharp-wasm32@0.34.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/runtime': 1.10.0
|
'@emnapi/runtime': 1.11.1
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@img/sharp-win32-arm64@0.34.5':
|
'@img/sharp-win32-arm64@0.34.5':
|
||||||
@@ -3912,7 +3920,7 @@ snapshots:
|
|||||||
|
|
||||||
semver@6.3.1: {}
|
semver@6.3.1: {}
|
||||||
|
|
||||||
semver@7.8.1:
|
semver@7.8.5:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
seroval-plugins@1.5.4(seroval@1.5.4):
|
seroval-plugins@1.5.4(seroval@1.5.4):
|
||||||
@@ -3925,7 +3933,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@img/colour': 1.1.0
|
'@img/colour': 1.1.0
|
||||||
detect-libc: 2.1.2
|
detect-libc: 2.1.2
|
||||||
semver: 7.8.1
|
semver: 7.8.5
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@img/sharp-darwin-arm64': 0.34.5
|
'@img/sharp-darwin-arm64': 0.34.5
|
||||||
'@img/sharp-darwin-x64': 0.34.5
|
'@img/sharp-darwin-x64': 0.34.5
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# pnpm v11 configuration file
|
||||||
|
packages:
|
||||||
|
- "."
|
||||||
|
|
||||||
|
# This replaces 'onlyBuiltDependencies'
|
||||||
|
allowBuilds:
|
||||||
|
"sharp": true
|
||||||
|
"esbuild": true
|
||||||
|
"lightningcss": true
|
||||||
@@ -1,12 +1,27 @@
|
|||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { Image } from "@unpic/react";
|
import { Image } from "@unpic/react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import MaintenanceModeBanner from "#/components/MaintenanceModeBanner.tsx";
|
||||||
|
import { env } from "#/env.ts";
|
||||||
import logo from "@/assets/images/logo.svg?url";
|
import logo from "@/assets/images/logo.svg?url";
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
|
const maintenanceModeMessage = env.VITE_MAINTENANCE_MODE
|
||||||
|
? z
|
||||||
|
.string()
|
||||||
|
.transform((value) => (value.trim() === "" ? null : value))
|
||||||
|
.parse(env.VITE_MAINTENANCE_MODE_MESSAGE)
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header>
|
<header>
|
||||||
<nav className="w-full max-w-screen-2xl mx-auto p-8">
|
{maintenanceModeMessage ? (
|
||||||
|
<div className="absolute w-full">
|
||||||
|
<MaintenanceModeBanner message={maintenanceModeMessage} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<nav className="w-full max-w-screen-2xl place-self-center p-8">
|
||||||
<div className="container mx-auto flex items-center justify-between">
|
<div className="container mx-auto flex items-center justify-between">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Link to="/" className="flex flex-row items-center justify-center">
|
<Link to="/" className="flex flex-row items-center justify-center">
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { FormEvent } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { useLichess } from "#/hooks/useLichess.ts";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
setSelectedStudyId: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LichessStudyLinkInput = ({ setSelectedStudyId }: IProps) => {
|
||||||
|
const { parseLichessStudyLink } = useLichess();
|
||||||
|
|
||||||
|
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const studyLink = formData.get("lichess-study-link");
|
||||||
|
|
||||||
|
if (!studyLink) return;
|
||||||
|
|
||||||
|
if (typeof studyLink !== "string") {
|
||||||
|
toast.error("Invalid study link");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = parseLichessStudyLink(studyLink);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(result.data);
|
||||||
|
|
||||||
|
setSelectedStudyId(result.data);
|
||||||
|
toast.success("Study loaded — pick a chapter");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="flex items-end gap-2" onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="lichess-study-link" className="text-sm capitalize">
|
||||||
|
Lichess Study Link
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="border rounded-md p-2 w-full"
|
||||||
|
type="text"
|
||||||
|
id="lichess-study-link"
|
||||||
|
name="lichess-study-link"
|
||||||
|
placeholder="https://lichess.org/study/..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="border py-2 px-3 rounded-md cursor-pointer bg-(--accent) text-white hover:bg-transparent hover:text-(--accent) transition-colors duration-300 ease-in-out"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LichessStudyLinkInput;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
interface IProps {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_MESSAGE = "Maintenance Mode";
|
||||||
|
|
||||||
|
const MaintenanceModeBanner = ({ message }: IProps) => (
|
||||||
|
<div className="bg-orange-400 text-white text-center py-1 w-full text-sm">
|
||||||
|
<p>{message || DEFAULT_MESSAGE}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default MaintenanceModeBanner;
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
import { useClickOutside } from "#/hooks/useClickOutside.ts";
|
import { useClickOutside } from "#/hooks/useClickOutside.ts";
|
||||||
import { type IUserStudyChapter, useLichess } from "#/hooks/useLichess.ts";
|
import { useLichess } from "#/hooks/useLichess.ts";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
setSelectedStudyId: (studyId: string) => void;
|
setSelectedStudyId: (studyId: string) => void;
|
||||||
setStudyChapters: (chapters: IUserStudyChapter[]) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SelectLichessStudy = ({ setSelectedStudyId }: IProps) => {
|
const SelectLichessStudy = ({ setSelectedStudyId }: IProps) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const { getLichessUserStudies, userStudies } = useLichess();
|
const { getLichessUserStudies, userStudies, setUserStudies } = useLichess();
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const filteredStudies = userStudies.filter((study) =>
|
const filteredStudies = userStudies.filter((study) =>
|
||||||
@@ -20,15 +20,20 @@ const SelectLichessStudy = ({ setSelectedStudyId }: IProps) => {
|
|||||||
const onClickHandler = async () => {
|
const onClickHandler = async () => {
|
||||||
setIsOpen(!isOpen);
|
setIsOpen(!isOpen);
|
||||||
|
|
||||||
if (!userStudies.length) {
|
if (userStudies.length) return;
|
||||||
await getLichessUserStudies();
|
|
||||||
|
const result = await getLichessUserStudies();
|
||||||
|
if (result.ok) {
|
||||||
|
setUserStudies(result.data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStudyClick = async (studyId: string) => {
|
const handleStudyClick = async (studyId: string) => {
|
||||||
|
const study = userStudies.find((s) => s.id === studyId);
|
||||||
setSelectedStudyId(studyId);
|
setSelectedStudyId(studyId);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setSearch("");
|
setSearch("");
|
||||||
|
toast.success(`Loaded study "${study?.name ?? studyId}"`);
|
||||||
};
|
};
|
||||||
|
|
||||||
useClickOutside({ isOpen, setIsOpen, setSearch, ref: dropdownRef });
|
useClickOutside({ isOpen, setIsOpen, setSearch, ref: dropdownRef });
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { type Dispatch, useRef, useState } from "react";
|
import {
|
||||||
import { useClickOutside } from "#/hooks/useClickOutside.ts";
|
type Dispatch,
|
||||||
import { type IUserStudyChapter, useLichess } from "#/hooks/useLichess.ts";
|
useActionState,
|
||||||
|
useEffect,
|
||||||
|
// useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
// import { useClickOutside } from "#/hooks/useClickOutside.ts";
|
||||||
|
import { useLichess } from "#/hooks/useLichess.ts";
|
||||||
|
import type { IUserStudyChapter } from "#/interfaces.ts";
|
||||||
import type { GameAction } from "#/reducers/gameReducer.ts";
|
import type { GameAction } from "#/reducers/gameReducer.ts";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
@@ -8,26 +16,68 @@ interface IProps {
|
|||||||
gameDispatch: Dispatch<GameAction>;
|
gameDispatch: Dispatch<GameAction>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ChapterState = {
|
||||||
|
status: "idle" | "success" | "error";
|
||||||
|
chapters: IUserStudyChapter[];
|
||||||
|
error: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialChapterState: ChapterState = {
|
||||||
|
status: "idle",
|
||||||
|
chapters: [],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
const SelectStudyChapter = ({ selectedStudyId, gameDispatch }: IProps) => {
|
const SelectStudyChapter = ({ selectedStudyId, gameDispatch }: IProps) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [studyChapters, setStudyChapters] = useState<IUserStudyChapter[]>([]);
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
// const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { getLichessStudyChapters, loadLichessStudyChapter } = useLichess();
|
const { getLichessStudyChapters, loadLichessStudyChapter } = useLichess();
|
||||||
|
|
||||||
const filteredChapters = studyChapters.filter((chapter) =>
|
const [chapterState, fetchChaptersAction, isPending] = useActionState(
|
||||||
|
async (_prev: ChapterState, _formData: FormData): Promise<ChapterState> => {
|
||||||
|
if (!selectedStudyId) {
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
chapters: [],
|
||||||
|
error: "No study selected.",
|
||||||
|
} satisfies ChapterState;
|
||||||
|
}
|
||||||
|
const result = await getLichessStudyChapters({
|
||||||
|
studyId: selectedStudyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(result);
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
return {
|
||||||
|
status: "success",
|
||||||
|
chapters: result.data,
|
||||||
|
error: null,
|
||||||
|
} satisfies ChapterState;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
chapters: [],
|
||||||
|
error: result.error,
|
||||||
|
} satisfies ChapterState;
|
||||||
|
},
|
||||||
|
initialChapterState,
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredChapters = chapterState.chapters.filter((chapter) =>
|
||||||
chapter.name.toLowerCase().includes(search.toLowerCase()),
|
chapter.name.toLowerCase().includes(search.toLowerCase()),
|
||||||
);
|
);
|
||||||
|
|
||||||
useClickOutside({ isOpen, setIsOpen, setSearch, ref: dropdownRef });
|
// useClickOutside({ isOpen, setIsOpen, setSearch, ref: dropdownRef });
|
||||||
|
|
||||||
const onClickHandler = async () => {
|
useEffect(() => {
|
||||||
const studies = await getLichessStudyChapters({ studyId: selectedStudyId });
|
if (isPending || chapterState.status === "success") {
|
||||||
if (!studies) return;
|
setIsOpen(true);
|
||||||
setStudyChapters(studies);
|
}
|
||||||
setIsOpen(!isOpen);
|
}, [isPending, chapterState.status]);
|
||||||
};
|
|
||||||
|
|
||||||
const handleChapterClick = async (chapter: IUserStudyChapter) => {
|
const handleChapterClick = async (chapter: IUserStudyChapter) => {
|
||||||
const { headers, pgn } = await loadLichessStudyChapter({ chapter });
|
const { headers, pgn } = await loadLichessStudyChapter({ chapter });
|
||||||
@@ -36,16 +86,22 @@ const SelectStudyChapter = ({ selectedStudyId, gameDispatch }: IProps) => {
|
|||||||
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
setSearch("");
|
setSearch("");
|
||||||
|
toast.success(`Loaded chapter "${chapter.name}"`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={dropdownRef} className="relative">
|
<form
|
||||||
<button
|
key={selectedStudyId}
|
||||||
type="button"
|
action={fetchChaptersAction}
|
||||||
className="btn btn-secondary"
|
className="relative"
|
||||||
onClick={onClickHandler}
|
|
||||||
>
|
>
|
||||||
Import Study Chapter/Game
|
<input type="hidden" name="studyId" value={selectedStudyId} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={!selectedStudyId || isPending}
|
||||||
|
>
|
||||||
|
{isPending ? "Loading chapters…" : "Import Chapter"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen ? (
|
{isOpen ? (
|
||||||
@@ -79,7 +135,11 @@ const SelectStudyChapter = ({ selectedStudyId, gameDispatch }: IProps) => {
|
|||||||
|
|
||||||
<hr className="border-(--neutral-content)/15 my-2" />
|
<hr className="border-(--neutral-content)/15 my-2" />
|
||||||
|
|
||||||
{filteredChapters.length > 0 ? (
|
{isPending ? (
|
||||||
|
<p className="text-(--neutral-content) text-sm text-center py-4">
|
||||||
|
Loading chapters…
|
||||||
|
</p>
|
||||||
|
) : filteredChapters.length > 0 ? (
|
||||||
<ul>
|
<ul>
|
||||||
{filteredChapters.map((chapter) => (
|
{filteredChapters.map((chapter) => (
|
||||||
<li key={`${chapter.chapterId}${chapter.name}`}>
|
<li key={`${chapter.chapterId}${chapter.name}`}>
|
||||||
@@ -100,7 +160,7 @@ const SelectStudyChapter = ({ selectedStudyId, gameDispatch }: IProps) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+6
-2
@@ -1,9 +1,11 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const clientSchema = z.object({
|
const clientSchema = z.object({
|
||||||
VITE_API_BASE_URL: z.string("Invalid API Base URL format"),
|
VITE_API_BASE_URL: z.url("Invalid API Base URL format"),
|
||||||
VITE_MATOMO_URL: z.string("Invalid URL format"),
|
VITE_MATOMO_URL: z.url("Invalid URL format"),
|
||||||
VITE_MATOMO_SITE_ID: z.string("Invalid Site ID"),
|
VITE_MATOMO_SITE_ID: z.string("Invalid Site ID"),
|
||||||
|
VITE_MAINTENANCE_MODE: z.string("Invalid Maintenance Mode"),
|
||||||
|
VITE_MAINTENANCE_MODE_MESSAGE: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const serverSchema = z.object({
|
const serverSchema = z.object({
|
||||||
@@ -25,6 +27,7 @@ const getEnv = () => {
|
|||||||
const _env = fullSchema.safeParse(rawEnv);
|
const _env = fullSchema.safeParse(rawEnv);
|
||||||
if (!_env.success) {
|
if (!_env.success) {
|
||||||
console.error("❌ Invalid Server Environment Variables:");
|
console.error("❌ Invalid Server Environment Variables:");
|
||||||
|
console.error(JSON.stringify(_env.error.format(), null, 2));
|
||||||
throw new Error("Invalid environment variables");
|
throw new Error("Invalid environment variables");
|
||||||
}
|
}
|
||||||
return _env.data;
|
return _env.data;
|
||||||
@@ -32,6 +35,7 @@ const getEnv = () => {
|
|||||||
const _env = clientSchema.safeParse(rawEnv);
|
const _env = clientSchema.safeParse(rawEnv);
|
||||||
if (!_env.success) {
|
if (!_env.success) {
|
||||||
console.error("❌ Invalid Client Environment Variables:");
|
console.error("❌ Invalid Client Environment Variables:");
|
||||||
|
console.error(JSON.stringify(_env.error.format(), null, 2));
|
||||||
throw new Error("Invalid environment variables");
|
throw new Error("Invalid environment variables");
|
||||||
}
|
}
|
||||||
return _env.data as z.infer<typeof fullSchema>;
|
return _env.data as z.infer<typeof fullSchema>;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useServerFn } from "@tanstack/react-start";
|
||||||
import {
|
import {
|
||||||
type ChangeEvent,
|
type ChangeEvent,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -9,6 +10,7 @@ import { toast } from "react-toastify";
|
|||||||
import { env } from "#/env.ts";
|
import { env } from "#/env.ts";
|
||||||
import type { IHeader, IPosition } from "#/interfaces.ts";
|
import type { IHeader, IPosition } from "#/interfaces.ts";
|
||||||
import { gameReducer, initialGameState } from "#/reducers/gameReducer.ts";
|
import { gameReducer, initialGameState } from "#/reducers/gameReducer.ts";
|
||||||
|
import { recordPdfSuccess } from "#/server/pdfMetrics.ts";
|
||||||
import { downloadPDF } from "#/utils/pdfUtils.ts";
|
import { downloadPDF } from "#/utils/pdfUtils.ts";
|
||||||
import { buildPgnString, getHeaders } from "#/utils/pgnUtils.ts";
|
import { buildPgnString, getHeaders } from "#/utils/pgnUtils.ts";
|
||||||
import { downloadString } from "#/utils/stringUtils.ts";
|
import { downloadString } from "#/utils/stringUtils.ts";
|
||||||
@@ -16,6 +18,8 @@ import { downloadString } from "#/utils/stringUtils.ts";
|
|||||||
export const useChessGame = () => {
|
export const useChessGame = () => {
|
||||||
const [gameState, gameDispatch] = useReducer(gameReducer, initialGameState);
|
const [gameState, gameDispatch] = useReducer(gameReducer, initialGameState);
|
||||||
|
|
||||||
|
const recordPdfSuccessFn = useServerFn(recordPdfSuccess);
|
||||||
|
|
||||||
const [currentPosition, setCurrentPosition] = useState<IPosition>({
|
const [currentPosition, setCurrentPosition] = useState<IPosition>({
|
||||||
ply: 0,
|
ply: 0,
|
||||||
fen: "",
|
fen: "",
|
||||||
@@ -66,7 +70,7 @@ export const useChessGame = () => {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
downloadPDF(await response.blob());
|
downloadPDF(await response.blob());
|
||||||
|
|
||||||
// TODO add metrics logger - add success to analytics
|
await recordPdfSuccessFn();
|
||||||
|
|
||||||
toast.success("PDF successfully generated!", {
|
toast.success("PDF successfully generated!", {
|
||||||
toastId: "pdf-success",
|
toastId: "pdf-success",
|
||||||
|
|||||||
+58
-17
@@ -20,13 +20,35 @@ import { getHeaders } from "#/utils/pgnUtils.ts";
|
|||||||
|
|
||||||
type Status = "loading" | "success" | "error";
|
type Status = "loading" | "success" | "error";
|
||||||
|
|
||||||
|
export type LichessResult<T> =
|
||||||
|
| { ok: true; data: T }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
const LICHESS_STUDY_LINK_RE =
|
||||||
|
/^https?:\/\/lichess\.org\/study\/([A-Za-z0-9]{8})(?:\.pgn)?(?:$|\/|\?|#)/i;
|
||||||
|
|
||||||
|
const lichessFetch = async <T>(
|
||||||
|
fn: () => Promise<T | undefined>,
|
||||||
|
fallbackError: string,
|
||||||
|
): Promise<LichessResult<T>> => {
|
||||||
|
try {
|
||||||
|
const data = await fn();
|
||||||
|
if (data === undefined || data === null) {
|
||||||
|
return { ok: false, error: fallbackError };
|
||||||
|
}
|
||||||
|
return { ok: true, data };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: err instanceof Error ? err.message : fallbackError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const useLichess = () => {
|
export const useLichess = () => {
|
||||||
const { user, setUser } = useLichessUser();
|
const { user, setUser } = useLichessUser();
|
||||||
const [userStudies, setUserStudies] = useState<IUserStudy[]>([]);
|
const [userStudies, setUserStudies] = useState<IUserStudy[]>([]);
|
||||||
const [selectedStudyId, setSelectedStudyId] = useState<string | null>(null);
|
const [selectedStudyId, setSelectedStudyId] = useState<string | null>(null);
|
||||||
const [studyChapters, setStudyChapters] = useState<
|
|
||||||
IUserStudyChapter[] | null
|
|
||||||
>(null);
|
|
||||||
|
|
||||||
const loginFn = useServerFn(login);
|
const loginFn = useServerFn(login);
|
||||||
const logoutFn = useServerFn(logout);
|
const logoutFn = useServerFn(logout);
|
||||||
@@ -108,20 +130,23 @@ export const useLichess = () => {
|
|||||||
return { status, error };
|
return { status, error };
|
||||||
};
|
};
|
||||||
|
|
||||||
const getLichessUserStudies = async () => {
|
const getLichessUserStudies = async (): Promise<
|
||||||
if (!user) throw new Error("User not found");
|
LichessResult<IUserStudy[]>
|
||||||
const studies: IUserStudy[] | undefined = await getUserStudiesFn();
|
> => {
|
||||||
if (!studies) return;
|
if (!user) return { ok: false, error: "Not signed in to Lichess." };
|
||||||
setUserStudies(studies);
|
return lichessFetch(() => getUserStudiesFn(), "Failed to load studies.");
|
||||||
};
|
};
|
||||||
|
|
||||||
const getLichessStudyChapters = async ({ studyId }: { studyId: string }) => {
|
const getLichessStudyChapters = async ({
|
||||||
if (!user) throw new Error("User not found");
|
studyId,
|
||||||
const chapters: IUserStudyChapter[] | undefined = await getStudyChaptersFn({
|
}: {
|
||||||
data: { studyId },
|
studyId: string;
|
||||||
});
|
}): Promise<LichessResult<IUserStudyChapter[]>> => {
|
||||||
if (!chapters) return;
|
if (!user) return { ok: false, error: "Not signed in to Lichess." };
|
||||||
return chapters;
|
return lichessFetch(
|
||||||
|
() => getStudyChaptersFn({ data: { studyId } }),
|
||||||
|
"Failed to load chapters.",
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// return headers and pgn from the Lichess chapter
|
// return headers and pgn from the Lichess chapter
|
||||||
@@ -144,6 +169,23 @@ export const useLichess = () => {
|
|||||||
return { headers, pgn };
|
return { headers, pgn };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// parse and process lichess study link
|
||||||
|
const parseLichessStudyLink = (studyLink: string): LichessResult<string> => {
|
||||||
|
const trimmed = studyLink.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
return { ok: false, error: "Please paste a Lichess study link." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = trimmed.match(LICHESS_STUDY_LINK_RE);
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
return { ok: false, error: "Invalid Lichess study link." };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, data: match[1] };
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
lichessLogin,
|
lichessLogin,
|
||||||
lichessLogout,
|
lichessLogout,
|
||||||
@@ -158,8 +200,7 @@ export const useLichess = () => {
|
|||||||
setSelectedStudyId,
|
setSelectedStudyId,
|
||||||
selectedStudyId,
|
selectedStudyId,
|
||||||
getLichessStudyChapters,
|
getLichessStudyChapters,
|
||||||
studyChapters,
|
|
||||||
setStudyChapters,
|
|
||||||
loadLichessStudyChapter,
|
loadLichessStudyChapter,
|
||||||
|
parseLichessStudyLink,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { lazy, Suspense } from "react";
|
|||||||
import CustomHeaders from "#/components/CustomHeaders.tsx";
|
import CustomHeaders from "#/components/CustomHeaders.tsx";
|
||||||
import HeaderFields from "#/components/HeaderFields.tsx";
|
import HeaderFields from "#/components/HeaderFields.tsx";
|
||||||
import LichessButton from "#/components/LichessButton.tsx";
|
import LichessButton from "#/components/LichessButton.tsx";
|
||||||
|
import LichessStudyLinkInput from "#/components/LichessStudyLinkInput.tsx";
|
||||||
import Section from "#/components/Section.tsx";
|
import Section from "#/components/Section.tsx";
|
||||||
import SelectLichessStudy from "#/components/SelectLichessStudy.tsx";
|
import SelectLichessStudy from "#/components/SelectLichessStudy.tsx";
|
||||||
import SelectStudyChapter from "#/components/SelectStudyChapter.tsx";
|
import SelectStudyChapter from "#/components/SelectStudyChapter.tsx";
|
||||||
@@ -38,8 +39,7 @@ const Chessboard = () => {
|
|||||||
gameDispatch,
|
gameDispatch,
|
||||||
} = useChessGame();
|
} = useChessGame();
|
||||||
|
|
||||||
const { selectedStudyId, setSelectedStudyId, setStudyChapters } =
|
const { selectedStudyId, setSelectedStudyId } = useLichess();
|
||||||
useLichess();
|
|
||||||
|
|
||||||
const { user } = useLichessUser();
|
const { user } = useLichessUser();
|
||||||
|
|
||||||
@@ -55,18 +55,21 @@ const Chessboard = () => {
|
|||||||
onChange={handleLoadPgn}
|
onChange={handleLoadPgn}
|
||||||
/>
|
/>
|
||||||
{user ? (
|
{user ? (
|
||||||
<SelectLichessStudy
|
<SelectLichessStudy setSelectedStudyId={setSelectedStudyId} />
|
||||||
setSelectedStudyId={setSelectedStudyId}
|
|
||||||
setStudyChapters={setStudyChapters}
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
<div />
|
<div />
|
||||||
{selectedStudyId && user ? (
|
{user ? (
|
||||||
<SelectStudyChapter
|
<SelectStudyChapter
|
||||||
selectedStudyId={selectedStudyId || ""}
|
selectedStudyId={selectedStudyId || ""}
|
||||||
gameDispatch={gameDispatch}
|
gameDispatch={gameDispatch}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
{user ? (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<p className="text-center">or</p>
|
||||||
|
<LichessStudyLinkInput setSelectedStudyId={setSelectedStudyId} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<div className="grid lg:grid-cols-2 gap-4">
|
<div className="grid lg:grid-cols-2 gap-4">
|
||||||
|
|||||||
+7
-7
@@ -1,19 +1,19 @@
|
|||||||
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
|
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from "./routeTree.gen";
|
||||||
|
|
||||||
export function getRouter() {
|
export function getRouter() {
|
||||||
const router = createTanStackRouter({
|
const router = createTanStackRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
defaultPreload: 'intent',
|
defaultPreload: "intent",
|
||||||
defaultPreloadStaleTime: 0,
|
defaultPreloadStaleTime: 0,
|
||||||
})
|
});
|
||||||
|
|
||||||
return router
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module "@tanstack/react-router" {
|
||||||
interface Register {
|
interface Register {
|
||||||
router: ReturnType<typeof getRouter>
|
router: ReturnType<typeof getRouter>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { promises as fs } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
|
||||||
|
const METRICS_DIR = join(process.cwd(), "data");
|
||||||
|
const LOG_FILE = join(METRICS_DIR, "pdf-events.log");
|
||||||
|
const COUNT_FILE = join(METRICS_DIR, "pdf-count.txt");
|
||||||
|
|
||||||
|
const readCount = async (): Promise<number> => {
|
||||||
|
try {
|
||||||
|
const n = Number.parseInt(
|
||||||
|
(await fs.readFile(COUNT_FILE, "utf-8")).trim(),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const writeCountAtomic = async (count: number) => {
|
||||||
|
const tmp = `${COUNT_FILE}.tmp`;
|
||||||
|
await fs.writeFile(tmp, String(count), "utf-8");
|
||||||
|
await fs.rename(tmp, COUNT_FILE);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const recordPdfSuccess = createServerFn({ method: "POST" }).handler(
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
await fs.mkdir(METRICS_DIR, { recursive: true });
|
||||||
|
const next = (await readCount()) + 1;
|
||||||
|
await Promise.all([
|
||||||
|
fs.appendFile(LOG_FILE, `${new Date().toISOString()}\n`, "utf-8"),
|
||||||
|
writeCountAtomic(next),
|
||||||
|
]);
|
||||||
|
return { count: next };
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to record PDF success:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
+13
-13
@@ -17,6 +17,19 @@
|
|||||||
border-bottom: 1px solid var(--neutral-content);
|
border-bottom: 1px solid var(--neutral-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero__accordion-item__content {
|
||||||
|
padding: 0 10px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition:
|
||||||
|
grid-template-rows 0.3s ease,
|
||||||
|
padding 0.3s ease;
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion) {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.hero__accordion-details[open] + .hero__accordion-item__content {
|
.hero__accordion-details[open] + .hero__accordion-item__content {
|
||||||
grid-template-rows: 1fr;
|
grid-template-rows: 1fr;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@@ -53,19 +66,6 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero__accordion-item__content {
|
|
||||||
padding: 0 10px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: 0fr;
|
|
||||||
transition:
|
|
||||||
grid-template-rows 0.3s ease,
|
|
||||||
padding 0.3s ease;
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion) {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero__accordion-item__content__inner {
|
.hero__accordion-item__content__inner {
|
||||||
color: var(--neutral-content);
|
color: var(--neutral-content);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
+688
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user