Initial frontend import
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
32
.gitignore
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Local debug screenshots/artifacts
|
||||
public/assets/debug-*.png
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Test / cache
|
||||
coverage/
|
||||
.cache/
|
||||
.vite/
|
||||
5
.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
coverage
|
||||
.DS_Store
|
||||
21
Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
ARG VITE_WALLETCONNECT_PROJECT_ID=
|
||||
ARG VITE_API_URL=
|
||||
ARG VITE_ADMIN_UI_PREFIX=
|
||||
ARG VITE_ADMIN_ONLY=
|
||||
ARG VITE_DISABLE_ADMIN=
|
||||
ENV VITE_WALLETCONNECT_PROJECT_ID=$VITE_WALLETCONNECT_PROJECT_ID
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
ENV VITE_ADMIN_UI_PREFIX=$VITE_ADMIN_UI_PREFIX
|
||||
ENV VITE_ADMIN_ONLY=$VITE_ADMIN_ONLY
|
||||
ENV VITE_DISABLE_ADMIN=$VITE_DISABLE_ADMIN
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ARK 資料庫</title>
|
||||
</head>
|
||||
<body class="bg-ark-bg text-neutral-100">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
18
nginx.conf
Normal file
@@ -0,0 +1,18 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location ^~ /api/ {
|
||||
return 404;
|
||||
}
|
||||
location ^~ /uploads/ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
10578
package-lock.json
generated
Normal file
34
package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "ark-database-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx,css}\" \"*.{js,ts,json,html}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx,css}\" \"*.{js,ts,json,html}\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@rainbow-me/rainbowkit": "^2.2.11",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
"lucide-react": "^0.460.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"viem": "^2.48.11",
|
||||
"wagmi": "^2.19.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.8.3",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
12
public/assets/ark-library/figma/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Figma ARK Library UI assets
|
||||
|
||||
Source: Figma file `uHDZkVHjAp7BXDKQKB0PM4`, responsive reference node `3761:10923`.
|
||||
|
||||
- `banner-desktop.png` — desktop/tablet banner export from node `3621:1225`.
|
||||
- `banner-wide.png` — provided wide banner crop from node `3718:11952`.
|
||||
- `banner-576.png` — mobile/tablet banner crop from node `3726:13099`.
|
||||
- `banner-440.png` — mobile banner crop from node `3726:14199`.
|
||||
- `banner-375.png` — mobile banner crop from node `3726:14238`.
|
||||
- `recommendation-1.png` ... `recommendation-5.png` — official recommendation cover exports from the 1920px frame card image nodes.
|
||||
|
||||
These files are visual UI assets only. They do not change backend data or API contracts.
|
||||
BIN
public/assets/ark-library/figma/banner-375.png
Normal file
|
After Width: | Height: | Size: 227 KiB |
BIN
public/assets/ark-library/figma/banner-440.png
Normal file
|
After Width: | Height: | Size: 284 KiB |
BIN
public/assets/ark-library/figma/banner-576.png
Normal file
|
After Width: | Height: | Size: 411 KiB |
BIN
public/assets/ark-library/figma/banner-desktop.png
Normal file
|
After Width: | Height: | Size: 589 KiB |
BIN
public/assets/ark-library/figma/banner-wide.png
Normal file
|
After Width: | Height: | Size: 506 KiB |
BIN
public/assets/ark-library/figma/recommendation-1.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
public/assets/ark-library/figma/recommendation-2.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
public/assets/ark-library/figma/recommendation-3.png
Normal file
|
After Width: | Height: | Size: 171 KiB |
BIN
public/assets/ark-library/figma/recommendation-4.png
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
public/assets/ark-library/figma/recommendation-5.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
public/assets/ark-library/media/jpeg/hero.jpg
Normal file
|
After Width: | Height: | Size: 223 KiB |
9
public/assets/ark-library/media/svg/community.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.08594 2H27.9141C32.3726 2 36 5.62735 36 10.0859V21.8862C36 25.9875 32.931 29.3854 28.9688 29.9037V33.9311C28.9688 34.3577 28.7118 34.7423 28.3177 34.9055C28.1872 34.9596 28.0501 34.9858 27.9143 34.9858C27.6398 34.9858 27.3701 34.8786 27.1683 34.6768L19.3331 29.9721H8.08594C3.62735 29.9721 3.57628e-07 26.3448 3.57628e-07 21.8862V10.0859C3.57628e-07 5.62735 3.62735 2 8.08594 2ZM26.4374 18.3333C27.6024 18.3333 28.5468 17.389 28.5468 16.224C28.5468 15.059 27.6024 14.1146 26.4374 14.1146C25.2725 14.1146 24.3281 15.059 24.3281 16.224C24.3281 17.3889 25.2724 18.3333 26.4374 18.3333ZM17.9999 18.3333C19.1649 18.3333 20.1093 17.389 20.1093 16.224C20.1093 15.059 19.1649 14.1146 17.9999 14.1146C16.835 14.1146 15.8906 15.059 15.8906 16.224C15.8906 17.3889 16.835 18.3333 17.9999 18.3333ZM9.56243 18.3333C10.7274 18.3333 11.6718 17.389 11.6718 16.224C11.6718 15.059 10.7274 14.1146 9.56243 14.1146C8.39749 14.1146 7.45305 15.059 7.45305 16.224C7.45305 17.3889 8.39749 18.3333 9.56243 18.3333Z" fill="url(#paint0_linear_3621_1517)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_3621_1517" x1="18" y1="2" x2="18" y2="34.9858" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
14
public/assets/ark-library/media/svg/directory.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 1.125C8.685 1.125 1.125 8.685 1.125 18C1.125 27.315 8.685 34.875 18 34.875C27.315 34.875 34.875 27.315 34.875 18C34.875 8.685 27.315 1.125 18 1.125ZM26.775 10.3275L22.5338 20.61C22.1849 21.4875 21.4874 22.185 20.61 22.5338L10.3275 26.775C9.63 27.0562 8.94375 26.37 9.225 25.6725L13.4663 15.39C13.8151 14.5125 14.5126 13.815 15.39 13.4663L25.6725 9.225C26.37 8.94375 27.0562 9.63 26.775 10.3275Z" fill="url(#paint0_linear_3621_1523)"/>
|
||||
<path d="M18 20.25C19.2426 20.25 20.25 19.2426 20.25 18C20.25 16.7574 19.2426 15.75 18 15.75C16.7574 15.75 15.75 16.7574 15.75 18C15.75 19.2426 16.7574 20.25 18 20.25Z" fill="url(#paint1_linear_3621_1523)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_3621_1523" x1="18" y1="1.125" x2="18" y2="34.875" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_3621_1523" x1="18" y1="1.125" x2="18" y2="34.875" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
19
public/assets/ark-library/media/svg/education.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M28.2619 19.2138C28.7318 18.9688 29.2941 19.3098 29.2941 19.8397V24.3033C29.2941 26.091 29.2941 28.2271 26.4919 29.8449C24.5294 30.978 21.2896 33.1462 17.4706 33.1462C13.6516 33.1462 10.4118 31.0286 8.43741 29.8449C5.64706 28.1721 5.64706 26.091 5.64706 24.3033V19.8397C5.64706 19.3098 6.20934 18.9688 6.67929 19.2138L14.0536 23.0587C15.1295 23.534 16.2882 23.7716 17.4706 23.7716C18.6529 23.7716 19.8116 23.534 20.8876 23.0587L28.2619 19.2138Z" fill="url(#paint0_linear_3621_1510)"/>
|
||||
<path d="M32.7491 16.5221C33.2168 16.3069 33.75 16.6486 33.75 17.1633V26.9036C33.75 27.5223 33.2438 28.0286 32.625 28.0286C32.0063 28.0286 31.5 27.5223 31.5 26.9036V18.0012C31.5 17.4499 31.8209 16.949 32.3218 16.7186L32.7491 16.5221Z" fill="url(#paint1_linear_3621_1510)"/>
|
||||
<path d="M15.6803 21.1454C16.4171 21.4818 17.208 21.6505 18 21.6505C18.792 21.6505 19.5818 21.4829 20.3198 21.1454L34.6871 13.1659C35.4971 12.7958 36 12.0117 36 11.1195C36 10.2274 35.4971 9.44216 34.6871 9.07203L20.3198 2.50541C18.8449 1.83153 17.1562 1.83153 15.6814 2.50541L1.31288 9.07091C0.502875 9.44216 0 10.2263 0 11.1184C0 12.0105 0.502875 12.7947 1.31288 13.1659L15.6803 21.1454Z" fill="url(#paint2_linear_3621_1510)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_3621_1510" x1="18" y1="2" x2="18" y2="33.1462" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_3621_1510" x1="18" y1="2" x2="18" y2="33.1462" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_3621_1510" x1="18" y1="2" x2="18" y2="33.1462" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M28.5233 3.73047H7.47674C3.34745 3.73047 0 7.07792 0 11.2072L0 24.4091C0 28.5384 3.34745 31.8858 7.47674 31.8858H28.5233C32.6526 31.8858 36 28.5384 36 24.4091V11.2072C36 7.07792 32.6526 3.73047 28.5233 3.73047ZM24.2328 18.5115L14.3886 23.2066C14.1263 23.3317 13.8233 23.1404 13.8233 22.8499V13.1663C13.8233 12.8716 14.1343 12.6806 14.3971 12.8138L24.2413 17.8023C24.5339 17.9506 24.5289 18.3703 24.2328 18.5115Z" fill="url(#paint0_linear_3621_1529)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_3621_1529" x1="18" y1="3.73047" x2="18" y2="31.8858" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 785 B |
24
public/assets/ark-library/media/svg/everyday-class.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.6471 1.63636C11.6471 0.732625 10.8886 0 9.95296 0C9.01732 0 8.25884 0.732625 8.25884 1.63636V4.90909C8.25884 5.81283 9.01732 6.54545 9.95296 6.54545C10.8886 6.54545 11.6471 5.81283 11.6471 4.90909V1.63636Z" fill="url(#paint0_linear_3621_1506)"/>
|
||||
<path d="M27.7412 1.63636C27.7412 0.732625 26.9827 0 26.0471 0C25.1114 0 24.353 0.732625 24.353 1.63636V4.90909C24.353 5.81283 25.1114 6.54545 26.0471 6.54545C26.9827 6.54545 27.7412 5.81283 27.7412 4.90909V1.63636Z" fill="url(#paint1_linear_3621_1506)"/>
|
||||
<path d="M32.9412 11.4545C34.0458 11.4545 34.9412 10.5591 34.9412 9.45455V6.54545C34.9412 4.73809 33.4241 3.27273 31.553 3.27273H29.4353V4.09091C29.4353 5.89582 27.9157 7.36364 26.0471 7.36364C24.1785 7.36364 22.6588 5.89582 22.6588 4.09091V3.27273H13.3412V4.09091C13.3412 5.89582 11.8216 7.36364 9.95296 7.36364C8.08434 7.36364 6.56472 5.89582 6.56472 4.09091V3.27273C6.56472 3.27273 6.31823 3.27273 4.44707 3.27273C2.57592 3.27273 1.05884 4.73809 1.05884 6.54545V9.45454C1.05884 10.5591 1.95427 11.4545 3.05884 11.4545H32.9412Z" fill="url(#paint2_linear_3621_1506)"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M33.2471 12.6818C34.1827 12.6818 34.9412 13.4144 34.9412 14.3182V32.7273C34.9412 34.5346 33.4241 36 31.553 36H4.44707C2.57592 36 1.05884 34.5346 1.05884 32.7273V14.3182C1.05884 13.4144 1.81732 12.6818 2.75296 12.6818H33.2471ZM10.1647 24.9545C8.87822 24.9545 7.83531 25.9619 7.83531 27.2045C7.83531 28.4472 8.87822 29.4545 10.1647 29.4545C11.4512 29.4545 12.4941 28.4472 12.4941 27.2045C12.4941 25.9619 11.4512 24.9545 10.1647 24.9545ZM17.7882 24.9545C16.5017 24.9545 15.4588 25.9619 15.4588 27.2045C15.4588 28.4472 16.5017 29.4545 17.7882 29.4545C19.0747 29.4545 20.1177 28.4472 20.1177 27.2045C20.1177 25.9619 19.0747 24.9545 17.7882 24.9545ZM25.8353 24.9545C24.5488 24.9545 23.5059 25.9619 23.5059 27.2045C23.5059 28.4472 24.5488 29.4545 25.8353 29.4545C27.1218 29.4545 28.1647 28.4472 28.1647 27.2045C28.1647 25.9619 27.1218 24.9545 25.8353 24.9545ZM10.1647 17.5909C8.87822 17.5909 7.83531 18.5983 7.83531 19.8409C7.83531 21.0835 8.87822 22.0909 10.1647 22.0909C11.4512 22.0909 12.4941 21.0835 12.4941 19.8409C12.4941 18.5983 11.4512 17.5909 10.1647 17.5909ZM17.7882 17.5909C16.5017 17.5909 15.4588 18.5983 15.4588 19.8409C15.4588 21.0835 16.5017 22.0909 17.7882 22.0909C19.0747 22.0909 20.1177 21.0835 20.1177 19.8409C20.1177 18.5983 19.0747 17.5909 17.7882 17.5909ZM25.8353 17.5909C24.5488 17.5909 23.5059 18.5983 23.5059 19.8409C23.5059 21.0835 24.5488 22.0909 25.8353 22.0909C27.1218 22.0909 28.1647 21.0835 28.1647 19.8409C28.1647 18.5983 27.1218 17.5909 25.8353 17.5909Z" fill="url(#paint3_linear_3621_1506)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_3621_1506" x1="18" y1="0" x2="18" y2="36" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_3621_1506" x1="18" y1="0" x2="18" y2="36" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_3621_1506" x1="18" y1="0" x2="18" y2="36" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_3621_1506" x1="18" y1="0" x2="18" y2="36" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
14
public/assets/ark-library/media/svg/general.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3621_1531)">
|
||||
<path d="M32.4141 24.6797C32.4141 23.0293 31.0762 21.6914 29.4258 21.6914H26.3642L28.5175 14.3086H32.9414C34.5918 14.3086 35.9297 12.9707 35.9297 11.3203C35.9297 9.66994 34.5918 8.33203 32.9414 8.33203H30.2606L31.5752 3.82486C32.133 1.91243 30.6987 0 28.7066 0C27.3785 0 26.2098 0.876516 25.8379 2.15149L24.0352 8.33203H17.6044L18.919 3.82486C19.4768 1.91243 18.0425 0 16.0503 0C14.7222 0 13.5535 0.876516 13.1817 2.15149L11.379 8.33203H6.57422C4.92384 8.33203 3.58594 9.66994 3.58594 11.3203C3.58594 12.9707 4.92384 14.3086 6.57422 14.3086H9.63584L7.48252 21.6914H3.05859C1.40822 21.6914 0.0703125 23.0293 0.0703125 24.6797C0.0703125 26.3301 1.40822 27.668 3.05859 27.668H5.73933L4.42477 32.1751C3.86698 34.0876 5.30128 36 7.29345 36C8.62151 36 9.79024 35.1235 10.1621 33.8485L11.9648 27.668H18.3956L17.0811 32.1751C16.5232 34.0876 17.9575 36 19.9496 36C21.2777 36 22.4464 35.1235 22.8183 33.8485L24.621 27.668H29.4258C31.0762 27.668 32.4141 26.3301 32.4141 24.6797ZM20.1388 21.6914H13.7079L15.8612 14.3086H22.2921L20.1388 21.6914Z" fill="url(#paint0_linear_3621_1531)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_3621_1531" x1="18" y1="0" x2="18" y2="36" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_3621_1531">
|
||||
<rect width="36" height="36" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
24
public/assets/ark-library/media/svg/gift.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3621_1521)">
|
||||
<path d="M33.6522 7.82992H30.2135C30.5592 7.28289 31.6055 6.66885 31.6705 5.20126C31.8783 3.12585 30.7853 1.26538 28.9245 0.440543C27.214 -0.317637 25.2919 -0.00769899 23.9078 1.24766L20.5989 4.24649C19.9545 3.54168 19.0281 3.09871 18 3.09871C16.9701 3.09871 16.0422 3.54308 15.3977 4.25007L12.0845 1.24731C10.6982 -0.00805051 8.77683 -0.316793 7.06718 0.440965C5.20685 1.26587 4.11363 3.12697 4.32204 5.20232C4.38722 6.66941 5.43335 7.28317 5.77894 7.82992H2.3478C1.0511 7.82992 0 8.88109 0 10.1777L0 16.5631C0 17.2114 0.525586 17.737 1.17394 17.737H34.8261C35.4744 17.737 36.0001 17.2115 36.0001 16.5631V10.1777C36 8.88109 34.9489 7.82992 33.6522 7.82992ZM14.4783 6.62045V7.01174H8.9557C7.49658 7.01174 6.34823 5.65147 6.71266 4.1323C6.87319 3.46328 7.35609 2.89297 7.98068 2.60427C8.83835 2.20785 9.78841 2.33511 10.5088 2.98712L14.4792 6.58586C14.479 6.59746 14.4783 6.60885 14.4783 6.62045ZM29.341 4.88957C29.2535 6.1097 28.1514 7.01188 26.9281 7.01188H21.5217V6.62059C21.5217 6.60674 21.5209 6.59303 21.5208 6.57918C22.4202 5.76383 24.3714 3.99533 25.4297 3.03606C26.0312 2.49093 26.8727 2.23647 27.6507 2.4685C28.767 2.8015 29.4226 3.75086 29.341 4.88957Z" fill="url(#paint0_linear_3621_1521)"/>
|
||||
<path d="M2.3478 19.6758V33.6204C2.3478 34.9172 3.39891 35.9683 4.69561 35.9683H16.8794V19.6758H2.3478Z" fill="url(#paint1_linear_3621_1521)"/>
|
||||
<path d="M19.1205 19.6758V35.9683H31.3043C32.601 35.9683 33.6521 34.9172 33.6521 33.6204V19.6758H19.1205Z" fill="url(#paint2_linear_3621_1521)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_3621_1521" x1="18" y1="0.03125" x2="18" y2="35.9683" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_3621_1521" x1="18" y1="0.03125" x2="18" y2="35.9683" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_3621_1521" x1="18" y1="0.03125" x2="18" y2="35.9683" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_3621_1521">
|
||||
<rect width="36" height="36" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
14
public/assets/ark-library/media/svg/global-news.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3621_1512)">
|
||||
<path d="M18 0.774414C27.5136 0.774414 35.2258 8.48665 35.2258 18.0002C35.2258 27.5138 27.5136 35.226 18 35.226C8.48643 35.226 0.7742 27.5138 0.7742 18.0002C0.7742 8.48665 8.48643 0.774414 18 0.774414ZM18 26.7099C16.6322 26.7099 15.3182 26.8166 14.0803 27.0119C14.2509 27.5298 14.4352 28.0174 14.6314 28.4715C15.1945 29.7745 15.8249 30.7309 16.4463 31.3392C17.0608 31.9406 17.5838 32.1293 18 32.1293C18.4162 32.1293 18.9393 31.9406 19.5537 31.3392C20.1751 30.7309 20.8056 29.7745 21.3686 28.4715C21.5648 28.0174 21.7488 27.5297 21.9194 27.0119C20.6816 26.8167 19.3676 26.7099 18 26.7099ZM11.0515 27.7083C10.2013 27.9712 9.40957 28.2795 8.687 28.625C9.78854 29.5913 11.0417 30.3885 12.406 30.9774C12.1865 30.5705 11.9803 30.1436 11.7887 29.7001C11.5198 29.0779 11.2738 28.4118 11.0515 27.7083ZM24.9481 27.7083C24.7259 28.4118 24.4802 29.0779 24.2113 29.7001C24.0197 30.1436 23.8132 30.5704 23.5936 30.9774C24.9579 30.3886 26.2107 29.5912 27.3123 28.625C26.5898 28.2796 25.7982 27.9712 24.9481 27.7083ZM3.97796 19.7422C4.27423 22.1518 5.17708 24.3735 6.52924 26.2502C7.66015 25.6342 8.927 25.1133 10.2955 24.7011C9.99458 23.1475 9.79708 21.4797 9.7175 19.7422H3.97796ZM26.2825 19.7422C26.2029 21.4798 26.0051 23.1475 25.7041 24.7011C27.0726 25.1133 28.3395 25.6342 29.4704 26.2502C30.8226 24.3734 31.7258 22.1519 32.0221 19.7422H26.2825ZM12.8181 19.7422C12.8934 21.2508 13.0647 22.681 13.3144 23.9995C14.8099 23.747 16.3831 23.6131 18 23.6131C19.6168 23.6131 21.1899 23.747 22.6852 23.9995C22.935 22.6809 23.1067 21.2508 23.182 19.7422H12.8181ZM29.4704 9.74984C28.3395 10.3658 27.0727 10.8868 25.7041 11.299C26.0274 12.9677 26.2318 14.7681 26.2984 16.6454H32.064C31.8206 14.0864 30.8948 11.7268 29.4704 9.74984ZM22.6852 12.0006C21.1898 12.253 19.6168 12.3873 18 12.3873C16.3831 12.3873 14.8099 12.2531 13.3144 12.0006C13.0434 13.4315 12.8645 14.9939 12.8007 16.6454H23.1994C23.1356 14.9939 22.9563 13.4315 22.6852 12.0006ZM6.52924 9.74984C5.10487 11.7267 4.17945 14.0865 3.93599 16.6454H9.70162C9.76823 14.7682 9.97225 12.9677 10.2955 11.299C8.92698 10.8868 7.66016 10.3659 6.52924 9.74984ZM18 3.87119C17.5838 3.87119 17.0608 4.05984 16.4463 4.66126C15.8249 5.26955 15.1945 6.22597 14.6314 7.52895C14.4353 7.98296 14.2508 8.47042 14.0803 8.98812C15.3182 9.18343 16.6322 9.29054 18 9.29054C19.3677 9.29054 20.6816 9.18339 21.9194 8.98812C21.7488 8.47046 21.5647 7.98293 21.3686 7.52895C20.8056 6.22597 20.1751 5.26955 19.5537 4.66126C18.9393 4.05984 18.4162 3.87119 18 3.87119ZM12.406 5.02265C11.0418 5.6115 9.78886 6.4089 8.68738 7.37509C9.40987 7.72052 10.2014 8.02886 11.0515 8.2918C11.2737 7.58839 11.5199 6.92247 11.7887 6.30037C11.9804 5.85678 12.1864 5.4297 12.406 5.02265ZM23.5936 5.02265C23.8132 5.42975 24.0196 5.85672 24.2113 6.30037C24.4801 6.92244 24.7259 7.58843 24.9481 8.2918C25.7984 8.02884 26.59 7.72056 27.3126 7.37509C26.2111 6.40883 24.958 5.61151 23.5936 5.02265Z" fill="url(#paint0_linear_3621_1512)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_3621_1512" x1="18" y1="0.774414" x2="18" y2="35.226" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_3621_1512">
|
||||
<rect width="36" height="36" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
24
public/assets/ark-library/media/svg/guidelines.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3621_1525)">
|
||||
<path d="M22.4975 6.48898C22.499 6.48834 22.5 6.48686 22.5 6.48522C22.5 6.48226 22.503 6.48029 22.5058 6.4815L28.7543 9.2802C29.0632 9.41857 29.4143 9.42952 29.7312 9.31069L32.7893 8.16417C33.9017 7.7471 33.9018 6.17359 32.7894 5.75644L18.4514 0.379714C18.1604 0.270564 17.8396 0.270564 17.5486 0.379714L3.21027 5.75657C2.09791 6.17371 2.09791 7.74714 3.21027 8.16427L17.5485 13.5411C17.8396 13.6503 18.1604 13.6503 18.4515 13.5411L21.6773 12.3311C22.7706 11.9211 22.7952 10.3836 21.7157 9.93861L18.0031 8.40847C18.0012 8.40768 18 8.40583 18 8.40378C18 8.40174 18.0012 8.39991 18.0031 8.39911L22.4975 6.48898Z" fill="url(#paint0_linear_3621_1525)"/>
|
||||
<path d="M1.73723 9.862C0.89668 9.54674 0 10.1681 0 11.0658V28.5695C0 29.1054 0.332417 29.5851 0.8342 29.7733L15.1378 35.1381C15.9783 35.4534 16.875 34.832 16.875 33.9343V16.4306C16.875 15.8947 16.5426 15.415 16.0408 15.2268L1.73723 9.862ZM9 27.9128C9 28.6976 8.21619 29.2407 7.48141 28.9652L5.2293 28.1207C4.79062 27.9562 4.5 27.5368 4.5 27.0683C4.5 26.2835 5.28381 25.7404 6.01859 26.0159L8.2707 26.8604C8.70938 27.0249 9 27.4443 9 27.9128Z" fill="url(#paint1_linear_3621_1525)"/>
|
||||
<path d="M30.0843 11.4288C29.5825 11.617 29.25 12.0967 29.25 12.6327V16.4869C29.25 17.0229 28.9175 17.5026 28.4157 17.6908L26.4872 18.414C25.6466 18.7292 24.75 18.1078 24.75 17.2101V15.2848C24.75 14.387 23.8532 13.7657 23.0126 14.081L19.9591 15.2267C19.4573 15.415 19.125 15.8946 19.125 16.4305V33.9343C19.125 34.832 20.0217 35.4534 20.8622 35.1381L35.1658 29.7733C35.6676 29.5851 36 29.1054 36 28.5695V11.0657C36 10.168 35.1034 9.54666 34.2628 9.86186L30.0843 11.4288Z" fill="url(#paint2_linear_3621_1525)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_3621_1525" x1="18" y1="0.297852" x2="18" y2="35.2211" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_3621_1525" x1="18" y1="0.297852" x2="18" y2="35.2211" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_3621_1525" x1="18" y1="0.297852" x2="18" y2="35.2211" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_3621_1525">
|
||||
<rect width="36" height="36" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
9
public/assets/ark-library/media/svg/news-record.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M31.7646 1.05859C32.6433 1.05859 33.3523 1.76786 33.3525 2.64648V25.9414C33.3523 26.8201 32.6433 27.5293 31.7646 27.5293H7.94141C6.77684 27.5293 5.82347 28.482 5.82324 29.6465C5.82324 30.8112 6.7767 31.7646 7.94141 31.7646H31.7646C32.6434 31.7646 33.3524 32.4738 33.3525 33.3525C33.3525 34.2314 32.6435 34.9414 31.7646 34.9414H7.94141C5.01905 34.9414 2.64746 32.5688 2.64746 29.6465V6.35254C2.6476 3.4303 5.01914 1.05859 7.94141 1.05859H31.7646ZM10.7627 19.2988C9.943 19.2989 9.27848 19.9635 9.27832 20.7832C9.27832 21.603 9.9429 22.2675 10.7627 22.2676H17.8145C18.6343 22.2676 19.2988 21.6031 19.2988 20.7832C19.2987 19.9635 18.6342 19.2988 17.8145 19.2988H10.7627ZM10.7627 12.9893C9.9429 12.9894 9.27832 13.6548 9.27832 14.4746C9.27853 15.2943 9.94302 15.9589 10.7627 15.959H25.2373C26.057 15.9589 26.7215 15.2943 26.7217 14.4746C26.7217 13.6548 26.0571 12.9894 25.2373 12.9893H10.7627ZM10.7627 6.68066C9.9429 6.68077 9.27832 7.34522 9.27832 8.16504C9.27837 8.98482 9.94293 9.64931 10.7627 9.64941H25.2373C26.0571 9.64931 26.7216 8.98482 26.7217 8.16504C26.7217 7.34522 26.0571 6.68077 25.2373 6.68066H10.7627Z" fill="url(#paint0_linear_3621_1527)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_3621_1527" x1="18" y1="1.05859" x2="18" y2="34.9414" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M29.9812 2.1547V30.6566C29.9812 31.0827 29.7246 31.4673 29.3302 31.6311C28.9195 31.7992 28.4723 31.6937 28.1805 31.4026C28.1032 31.3245 17.9944 23.9924 10.4456 23.3385V10.5535C18.007 9.90033 28.1032 1.48673 28.1805 1.40869C28.4829 1.10705 28.9357 1.01423 29.3302 1.18017C29.7246 1.3433 29.9812 1.72861 29.9812 2.1547Z" fill="url(#paint0_linear_3621_1508)"/>
|
||||
<path d="M35.82 15.6596C35.82 16.9924 33.7147 18.8093 32.2041 19.3083C31.8266 19.433 31.5 19.1173 31.5 18.7196V14.0129C31.5 13.6153 31.8224 13.2929 32.22 13.2929H33.8906C34.56 13.2929 35.82 14.1008 35.82 15.6596Z" fill="url(#paint1_linear_3621_1508)"/>
|
||||
<path d="M16.3724 29.1693C16.5712 28.8974 16.6279 28.5471 16.5248 28.2258C16.2296 27.3009 16.0271 26.4283 15.9177 25.6275C15.204 25.485 14.4889 25.3924 13.7792 25.3875C13.8424 26.0555 13.9468 26.7505 14.1271 27.4925H12.5617C12.3442 26.822 12.1395 26.1204 11.9808 25.3831H6.32812C6.04259 25.3831 5.77259 25.3266 5.49422 25.2988C6.02423 29.4815 7.38654 32.9779 7.45798 33.1574C7.61864 33.558 8.00592 33.8207 8.4375 33.8207H13.7109C14.5306 33.8207 15.0363 32.9244 14.6152 32.2243C14.6019 32.2018 14.0027 31.1616 13.3446 29.602H15.5206C15.8574 29.6019 16.1736 29.4412 16.3724 29.1693Z" fill="url(#paint2_linear_3621_1508)"/>
|
||||
<path d="M6.32812 23.2738H9.05625V10.6175H6.32812C2.83859 10.6175 0 13.4561 0 16.9456C0 20.4352 2.83859 23.2738 6.32812 23.2738Z" fill="url(#paint3_linear_3621_1508)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_3621_1508" x1="17.91" y1="1.09863" x2="17.91" y2="33.8207" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_3621_1508" x1="17.91" y1="1.09863" x2="17.91" y2="33.8207" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_3621_1508" x1="17.91" y1="1.09863" x2="17.91" y2="33.8207" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_3621_1508" x1="17.91" y1="1.09863" x2="17.91" y2="33.8207" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
15
public/assets/ark-library/media/svg/poster.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M29.8695 2.05566H6.1305C3.8836 2.05566 2.05566 3.88366 2.05566 6.13056V29.8695C2.05566 32.1163 3.8836 33.9443 6.1305 33.9443H29.8695C32.1164 33.9443 33.9443 32.1163 33.9443 29.8694V6.13056C33.9443 3.88366 32.1164 2.05566 29.8695 2.05566ZM32.066 19.4864L27.5962 13.9827C27.153 13.4369 26.496 13.1032 25.7937 13.0671C25.0919 13.0316 24.4037 13.2958 23.9069 13.7934L15.3114 22.4022L12.0982 19.7341C11.1665 18.9604 9.82099 18.9684 8.89859 19.7533L3.93392 23.9774V6.13056C3.93392 4.91935 4.91929 3.93398 6.1305 3.93398H29.8695C31.0807 3.93398 32.066 4.91935 32.066 6.13056V19.4864Z" fill="url(#paint0_linear_3621_1514)"/>
|
||||
<path d="M29.5959 31.4077V33.9443H6.40412V31.4077H29.5959ZM31.4077 29.5959V6.40412C31.4077 5.40346 30.5965 4.59226 29.5959 4.59226H6.40412C5.40346 4.59226 4.59226 5.40346 4.59226 6.40412V29.5959C4.59226 30.5965 5.40346 31.4077 6.40412 31.4077V33.9443L6.29194 33.9429C3.97948 33.8844 2.11564 32.0205 2.05708 29.7081L2.05566 29.5959V6.40412C2.05566 4.00253 4.00253 2.05566 6.40412 2.05566H29.5959C31.9975 2.05566 33.9443 4.00253 33.9443 6.40412V29.5959C33.9443 31.96 32.0578 33.8834 29.7081 33.9429L29.5959 33.9443V31.4077C30.5965 31.4077 31.4077 30.5965 31.4077 29.5959Z" fill="url(#paint1_linear_3621_1514)"/>
|
||||
<circle cx="11.5" cy="11.5" r="3.5" fill="#E6C164"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_3621_1514" x1="18" y1="2.05566" x2="18" y2="33.9443" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_3621_1514" x1="18" y1="2.05566" x2="18" y2="33.9443" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
14
public/assets/ark-library/media/svg/project-details.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32.686 5.64883H14.4171C14.0953 5.64883 13.7929 5.52353 13.5653 5.296L11.4998 3.23043C10.8738 2.60451 10.0415 2.25977 9.15637 2.25977H3.31411C1.48669 2.25977 0 3.74652 0 5.57387V10.3227C0.917227 9.61227 2.0667 9.18787 3.31404 9.18787H32.686C33.9333 9.18787 35.0828 9.6122 36 10.3227V8.96294C36 7.12947 34.5161 5.64883 32.686 5.64883Z" fill="url(#paint0_linear_3621_1504)"/>
|
||||
<path d="M32.686 11.2973H3.31404C1.48092 11.2973 0 12.7809 0 14.6114V30.427C0 32.2601 1.48366 33.741 3.31404 33.741H32.6859C34.5193 33.741 35.9999 32.257 35.9999 30.427V14.6114C36 12.778 34.5161 11.2973 32.686 11.2973Z" fill="url(#paint1_linear_3621_1504)"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_3621_1504" x1="18" y1="2.25977" x2="18" y2="33.741" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_3621_1504" x1="18" y1="2.25977" x2="18" y2="33.741" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
14
public/assets/ark-library/media/svg/videos.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3621_1519)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.70812 15.4945L9.23079 13.5003C9.80842 13.3237 9.951 12.5716 9.47808 12.1959C7.90061 10.9425 6.32307 9.68907 4.74557 8.43567C4.55228 8.28209 4.2958 8.23347 4.05972 8.30565L1.53092 9.07878C1.13481 9.19989 0.911866 9.61917 1.03297 10.0153L2.70812 15.4945ZM2.70812 15.4945H33.6103C34.0246 15.4945 34.3603 15.8303 34.3603 16.2445V33.6324C34.3603 34.9347 33.2948 36.0002 31.9924 36.0002H5.07604C3.77371 36.0002 2.70812 34.9346 2.70812 33.6324V15.4945ZM21.1022 26.3968C21.6022 26.1082 21.6021 25.3865 21.1021 25.0978L15.9663 22.1327C15.4663 21.844 14.8413 22.2048 14.8413 22.7822V28.7125C14.8413 29.2899 15.4663 29.6507 15.9663 29.362L21.1022 26.3968ZM13.7525 12.1178C13.5165 12.19 13.26 12.1414 13.0667 11.9878C11.4892 10.7344 9.91164 9.481 8.33415 8.22759C7.86124 7.85184 8.00382 7.09976 8.58144 6.92317L12.7097 5.66106C12.9458 5.58888 13.2023 5.6375 13.3956 5.79108C14.973 7.04449 16.5506 8.29787 18.1281 9.5513C18.601 9.92705 18.4584 10.6791 17.8808 10.8557L13.7525 12.1178ZM21.7167 9.34328C21.91 9.49686 22.1664 9.54548 22.4025 9.4733L27.6779 7.8605C27.7698 7.8324 27.7925 7.71274 27.7172 7.65296L22.0456 3.14649C21.8523 2.99291 21.5958 2.94429 21.3598 3.01647L17.2315 4.27863C16.6539 4.45523 16.5113 5.2073 16.9842 5.58306C18.5617 6.83647 20.1392 8.08988 21.7167 9.34328ZM31.0525 6.82871C30.8164 6.90088 30.56 6.85226 30.3667 6.69869C28.7892 5.44528 27.2117 4.19188 25.6342 2.93847C25.1613 2.56272 25.3039 1.81064 25.8815 1.63405L30.5083 0.219493C30.9044 0.0983899 31.3237 0.32133 31.4448 0.717444L32.9007 5.47941C33.0218 5.87553 32.7988 6.29482 32.4027 6.41592L31.0525 6.82871Z" fill="url(#paint0_linear_3621_1519)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_3621_1519" x1="17.6802" y1="0.186523" x2="17.6802" y2="36.0002" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#E6CD8B"/>
|
||||
<stop offset="1" stop-color="#E7AD25"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_3621_1519">
|
||||
<rect width="36" height="36" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
3
public/assets/ark-library/navbar/document-active.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M45.5346 41.8605C45.5346 43.488 44.8872 45.0508 43.7374 46.2028C42.5854 47.3526 41.0226 48 39.3951 48H9.60438C7.97684 48 6.41405 47.3526 5.26205 46.2028C4.11229 45.0508 3.46484 43.488 3.46484 41.8605V6.13953C3.46484 4.512 4.11229 2.94921 5.26205 1.79721C6.41405 0.647442 7.97684 0 9.60438 0L31.7731 0C32.809 0 33.8025 0.410791 34.5348 1.1453L44.3893 10.9998C45.1238 11.7321 45.5346 12.7256 45.5346 13.7615V41.8605ZM15.0695 39.0698H25.116C26.0403 39.0698 26.7904 38.3196 26.7904 37.3953C26.7904 36.4711 26.0403 35.7209 25.116 35.7209H15.0695C14.1452 35.7209 13.3951 36.4711 13.3951 37.3953C13.3951 38.3196 14.1452 39.0698 15.0695 39.0698ZM15.0695 31.2558H32.93C33.8542 31.2558 34.6044 30.5057 34.6044 29.5814C34.6044 28.6571 33.8542 27.907 32.93 27.907H15.0695C14.1452 27.907 13.3951 28.6571 13.3951 29.5814C13.3951 30.5057 14.1452 31.2558 15.0695 31.2558ZM33.3721 4.71875V10.5091C33.3721 11.4223 34.1103 12.1635 35.0199 12.1635H40.7871L33.3721 4.71875ZM15.0695 23.4419H32.93C33.8542 23.4419 34.6044 22.6917 34.6044 21.7674C34.6044 20.8432 33.8542 20.093 32.93 20.093H15.0695C14.1452 20.093 13.3951 20.8432 13.3951 21.7674C13.3951 22.6917 14.1452 23.4419 15.0695 23.4419Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
7
public/assets/ark-library/navbar/document-inactive.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M45.5346 41.8605C45.5346 43.488 44.8872 45.0508 43.7374 46.2028C42.5854 47.3526 41.0226 48 39.3951 48H9.60438C7.97684 48 6.41405 47.3526 5.26205 46.2028C4.11229 45.0508 3.46484 43.488 3.46484 41.8605V6.13953C3.46484 4.512 4.11229 2.94921 5.26205 1.79721C6.41405 0.647442 7.97684 0 9.60438 0H31.7731C32.809 0 33.8025 0.410791 34.5348 1.1453L44.3893 10.9998C45.1238 11.7321 45.5346 12.7256 45.5346 13.7615V41.8605ZM42.1858 41.8605V13.7615C42.1858 13.6141 42.1277 13.4713 42.0228 13.3663L32.1683 3.51181C32.0633 3.40688 31.9205 3.34884 31.7731 3.34884H9.60438C8.86317 3.34884 8.15545 3.64353 7.6308 4.16595C7.10838 4.6906 6.81368 5.39833 6.81368 6.13953V41.8605C6.81368 42.6017 7.10838 43.3094 7.6308 43.834C8.15545 44.3565 8.86317 44.6512 9.60438 44.6512H39.3951C40.1363 44.6512 40.844 44.3565 41.3687 43.834C41.8911 43.3094 42.1858 42.6017 42.1858 41.8605Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M30.0234 3.79063C30.0234 2.86635 30.7736 2.11621 31.6979 2.11621C32.6221 2.11621 33.3723 2.86635 33.3723 3.79063V11.6046C33.3723 11.9127 33.6223 12.1627 33.9304 12.1627H41.7444C42.6686 12.1627 43.4188 12.9129 43.4188 13.8371C43.4188 14.7614 42.6686 15.5116 41.7444 15.5116H33.9304C31.7715 15.5116 30.0234 13.7635 30.0234 11.6046V3.79063Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.0699 22.3256C14.1456 22.3256 13.3955 21.5755 13.3955 20.6512C13.3955 19.7269 14.1456 18.9768 15.0699 18.9768H32.9304C33.8547 18.9768 34.6048 19.7269 34.6048 20.6512C34.6048 21.5755 33.8547 22.3256 32.9304 22.3256H15.0699Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.0699 30.1396C14.1456 30.1396 13.3955 29.3895 13.3955 28.4652C13.3955 27.5409 14.1456 26.7908 15.0699 26.7908H32.9304C33.8547 26.7908 34.6048 27.5409 34.6048 28.4652C34.6048 29.3895 33.8547 30.1396 32.9304 30.1396H15.0699Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.0699 37.9536C14.1456 37.9536 13.3955 37.2034 13.3955 36.2792C13.3955 35.3549 14.1456 34.6047 15.0699 34.6047H25.1164C26.0407 34.6047 26.7909 35.3549 26.7909 36.2792C26.7909 37.2034 26.0407 37.9536 25.1164 37.9536H15.0699Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
3
public/assets/ark-library/navbar/heart-active.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.38367 8.28389C7.6745 5.72389 10.8178 4.31396 14.2353 4.31396C16.7899 4.31396 19.1293 5.14608 21.1889 6.78699C22.2281 7.61528 23.1697 8.62863 24 9.8114C24.8299 8.62898 25.7719 7.61528 26.8114 6.78699C28.8706 5.14608 31.2101 4.31396 33.7646 4.31396C37.1821 4.31396 40.3258 5.72389 42.6166 8.28389C44.8801 10.814 46.127 14.2704 46.127 18.017C46.127 21.8732 44.7322 25.4031 41.7378 29.126C39.059 32.4562 35.209 35.8368 30.7506 39.7514C29.2282 41.0883 27.5026 42.6036 25.7107 44.2178C25.2374 44.645 24.63 44.8801 24 44.8801C23.3703 44.8801 22.7625 44.645 22.2899 44.2185C20.4981 42.604 18.7714 41.088 17.2484 39.7504C12.7906 35.8365 8.94061 32.4562 6.26185 29.1257C3.2674 25.4031 1.87298 21.8732 1.87298 18.0167C1.87298 14.2704 3.11985 10.814 5.38367 8.28389Z" fill="white" stroke="white" stroke-width="2.4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 920 B |
3
public/assets/ark-library/navbar/heart-inactive.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.38367 8.28389C7.6745 5.72389 10.8178 4.31396 14.2353 4.31396C16.7899 4.31396 19.1293 5.14608 21.1889 6.78699C22.2281 7.61528 23.1697 8.62863 24 9.8114C24.8299 8.62898 25.7719 7.61528 26.8114 6.78699C28.8706 5.14608 31.2101 4.31396 33.7646 4.31396C37.1821 4.31396 40.3258 5.72389 42.6166 8.28389C44.8801 10.814 46.127 14.2704 46.127 18.017C46.127 21.8732 44.7322 25.4031 41.7378 29.126C39.059 32.4562 35.209 35.8368 30.7506 39.7514C29.2282 41.0883 27.5026 42.6036 25.7107 44.2178C25.2374 44.645 24.63 44.8801 24 44.8801C23.3703 44.8801 22.7625 44.645 22.2899 44.2185C20.4981 42.604 18.7714 41.088 17.2484 39.7504C12.7906 35.8365 8.94061 32.4562 6.26185 29.1257C3.2674 25.4031 1.87298 21.8732 1.87298 18.0167C1.87298 14.2704 3.11985 10.814 5.38367 8.28389Z" stroke="white" stroke-width="2.4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 907 B |
10
public/assets/ark-library/navbar/home-active.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3644_75)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M44.3496 13.3879L31.4232 2.70005C27.0689 -0.900018 20.9311 -0.900018 16.5769 2.70005L3.65033 13.3879C1.32142 15.3134 0 18.2329 0 21.2791V37.9462C0 43.3584 4.16196 48 9.6 48H14.4C17.051 48 19.2 45.851 19.2 43.2V35.3947C19.2 32.3527 21.485 30.1409 24 30.1409C26.515 30.1409 28.8 32.3527 28.8 35.3947V43.2C28.8 45.851 30.949 48 33.6 48H38.4C43.8382 48 48 43.3584 48 37.9462V21.2791C48 18.2329 46.6786 15.3134 44.3496 13.3879Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3644_75">
|
||||
<rect width="48" height="48" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 733 B |
10
public/assets/ark-library/navbar/home-inactive.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_3644_72)">
|
||||
<path d="M45.4514 37.2049V21.4231C45.4513 18.902 44.3566 16.504 42.4571 14.9334L30.2173 4.81364C26.5656 1.79467 21.4361 1.79474 17.7845 4.81364L5.54473 14.9334C3.64525 16.5039 2.5505 18.9019 2.55037 21.4231V37.2049C2.55037 41.7058 5.99784 45.4504 10.3656 45.4505H14.9105C16.7167 45.4505 18.1816 43.9864 18.1818 42.1803V34.7893C18.1818 31.2688 20.8533 28.5405 24.0004 28.5403C27.1476 28.5403 29.82 31.2687 29.82 34.7893V42.1803C29.8202 43.9864 31.2851 45.4505 33.0913 45.4505H37.6362C42.0039 45.4503 45.4514 41.7057 45.4514 37.2049ZM48.0008 37.2049C48.0008 42.9533 43.5667 47.9997 37.6362 47.9999H33.0913C29.8773 47.9999 27.2718 45.3942 27.2717 42.1803V34.7893C27.2717 32.5491 25.6158 31.0886 24.0004 31.0886C22.385 31.0889 20.7301 32.5493 20.7301 34.7893V42.1803C20.73 45.3942 18.1244 47.9999 14.9105 47.9999H10.3656C4.43509 47.9998 0.000976562 42.9534 0.000976562 37.2049V21.4231C0.00109717 18.1759 1.40889 15.0449 3.91946 12.969L16.1603 2.84921C20.7545 -0.949061 27.2473 -0.949111 31.8415 2.84921L44.0813 12.969C46.5921 15.0449 48.0007 18.1758 48.0008 21.4231V37.2049Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3644_72">
|
||||
<rect width="48" height="48" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
4
public/assets/ark-library/navbar/profile-active.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M44.0381 42.4465C44.0381 45.2856 41.7365 47.5879 38.8975 47.5881H9.10254C6.26352 47.5879 3.96191 45.2856 3.96191 42.4465C3.96213 35.2546 9.79247 29.4243 16.9844 29.4241H31.0156C38.2075 29.4243 44.0379 35.2546 44.0381 42.4465Z" fill="white"/>
|
||||
<path d="M36.2197 12.5625C36.2197 19.3111 30.7486 24.7822 24 24.7822C17.2514 24.7822 11.7803 19.3111 11.7803 12.5625C11.7803 5.81387 17.2514 0.342773 24 0.342773C30.7486 0.342806 36.2197 5.81389 36.2197 12.5625Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 582 B |
4
public/assets/ark-library/navbar/profile-inactive.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M41.6377 42.4465C41.6375 36.5801 36.882 31.8247 31.0156 31.8245H16.9844C11.118 31.8247 6.36252 36.5801 6.3623 42.4465C6.3623 43.9601 7.589 45.1875 9.10254 45.1877H38.8975C40.411 45.1875 41.6377 43.9601 41.6377 42.4465ZM44.0381 42.4465C44.0381 45.2856 41.7365 47.5879 38.8975 47.5881H9.10254C6.26352 47.5879 3.96191 45.2856 3.96191 42.4465C3.96213 35.2546 9.79247 29.4243 16.9844 29.4241H31.0156C38.2075 29.4243 44.0379 35.2546 44.0381 42.4465Z" fill="white"/>
|
||||
<path d="M33.8193 12.5625C33.8193 7.13938 29.4231 2.7432 24 2.74316C18.5769 2.74316 14.1807 7.13936 14.1807 12.5625C14.1807 17.9856 18.5769 22.3818 24 22.3818C29.4231 22.3818 33.8193 17.9856 33.8193 12.5625ZM36.2197 12.5625C36.2197 19.3111 30.7486 24.7822 24 24.7822C17.2514 24.7822 11.7803 19.3111 11.7803 12.5625C11.7803 5.81387 17.2514 0.342773 24 0.342773C30.7486 0.342806 36.2197 5.81389 36.2197 12.5625Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 998 B |
3
public/assets/ark-library/navbar/update-active.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24 0.799805C36.813 0.799805 47.2002 11.187 47.2002 24C47.2002 36.813 36.813 47.2002 24 47.2002C11.187 47.2002 0.799805 36.813 0.799805 24C0.799805 11.187 11.187 0.799805 24 0.799805ZM24 9C23.3373 9 22.7998 9.53745 22.7998 10.2002V24.2246C22.7999 25.0225 23.098 25.7924 23.6357 26.3818L30.4131 33.8096C30.8597 34.2988 31.6189 34.3331 32.1084 33.8867C32.5979 33.4401 32.633 32.681 32.1865 32.1914L25.4092 24.7637C25.2749 24.6164 25.2003 24.424 25.2002 24.2246V10.2002C25.2002 9.53745 24.6627 9 24 9Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 627 B |
4
public/assets/ark-library/navbar/update-inactive.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M44.7998 24C44.7998 12.5125 35.4875 3.2002 24 3.2002C12.5125 3.2002 3.2002 12.5125 3.2002 24C3.2002 35.4875 12.5125 44.7998 24 44.7998C35.4875 44.7998 44.7998 35.4875 44.7998 24ZM47.2002 24C47.2002 36.813 36.813 47.2002 24 47.2002C11.187 47.2002 0.799805 36.813 0.799805 24C0.799805 11.187 11.187 0.799805 24 0.799805C36.813 0.799805 47.2002 11.187 47.2002 24Z" fill="white"/>
|
||||
<path d="M32.1865 32.1912C32.6331 32.6807 32.5979 33.4398 32.1084 33.8865C31.6189 34.333 30.8598 34.2987 30.4131 33.8093L23.6357 26.3816C23.0979 25.7921 22.7998 25.0224 22.7998 24.2244V10.2C22.7998 9.53721 23.3373 8.99976 24 8.99976C24.6627 8.99976 25.2002 9.53721 25.2002 10.2V24.2244C25.2002 24.4238 25.2748 24.6161 25.4092 24.7634L32.1865 32.1912Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 856 B |
BIN
public/assets/ark-mark.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
public/assets/hero-banner.png
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
public/assets/logo-primary.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
public/hero-design.jpg
Normal file
|
After Width: | Height: | Size: 223 KiB |
50
src/App.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
|
||||
import { I18nProvider } from "./i18n";
|
||||
import { PublicLayout } from "./layouts/PublicLayout";
|
||||
import { Home } from "./pages/Home";
|
||||
import { Browse } from "./pages/Browse";
|
||||
import { CategoryPage } from "./pages/CategoryPage";
|
||||
import { SearchPage } from "./pages/SearchPage";
|
||||
import { FavoritesPage } from "./pages/FavoritesPage";
|
||||
import { ResourceDetail } from "./pages/ResourceDetail";
|
||||
import { WalletPage } from "./pages/WalletPage";
|
||||
import { AboutPage } from "./pages/AboutPage";
|
||||
import { adminUiPrefix } from "./adminPaths";
|
||||
import { AdminRouteTree } from "./adminRouteTree";
|
||||
import { AdminRouterModeProvider } from "./adminRouterMode";
|
||||
|
||||
const adminEnabled = import.meta.env.VITE_DISABLE_ADMIN !== "true";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<I18nProvider>
|
||||
<AdminRouterModeProvider value="absolute">
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route element={<PublicLayout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/browse" element={<Browse />} />
|
||||
<Route path="/category/:slug" element={<CategoryPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/favorites" element={<FavoritesPage />} />
|
||||
<Route path="/resource/:id" element={<ResourceDetail />} />
|
||||
<Route path="/wallet" element={<WalletPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
</Route>
|
||||
|
||||
{adminEnabled ? (
|
||||
AdminRouteTree()
|
||||
) : (
|
||||
<Route
|
||||
path={`${adminUiPrefix}/*`}
|
||||
element={<Navigate to="/" replace />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AdminRouterModeProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
45
src/AppAdminOnly.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||
import { I18nProvider } from "./i18n";
|
||||
import { adminUiPrefix } from "./adminPaths";
|
||||
import { AdminRouterModeProvider } from "./adminRouterMode";
|
||||
import { AdminLayout } from "./layouts/AdminLayout";
|
||||
import { AdminLogin } from "./pages/admin/AdminLogin";
|
||||
import { AdminDashboard } from "./pages/admin/AdminDashboard";
|
||||
import { AdminResources } from "./pages/admin/AdminResources";
|
||||
import { AdminResourceForm } from "./pages/admin/AdminResourceForm";
|
||||
import { AdminSearchLogs } from "./pages/admin/AdminSearchLogs";
|
||||
|
||||
function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-black text-neutral-400">
|
||||
<p className="text-sm">404</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only bundle: mount under `BrowserRouter basename={adminUiPrefix}` with **relative** route paths
|
||||
* so React Router does not choke on a single long absolute path segment.
|
||||
*/
|
||||
export default function AppAdminOnly() {
|
||||
const b = adminUiPrefix;
|
||||
return (
|
||||
<I18nProvider>
|
||||
<AdminRouterModeProvider value="basename">
|
||||
<BrowserRouter basename={b}>
|
||||
<Routes>
|
||||
<Route path="login" element={<AdminLogin />} />
|
||||
<Route path="/" element={<AdminLayout />}>
|
||||
<Route index element={<AdminDashboard />} />
|
||||
<Route path="resources" element={<AdminResources />} />
|
||||
<Route path="resources/new" element={<AdminResourceForm />} />
|
||||
<Route path="resources/:id" element={<AdminResourceForm />} />
|
||||
<Route path="search-logs" element={<AdminSearchLogs />} />
|
||||
</Route>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AdminRouterModeProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
13
src/admin/token.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
const KEY = "ark_admin_token";
|
||||
|
||||
export function getToken() {
|
||||
return localStorage.getItem(KEY) || "";
|
||||
}
|
||||
|
||||
export function setToken(t: string) {
|
||||
localStorage.setItem(KEY, t);
|
||||
}
|
||||
|
||||
export function clearToken() {
|
||||
localStorage.removeItem(KEY);
|
||||
}
|
||||
7
src/admin/useAdminT.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { tLang } from "../i18n";
|
||||
|
||||
/** Admin area always uses 繁體中文, independent of site language. */
|
||||
export function useAdminT() {
|
||||
return useCallback((key: string) => tLang("zh-TW", key), []);
|
||||
}
|
||||
21
src/adminPaths.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Obscured admin UI path for hardened admin-only builds.
|
||||
* Keep in sync with `deploy/nginx-admin-host-8080.conf` on ark-library-backend-admin-1.
|
||||
*/
|
||||
export const ADMIN_UI_SECRET_PREFIX =
|
||||
"/2d7ccf8f4c9af0aaf5c0ef72ddc3f7dca90f44b53df9fd73d7f3ddf82d8b6d3d";
|
||||
|
||||
export const adminOnlyBuild = import.meta.env.VITE_ADMIN_ONLY === "true";
|
||||
|
||||
/** Base path for admin UI (no trailing slash). */
|
||||
function resolveAdminUiPrefix(): string {
|
||||
const raw = import.meta.env.VITE_ADMIN_UI_PREFIX;
|
||||
if (typeof raw === "string" && raw.trim() !== "") {
|
||||
const v = raw.replace(/\/+$/, "");
|
||||
return v.startsWith("/") ? v : `/${v}`;
|
||||
}
|
||||
if (adminOnlyBuild) return ADMIN_UI_SECRET_PREFIX;
|
||||
return "/admin";
|
||||
}
|
||||
|
||||
export const adminUiPrefix = resolveAdminUiPrefix();
|
||||
24
src/adminRouteTree.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Route } from "react-router-dom";
|
||||
import { adminUiPrefix } from "./adminPaths";
|
||||
import { AdminLayout } from "./layouts/AdminLayout";
|
||||
import { AdminLogin } from "./pages/admin/AdminLogin";
|
||||
import { AdminDashboard } from "./pages/admin/AdminDashboard";
|
||||
import { AdminResources } from "./pages/admin/AdminResources";
|
||||
import { AdminResourceForm } from "./pages/admin/AdminResourceForm";
|
||||
import { AdminSearchLogs } from "./pages/admin/AdminSearchLogs";
|
||||
|
||||
/** Shared between full `App` (when admin enabled) and `AppAdminOnly`. */
|
||||
export function AdminRouteTree() {
|
||||
return (
|
||||
<>
|
||||
<Route path={`${adminUiPrefix}/login`} element={<AdminLogin />} />
|
||||
<Route path={adminUiPrefix} element={<AdminLayout />}>
|
||||
<Route index element={<AdminDashboard />} />
|
||||
<Route path="resources" element={<AdminResources />} />
|
||||
<Route path="resources/new" element={<AdminResourceForm />} />
|
||||
<Route path="resources/:id" element={<AdminResourceForm />} />
|
||||
<Route path="search-logs" element={<AdminSearchLogs />} />
|
||||
</Route>
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
src/adminRouterMode.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
/** `basename`: admin-only app under `BrowserRouter basename={adminUiPrefix}`. `absolute`: full site `App`. */
|
||||
export type AdminRouterMode = "basename" | "absolute";
|
||||
|
||||
const AdminRouterModeCtx = createContext<AdminRouterMode>("absolute");
|
||||
|
||||
export const AdminRouterModeProvider = AdminRouterModeCtx.Provider;
|
||||
|
||||
export function useAdminRouterMode(): AdminRouterMode {
|
||||
return useContext(AdminRouterModeCtx);
|
||||
}
|
||||
129
src/api.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
export const apiBase = import.meta.env.VITE_API_URL || "";
|
||||
|
||||
/** Go JSON encodes nil slices as null — normalize before .map() */
|
||||
export function itemsOrEmpty<T>(items: T[] | null | undefined): T[] {
|
||||
return Array.isArray(items) ? items : [];
|
||||
}
|
||||
|
||||
export function assetUrl(path: string | undefined | null) {
|
||||
if (!path) return "";
|
||||
if (path.startsWith("http")) return path;
|
||||
return `${apiBase}${path}`;
|
||||
}
|
||||
|
||||
export async function getJSON<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${apiBase}${path}`);
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function getJSONAuth<T>(path: string, token: string): Promise<T> {
|
||||
const res = await fetch(`${apiBase}${path}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function postJSON<T>(
|
||||
path: string,
|
||||
body: unknown,
|
||||
token?: string,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const res = await fetch(`${apiBase}${path}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/** Best-effort favorite counter sync (anonymous; matches localStorage favorite). */
|
||||
export function postFavoriteDelta(id: string, add: boolean) {
|
||||
return postJSON(`/api/resources/${id}/favorite`, { add }).catch(() => {});
|
||||
}
|
||||
|
||||
export async function putJSON<T>(
|
||||
path: string,
|
||||
body: unknown,
|
||||
token: string,
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${apiBase}${path}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function del(path: string, token: string) {
|
||||
const res = await fetch(`${apiBase}${path}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
}
|
||||
|
||||
export async function uploadFile(
|
||||
file: File,
|
||||
token: string,
|
||||
): Promise<{ url: string }> {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetch(`${apiBase}/api/admin/upload`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: fd,
|
||||
});
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export type Category = {
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
iconKey: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
export type Resource = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
type: string;
|
||||
language: string;
|
||||
categoryId: number;
|
||||
categorySlug: string;
|
||||
categoryName: string;
|
||||
coverImage?: string;
|
||||
fileUrl?: string;
|
||||
previewUrl?: string;
|
||||
externalUrl?: string;
|
||||
bodyText?: string;
|
||||
badgeLabel?: string;
|
||||
isDownloadable: boolean;
|
||||
isRecommended: boolean;
|
||||
publishedAt?: string;
|
||||
updatedAt: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type AdminResource = Resource & {
|
||||
isPublic: boolean;
|
||||
sortOrder: number;
|
||||
status: string;
|
||||
publishedAt?: string;
|
||||
viewCount?: number;
|
||||
downloadCount?: number;
|
||||
};
|
||||
BIN
src/assets/logo-primary.webp
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
17
src/components/ArkLogoMark.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import logoSrc from "../assets/logo-primary.webp?url";
|
||||
|
||||
/** Primary ARK mark — imported so Vite emits a content-hashed URL (avoids stale inline-SVG JS + stable /assets/*.webp CDN cache). */
|
||||
export function ArkLogoMark({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<img
|
||||
src={logoSrc}
|
||||
alt=""
|
||||
className={["object-contain select-none", className]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
}
|
||||
63
src/components/CategoryIcon.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
BookOpen,
|
||||
Calendar,
|
||||
Film,
|
||||
Folder,
|
||||
Gift,
|
||||
Globe2,
|
||||
GraduationCap,
|
||||
Hash,
|
||||
Image as ImageIcon,
|
||||
Megaphone,
|
||||
MessageCircle,
|
||||
Newspaper,
|
||||
Palette,
|
||||
Play,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { categorySvgUrlForSlug } from "../lib/categorySvgSlug";
|
||||
|
||||
const map: Record<string, LucideIcon> = {
|
||||
folder: Folder,
|
||||
calendar: Calendar,
|
||||
megaphone: Megaphone,
|
||||
graduation: GraduationCap,
|
||||
globe: Globe2,
|
||||
image: ImageIcon,
|
||||
chat: MessageCircle,
|
||||
film: Film,
|
||||
gift: Gift,
|
||||
book: BookOpen,
|
||||
palette: Palette,
|
||||
newspaper: Newspaper,
|
||||
play: Play,
|
||||
hash: Hash,
|
||||
};
|
||||
|
||||
export function CategoryIcon({
|
||||
iconKey,
|
||||
categorySlug,
|
||||
className,
|
||||
}: {
|
||||
iconKey: string;
|
||||
/** When set, prefer branded SVG from `public/assets/ark-library/media/svg/`. */
|
||||
categorySlug?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const svgUrl = categorySlug ? categorySvgUrlForSlug(categorySlug) : null;
|
||||
if (svgUrl) {
|
||||
return (
|
||||
<img
|
||||
src={svgUrl}
|
||||
alt=""
|
||||
className={[className, "object-contain pointer-events-none select-none"]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const Icon = map[iconKey] || Folder;
|
||||
return <Icon className={className} />;
|
||||
}
|
||||
37
src/components/FigmaBanner.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
const FIGMA_ASSET_BASE = "/assets/ark-library/figma";
|
||||
|
||||
export const recommendationCoverFallbacks = [
|
||||
`${FIGMA_ASSET_BASE}/recommendation-1.png`,
|
||||
`${FIGMA_ASSET_BASE}/recommendation-2.png`,
|
||||
`${FIGMA_ASSET_BASE}/recommendation-3.png`,
|
||||
`${FIGMA_ASSET_BASE}/recommendation-4.png`,
|
||||
`${FIGMA_ASSET_BASE}/recommendation-5.png`,
|
||||
] as const;
|
||||
|
||||
export function FigmaBanner() {
|
||||
return (
|
||||
<picture className="block overflow-hidden border border-[#2a2a32] bg-black shadow-[0_24px_70px_rgba(0,0,0,0.18)] max-md:-mx-4 max-md:rounded-none max-md:border-x-0 md:rounded-xl">
|
||||
<source
|
||||
media="(max-width: 439px)"
|
||||
srcSet={`${FIGMA_ASSET_BASE}/banner-375.png`}
|
||||
/>
|
||||
<source
|
||||
media="(max-width: 575px)"
|
||||
srcSet={`${FIGMA_ASSET_BASE}/banner-440.png`}
|
||||
/>
|
||||
<source
|
||||
media="(max-width: 767px)"
|
||||
srcSet={`${FIGMA_ASSET_BASE}/banner-576.png`}
|
||||
/>
|
||||
<img
|
||||
src={`${FIGMA_ASSET_BASE}/banner-desktop.png`}
|
||||
alt=""
|
||||
className="h-auto w-full object-cover"
|
||||
width={1280}
|
||||
height={290}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
);
|
||||
}
|
||||
56
src/components/HeroVisual.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArkLogoMark } from "./ArkLogoMark";
|
||||
|
||||
const HERO_JPEG = "/assets/ark-library/media/jpeg/hero.jpg";
|
||||
|
||||
/** Hero: branded JPEG when present; otherwise gold mark + glow (previous behaviour). */
|
||||
export function HeroVisual() {
|
||||
const [usePhoto, setUsePhoto] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const im = new Image();
|
||||
im.onload = () => setUsePhoto(true);
|
||||
im.onerror = () => setUsePhoto(false);
|
||||
im.src = HERO_JPEG;
|
||||
}, []);
|
||||
|
||||
if (usePhoto) {
|
||||
return (
|
||||
<div className="relative flex min-h-[220px] md:min-h-[280px] items-center justify-center md:justify-end">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-3xl">
|
||||
<div className="absolute -right-8 top-1/2 h-[140%] w-[90%] -translate-y-1/2 rounded-full bg-[radial-gradient(ellipse_at_center,rgba(212,175,55,0.18)_0%,transparent_68%)]" />
|
||||
</div>
|
||||
<img
|
||||
src={HERO_JPEG}
|
||||
alt=""
|
||||
className="relative z-10 max-h-[min(340px,52vh)] w-full max-w-lg rounded-2xl object-contain shadow-[0_0_48px_rgba(212,175,55,0.22)]"
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
onError={() => setUsePhoto(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-[220px] md:min-h-[280px] items-center justify-center md:justify-end">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden rounded-3xl">
|
||||
<div className="absolute -right-8 top-1/2 h-[140%] w-[90%] -translate-y-1/2 rounded-full bg-[radial-gradient(ellipse_at_center,rgba(212,175,55,0.22)_0%,transparent_68%)]" />
|
||||
<div className="absolute right-[12%] top-[8%] h-3 w-3 rotate-12 rounded-sm border border-ark-gold/40 bg-ark-gold/10" />
|
||||
<div className="absolute right-[22%] top-[18%] h-2 w-2 -rotate-6 rounded-sm border border-ark-gold/30" />
|
||||
<div className="absolute right-[8%] bottom-[28%] h-2.5 w-2.5 rotate-45 rounded-sm bg-ark-gold/15" />
|
||||
</div>
|
||||
|
||||
<div className="relative flex flex-col items-center">
|
||||
<div className="relative drop-shadow-[0_0_40px_rgba(212,175,55,0.35)]">
|
||||
<ArkLogoMark className="h-36 w-36 md:h-44 md:w-44" />
|
||||
<div className="absolute -bottom-2 left-1/2 h-16 w-28 -translate-x-1/2 rounded-[100%] bg-gradient-to-t from-ark-gold/25 via-ark-gold/08 to-transparent blur-md" />
|
||||
</div>
|
||||
<div
|
||||
className="-mt-1 h-3 w-40 rounded-[100%] bg-gradient-to-r from-transparent via-ark-gold/35 to-transparent shadow-[0_0_24px_rgba(212,175,55,0.25)]"
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
src/components/LatestUpdateRow.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import type { Resource } from "../api";
|
||||
import { CategoryIcon } from "./CategoryIcon";
|
||||
import { useI18n } from "../i18n";
|
||||
import { resourceTypeLabel } from "../resourceTypeLabels";
|
||||
import { formatDateYmd } from "../utils/format";
|
||||
|
||||
const LATEST_CARD_CLASS =
|
||||
"flex min-h-[106px] items-start gap-4 overflow-hidden rounded-xl border border-ark-line bg-ark-panel p-4 outline-none transition hover:border-ark-gold/45 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg md:min-h-[138px] md:p-5";
|
||||
|
||||
export function LatestUpdateRow({
|
||||
r,
|
||||
iconKey,
|
||||
}: {
|
||||
r: Resource;
|
||||
iconKey: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const dateStr = formatDateYmd(r.updatedAt);
|
||||
|
||||
return (
|
||||
<Link to={`/resource/${r.id}`} className={LATEST_CARD_CLASS}>
|
||||
<div className="flex shrink-0 items-center justify-center pt-0.5">
|
||||
<CategoryIcon
|
||||
iconKey={iconKey}
|
||||
categorySlug={r.categorySlug}
|
||||
className="h-10 w-10 text-ark-gold"
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 py-0.5">
|
||||
<div className="text-base font-bold leading-snug text-white line-clamp-2 md:text-lg">
|
||||
{r.title}
|
||||
</div>
|
||||
<div className="mt-4 grid gap-1 text-sm text-[#9b9ca6] md:mt-6">
|
||||
<span>{r.categoryName}</span>
|
||||
<span>
|
||||
{resourceTypeLabel(t, r.type)}
|
||||
<time className="mx-2 text-ark-muted" dateTime={r.updatedAt}>
|
||||
{dateStr}
|
||||
</time>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
const comingSoonIconKeys = ["image", "film", "book", "gift", "folder"];
|
||||
|
||||
export function ComingSoonLatestUpdateRow({ index = 0 }: { index?: number }) {
|
||||
const iconKey = comingSoonIconKeys[index % comingSoonIconKeys.length];
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`${LATEST_CARD_CLASS} cursor-default opacity-95 hover:border-ark-line`}
|
||||
aria-label="即将到来"
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-center pt-0.5">
|
||||
<CategoryIcon iconKey={iconKey} className="h-10 w-10 text-ark-gold" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 py-0.5">
|
||||
<div className="text-base font-bold leading-snug text-white line-clamp-2 md:text-lg">
|
||||
即将到来
|
||||
</div>
|
||||
<div className="mt-4 grid gap-1 text-sm text-[#9b9ca6] md:mt-6">
|
||||
<span>更多内容准备中</span>
|
||||
<span>Coming soon</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
138
src/components/RecommendedCard.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { Resource } from "../api";
|
||||
import { assetUrl, postJSON } from "../api";
|
||||
import { useI18n } from "../i18n";
|
||||
import { useMemo } from "react";
|
||||
import { formatDateYmd } from "../utils/format";
|
||||
import { recommendationCoverFallbacks } from "./FigmaBanner";
|
||||
|
||||
function isPlaceholderAsset(path: string | undefined | null) {
|
||||
return !path || path.includes("placeholder-cover");
|
||||
}
|
||||
|
||||
const CARD_CLASS =
|
||||
"group flex w-[232px] shrink-0 flex-col overflow-hidden rounded-xl border border-ark-line bg-ark-panel transition hover:border-ark-gold/55 max-[439px]:w-[232px] min-[440px]:w-[230px] sm:w-[240px] lg:w-[246.4px]";
|
||||
|
||||
export function RecommendedCard({
|
||||
r,
|
||||
visualIndex = 0,
|
||||
}: {
|
||||
r: Resource;
|
||||
visualIndex?: number;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const cover = useMemo(() => {
|
||||
const original = r.coverImage || r.previewUrl;
|
||||
if (isPlaceholderAsset(original)) {
|
||||
return recommendationCoverFallbacks[
|
||||
visualIndex % recommendationCoverFallbacks.length
|
||||
];
|
||||
}
|
||||
return assetUrl(original);
|
||||
}, [r.coverImage, r.previewUrl, visualIndex]);
|
||||
const dateStr = formatDateYmd(r.updatedAt);
|
||||
|
||||
const dl =
|
||||
r.isDownloadable && (r.fileUrl || r.previewUrl)
|
||||
? assetUrl(r.fileUrl || r.previewUrl)
|
||||
: "";
|
||||
|
||||
return (
|
||||
<article className={CARD_CLASS}>
|
||||
<Link
|
||||
to={`/resource/${r.id}`}
|
||||
className="relative block aspect-[246.4/138.6] overflow-hidden bg-black"
|
||||
>
|
||||
{cover ? (
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-gradient-to-br from-neutral-900 to-neutral-950" />
|
||||
)}
|
||||
{r.badgeLabel ? (
|
||||
<span className="absolute left-3 top-3 rounded-md bg-ark-gold px-2.5 py-1 text-xs font-semibold text-black">
|
||||
{r.badgeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</Link>
|
||||
<div className="flex min-h-[121px] flex-1 flex-col p-4 pt-3">
|
||||
<Link
|
||||
to={`/resource/${r.id}`}
|
||||
className="text-base font-bold leading-snug text-white line-clamp-2 hover:text-ark-gold2"
|
||||
>
|
||||
{r.title}
|
||||
</Link>
|
||||
<div className="mt-auto flex items-center justify-between gap-2 pt-4 text-xs text-ark-muted">
|
||||
<div className="min-w-0 truncate">
|
||||
<span className="text-neutral-400">{r.categoryName}</span>
|
||||
<span className="mx-1.5 text-ark-line">·</span>
|
||||
<time dateTime={r.updatedAt}>{dateStr}</time>
|
||||
</div>
|
||||
{dl ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-lg p-1 text-ark-gold outline-none hover:bg-ark-gold/10 focus-visible:ring-2 focus-visible:ring-ark-gold/80"
|
||||
title={t("download")}
|
||||
aria-label={t("download")}
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await postJSON(`/api/resources/${r.id}/download`, {});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
window.open(dl, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
<Download className="h-5 w-5" strokeWidth={2.2} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function ComingSoonRecommendedCard({
|
||||
visualIndex = 0,
|
||||
}: {
|
||||
visualIndex?: number;
|
||||
}) {
|
||||
const cover =
|
||||
recommendationCoverFallbacks[
|
||||
visualIndex % recommendationCoverFallbacks.length
|
||||
];
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`${CARD_CLASS} cursor-default opacity-95`}
|
||||
aria-label="即将到来"
|
||||
>
|
||||
<div className="relative block aspect-[246.4/138.6] overflow-hidden bg-black">
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
className="h-full w-full object-cover opacity-75 grayscale-[15%]"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="absolute left-3 top-3 rounded-md bg-ark-gold px-2.5 py-1 text-xs font-semibold text-black">
|
||||
即将到来
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-h-[121px] flex-1 flex-col p-4 pt-3">
|
||||
<div className="text-base font-bold leading-snug text-white line-clamp-2">
|
||||
即将到来
|
||||
</div>
|
||||
<div className="mt-auto pt-4 text-xs text-ark-muted">
|
||||
更多内容准备中
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
101
src/components/ResourceCard.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Download, Eye, Heart } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { Resource } from "../api";
|
||||
import { assetUrl, postJSON, postFavoriteDelta } from "../api";
|
||||
import { isFavorite, toggleFavorite } from "../favorites";
|
||||
import { useI18n } from "../i18n";
|
||||
import { resourceTypeLabel } from "../resourceTypeLabels";
|
||||
import { formatDateYmd } from "../utils/format";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export function ResourceCard({
|
||||
r,
|
||||
onFavoriteToggle,
|
||||
}: {
|
||||
r: Resource;
|
||||
onFavoriteToggle?: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [fav, setFav] = useState(() => isFavorite(r.id));
|
||||
const cover = useMemo(
|
||||
() => assetUrl(r.coverImage || r.previewUrl),
|
||||
[r.coverImage, r.previewUrl],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-ark-line bg-ark-panel overflow-hidden flex flex-col">
|
||||
<div className="relative aspect-video bg-black">
|
||||
{cover ? (
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full bg-gradient-to-br from-neutral-900 to-neutral-800" />
|
||||
)}
|
||||
{r.badgeLabel ? (
|
||||
<span className="absolute left-3 top-3 rounded-full bg-ark-gold/90 px-3 py-1 text-xs font-semibold text-black">
|
||||
{r.badgeLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="p-4 flex flex-col gap-2 flex-1">
|
||||
<div className="text-sm text-ark-muted">{r.categoryName}</div>
|
||||
<div className="text-lg font-semibold leading-snug line-clamp-2">
|
||||
{r.title}
|
||||
</div>
|
||||
<div className="text-xs text-ark-muted">
|
||||
{r.type.toUpperCase()} · {new Date(r.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
{r.description ? (
|
||||
<p className="text-sm text-neutral-300 line-clamp-2">
|
||||
{r.description}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-auto flex flex-wrap gap-2 pt-2">
|
||||
<Link
|
||||
to={`/resource/${r.id}`}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-ark-line px-3 py-2 text-sm outline-none hover:border-ark-gold hover:text-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||
>
|
||||
<Eye size={16} /> {t("preview")}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex items-center gap-1 rounded-lg border px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
|
||||
fav
|
||||
? "border-ark-gold text-ark-gold2"
|
||||
: "border-ark-line hover:border-ark-gold"
|
||||
}`}
|
||||
onClick={() => {
|
||||
const on = toggleFavorite(r.id);
|
||||
setFav(on);
|
||||
void postFavoriteDelta(r.id, on);
|
||||
onFavoriteToggle?.();
|
||||
}}
|
||||
>
|
||||
<Heart size={16} /> {t("favorite")}
|
||||
</button>
|
||||
{r.isDownloadable && (r.fileUrl || r.previewUrl) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-ark-gold px-3 py-2 text-sm font-semibold text-black outline-none hover:bg-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold2 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||
onClick={async () => {
|
||||
const u = assetUrl(r.fileUrl || r.previewUrl);
|
||||
try {
|
||||
await postJSON(`/api/resources/${r.id}/download`, {});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
window.open(u, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
<Download size={16} /> {t("download")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
src/components/ResourceListFooter.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
type T = (k: string) => string;
|
||||
|
||||
export function ResourceListFooter({
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
t,
|
||||
onPrev,
|
||||
onNext,
|
||||
}: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
t: T;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
const pages = Math.max(1, Math.ceil(total / limit));
|
||||
const from = total === 0 ? 0 : (page - 1) * limit + 1;
|
||||
const to = Math.min(page * limit, total);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-between gap-3 border-t border-ark-line pt-6 sm:flex-row">
|
||||
<p className="text-sm text-neutral-400">
|
||||
{t("listRange")
|
||||
.replace("{{from}}", String(from))
|
||||
.replace("{{to}}", String(to))
|
||||
.replace("{{total}}", String(total))}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 1}
|
||||
onClick={onPrev}
|
||||
className="rounded-full border border-ark-line px-4 py-2 text-sm text-neutral-200 outline-none transition hover:border-ark-gold disabled:cursor-not-allowed disabled:opacity-40 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||
>
|
||||
{t("paginationPrev")}
|
||||
</button>
|
||||
<span className="text-sm text-ark-muted tabular-nums">
|
||||
{t("pageIndicator")
|
||||
.replace("{{c}}", String(page))
|
||||
.replace("{{p}}", String(pages))}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= pages}
|
||||
onClick={onNext}
|
||||
className="rounded-full border border-ark-line px-4 py-2 text-sm text-neutral-200 outline-none transition hover:border-ark-gold disabled:cursor-not-allowed disabled:opacity-40 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||
>
|
||||
{t("paginationNext")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/components/SectionHeader.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
viewAllTo,
|
||||
viewAllLabel,
|
||||
}: {
|
||||
title: string;
|
||||
viewAllTo: string;
|
||||
viewAllLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-6 items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<span className="h-6 w-1 shrink-0 bg-ark-gold" aria-hidden />
|
||||
<h2 className="truncate text-2xl font-bold leading-6 tracking-tight text-white md:text-2xl">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
to={viewAllTo}
|
||||
className="inline-flex shrink-0 items-center gap-1 text-[15px] font-medium leading-none text-ark-gold hover:text-ark-gold2"
|
||||
>
|
||||
{viewAllLabel}
|
||||
<ChevronRight className="h-4 w-4" strokeWidth={2.7} />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
src/components/WalletLoginControls.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { ConnectButton } from "@rainbow-me/rainbowkit";
|
||||
import { useAccount, useDisconnect, useSignMessage } from "wagmi";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { postJSON, apiBase } from "../api";
|
||||
import {
|
||||
clearWalletToken,
|
||||
getWalletToken,
|
||||
setWalletToken,
|
||||
} from "../walletToken";
|
||||
import { useI18n } from "../i18n";
|
||||
|
||||
export function WalletLoginControls() {
|
||||
const { t } = useI18n();
|
||||
const { address, isConnected } = useAccount();
|
||||
const { disconnectAsync } = useDisconnect();
|
||||
const { signMessageAsync, isPending: signing } = useSignMessage();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [sessionOk, setSessionOk] = useState(false);
|
||||
|
||||
const checkSession = useCallback(async () => {
|
||||
const tok = getWalletToken();
|
||||
if (!tok || !address) {
|
||||
setSessionOk(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`${apiBase}/api/auth/wallet/me`, {
|
||||
headers: { Authorization: `Bearer ${tok}` },
|
||||
});
|
||||
if (!r.ok) {
|
||||
clearWalletToken();
|
||||
setSessionOk(false);
|
||||
return;
|
||||
}
|
||||
const j = (await r.json()) as { wallet: string };
|
||||
setSessionOk(j.wallet?.toLowerCase() === address.toLowerCase());
|
||||
} catch {
|
||||
setSessionOk(false);
|
||||
}
|
||||
}, [address]);
|
||||
|
||||
useEffect(() => {
|
||||
void checkSession();
|
||||
}, [checkSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!address) clearWalletToken();
|
||||
}, [address]);
|
||||
|
||||
const signIn = async () => {
|
||||
if (!address) return;
|
||||
setErr(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const nonceRes = await postJSON<{ message: string }>(
|
||||
"/api/auth/wallet/nonce",
|
||||
{ address },
|
||||
);
|
||||
const sig = await signMessageAsync({ message: nonceRes.message });
|
||||
const out = await postJSON<{ token: string }>("/api/auth/wallet/verify", {
|
||||
address,
|
||||
message: nonceRes.message,
|
||||
signature: sig,
|
||||
});
|
||||
setWalletToken(out.token);
|
||||
setSessionOk(true);
|
||||
} catch (e) {
|
||||
setErr(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
clearWalletToken();
|
||||
setSessionOk(false);
|
||||
await disconnectAsync();
|
||||
};
|
||||
|
||||
if (!import.meta.env.VITE_WALLETCONNECT_PROJECT_ID) {
|
||||
return (
|
||||
<span
|
||||
className="text-xs text-amber-500/90 max-w-[220px] text-right leading-tight"
|
||||
title={t("walletMissingProjectId")}
|
||||
>
|
||||
{t("walletSetupNeeded")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1 min-w-[200px]">
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<ConnectButton chainStatus="icon" showBalance={false} />
|
||||
{isConnected && address ? (
|
||||
sessionOk ? (
|
||||
<span className="text-xs text-ark-gold2 whitespace-nowrap">
|
||||
{t("walletSignedIn")}
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || signing}
|
||||
onClick={() => void signIn()}
|
||||
className="rounded-lg border border-ark-gold bg-ark-gold/10 px-3 py-1.5 text-xs font-medium text-ark-gold2 hover:bg-ark-gold/20 disabled:opacity-50"
|
||||
>
|
||||
{busy || signing ? "…" : t("signInWallet")}
|
||||
</button>
|
||||
)
|
||||
) : null}
|
||||
{isConnected && sessionOk ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void signOut()}
|
||||
className="text-xs text-neutral-500 hover:text-white"
|
||||
>
|
||||
{t("walletLogout")}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{err ? (
|
||||
<p className="text-xs text-red-400 max-w-xs text-right">{err}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
src/favorites.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
const KEY = "ark_favorites";
|
||||
|
||||
export function readFavorites(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (!raw) return [];
|
||||
const v = JSON.parse(raw);
|
||||
return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleFavorite(id: string): boolean {
|
||||
const cur = new Set(readFavorites());
|
||||
if (cur.has(id)) {
|
||||
cur.delete(id);
|
||||
} else {
|
||||
cur.add(id);
|
||||
}
|
||||
const next = [...cur];
|
||||
localStorage.setItem(KEY, JSON.stringify(next));
|
||||
return cur.has(id);
|
||||
}
|
||||
|
||||
export function isFavorite(id: string) {
|
||||
return readFavorites().includes(id);
|
||||
}
|
||||
423
src/i18n.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export type Lang = "zh-TW" | "zh-CN" | "en";
|
||||
|
||||
type Dict = Record<string, string>;
|
||||
|
||||
const dict: Record<Lang, Dict> = {
|
||||
"zh-TW": {
|
||||
brand: "ARK 資料庫",
|
||||
mainNav: "網站導覽",
|
||||
home: "首頁",
|
||||
all: "全部資料",
|
||||
categories: "分類瀏覽",
|
||||
latest: "最新更新",
|
||||
official: "官方推薦",
|
||||
popular: "熱門資料",
|
||||
favorites: "我的收藏",
|
||||
search: "搜尋",
|
||||
searchPlaceholder: "搜尋資料...",
|
||||
searchNow: "立即搜尋資料",
|
||||
viewAll: "查看全部",
|
||||
heroTitle: "ARK 官方資料庫",
|
||||
heroSub:
|
||||
"集中、分類、管理 ARK 資料庫,讓你快速找到所需資源,推動社群共識與成長。",
|
||||
categorySection: "資料分類",
|
||||
officialSection: "官方推薦",
|
||||
latestSection: "最新更新",
|
||||
popularSection: "熱門資料",
|
||||
preview: "預覽",
|
||||
download: "下載",
|
||||
favorite: "收藏",
|
||||
share: "分享",
|
||||
profile: "個人中心",
|
||||
langLabel: "語言",
|
||||
admin: "後台",
|
||||
login: "登入",
|
||||
logout: "登出",
|
||||
email: "電子郵件",
|
||||
password: "密碼",
|
||||
dashboard: "儀表板",
|
||||
resources: "資料管理",
|
||||
newResource: "新增資料",
|
||||
save: "儲存",
|
||||
title: "標題",
|
||||
description: "簡介",
|
||||
type: "類型",
|
||||
language: "語言",
|
||||
category: "分類",
|
||||
status: "狀態",
|
||||
public: "公開",
|
||||
downloadable: "可下載",
|
||||
recommended: "首頁推薦",
|
||||
cover: "封面圖 URL",
|
||||
fileUrl: "檔案 URL",
|
||||
externalUrl: "外部連結",
|
||||
body: "文案內容",
|
||||
badge: "推薦標籤",
|
||||
published: "已發布",
|
||||
draft: "草稿",
|
||||
archived: "封存",
|
||||
noResults: "找不到符合的資料,請換個關鍵字或瀏覽分類。",
|
||||
copyLink: "複製連結",
|
||||
related: "相關資料",
|
||||
total: "總資料",
|
||||
views: "瀏覽",
|
||||
downloads: "下載",
|
||||
wallet: "錢包",
|
||||
walletPageTitle: "錢包登入",
|
||||
walletPageIntro:
|
||||
"連接 Web3 錢包以使用會員相關功能。採用標準簽名登入,不會發送交易、不消耗 gas。",
|
||||
walletStepExtension:
|
||||
"電腦已安裝擴充錢包(如 MetaMask)時,可直接在瀏覽器連線。",
|
||||
walletStepQR:
|
||||
"電腦未安裝錢包時:在連線視窗選擇 WalletConnect,用手機錢包掃描畫面上的 QR Code 即可連線。",
|
||||
walletStepSign:
|
||||
"連線成功後,點「簽署登入」並在錢包內簽署訊息,即完成網站身分驗證。",
|
||||
signInWallet: "簽署登入",
|
||||
walletSignedIn: "已驗證登入",
|
||||
walletLogout: "登出錢包",
|
||||
walletMissingProjectId:
|
||||
"請設定 VITE_WALLETCONNECT_PROJECT_ID(Reown Cloud 免費申請),否則無法使用 WalletConnect/手機掃碼。",
|
||||
walletSetupNeeded: "錢包掃碼未啟用(請於伺服器設定環境變數)",
|
||||
lang_zh_TW: "繁體中文",
|
||||
lang_zh_CN: "简体中文",
|
||||
lang_en: "English",
|
||||
filterAll: "全部",
|
||||
sortPublished: "發布時間",
|
||||
type_ppt: "PPT",
|
||||
type_video: "影片",
|
||||
type_image: "圖片",
|
||||
type_pdf: "PDF",
|
||||
type_link: "連結",
|
||||
type_text: "文字",
|
||||
type_archive: "壓縮檔",
|
||||
type_zip: "ZIP",
|
||||
adminLoginTitle: "管理後台登入",
|
||||
adminEditResource: "編輯資料",
|
||||
adminVideoFileHint:
|
||||
"上傳影片檔(MP4/WebM/MOV 等),類型請選「影片」;儲存後前台會自動播放(預設靜音,可點喇叭開聲音)。",
|
||||
adminStatTodayNew: "今日新增",
|
||||
adminStatFavorites: "收藏",
|
||||
adminMetricDownloads: "下載",
|
||||
adminMetricFavorites: "收藏",
|
||||
adminMetricViews: "瀏覽",
|
||||
edit: "編輯",
|
||||
backToList: "返回列表",
|
||||
sortOrderLabel: "排序權重",
|
||||
previewUrlLabel: "預覽網址",
|
||||
tagsCommaLabel: "標籤(逗號分隔)",
|
||||
uploadFile: "上傳檔案",
|
||||
loading: "載入中…",
|
||||
favoritesEmpty: "尚未加入收藏。",
|
||||
paginationPrev: "上一頁",
|
||||
paginationNext: "下一頁",
|
||||
listRange: "顯示 {{from}}–{{to}},共 {{total}} 筆",
|
||||
pageIndicator: "{{c}} / {{p}} 頁",
|
||||
resourceLangFilter: "資料語言",
|
||||
filterTagClear: "清除標籤",
|
||||
filterLanguageAll: "全部語言",
|
||||
aboutTitle: "關於本站",
|
||||
aboutIntro:
|
||||
"ARK 資料庫彙整官方教材、公告、影片與常用檔案,協助社群快速取得一致版本的可信內容。\n\n本站僅作展示與索引;資料權利仍以官方公告為準。",
|
||||
footerAbout: "關於本站",
|
||||
footerAdminLogin: "管理員登入",
|
||||
adminSearchLogs: "搜尋紀錄",
|
||||
adminMetricShares: "分享",
|
||||
adminSearchQuery: "查詢詞",
|
||||
adminSearchTime: "時間",
|
||||
adminSearchId: "編號",
|
||||
},
|
||||
"zh-CN": {
|
||||
brand: "ARK 数据库",
|
||||
mainNav: "网站导航",
|
||||
home: "首页",
|
||||
all: "全部资料",
|
||||
categories: "分类浏览",
|
||||
latest: "最新更新",
|
||||
official: "官方推荐",
|
||||
popular: "热门资料",
|
||||
favorites: "我的收藏",
|
||||
search: "搜索",
|
||||
searchPlaceholder: "搜索资料...",
|
||||
searchNow: "立即搜索资料",
|
||||
viewAll: "查看全部",
|
||||
heroTitle: "ARK 官方数据库",
|
||||
heroSub:
|
||||
"集中、分类、管理 ARK 数据库,让你快速找到所需资源,推动社群共识与成长。",
|
||||
categorySection: "资料分类",
|
||||
officialSection: "官方推荐",
|
||||
latestSection: "最新更新",
|
||||
popularSection: "热门资料",
|
||||
preview: "预览",
|
||||
download: "下载",
|
||||
favorite: "收藏",
|
||||
share: "分享",
|
||||
profile: "个人中心",
|
||||
langLabel: "语言",
|
||||
admin: "后台",
|
||||
login: "登录",
|
||||
logout: "退出",
|
||||
email: "邮箱",
|
||||
password: "密码",
|
||||
dashboard: "仪表盘",
|
||||
resources: "资料管理",
|
||||
newResource: "新增资料",
|
||||
save: "保存",
|
||||
title: "标题",
|
||||
description: "简介",
|
||||
type: "类型",
|
||||
language: "语言",
|
||||
category: "分类",
|
||||
status: "状态",
|
||||
public: "公开",
|
||||
downloadable: "可下载",
|
||||
recommended: "首页推荐",
|
||||
cover: "封面图 URL",
|
||||
fileUrl: "文件 URL",
|
||||
externalUrl: "外部链接",
|
||||
body: "文案内容",
|
||||
badge: "推荐标签",
|
||||
published: "已发布",
|
||||
draft: "草稿",
|
||||
archived: "归档",
|
||||
noResults: "找不到符合的资料,请换个关键字或浏览分类。",
|
||||
copyLink: "复制链接",
|
||||
related: "相关资料",
|
||||
total: "总资料",
|
||||
views: "浏览",
|
||||
downloads: "下载",
|
||||
wallet: "钱包",
|
||||
walletPageTitle: "钱包登录",
|
||||
walletPageIntro:
|
||||
"连接 Web3 钱包以使用会员相关功能。采用标准签名登录,不发送交易、不消耗 gas。",
|
||||
walletStepExtension:
|
||||
"电脑已安装浏览器扩展钱包(如 MetaMask)时,可直接连接。",
|
||||
walletStepQR:
|
||||
"电脑未安装钱包时:在连接窗口选择 WalletConnect,用手机钱包扫描 QR Code。",
|
||||
walletStepSign: "连接成功后,点击「签署登录」并在钱包内签名即可完成验证。",
|
||||
signInWallet: "签署登录",
|
||||
walletSignedIn: "已验证登录",
|
||||
walletLogout: "退出钱包",
|
||||
walletMissingProjectId:
|
||||
"请配置 VITE_WALLETCONNECT_PROJECT_ID(Reown Cloud),否则无法使用 WalletConnect/扫码。",
|
||||
walletSetupNeeded: "钱包扫码未启用(请在服务器配置环境变量)",
|
||||
lang_zh_TW: "繁体中文",
|
||||
lang_zh_CN: "简体中文",
|
||||
lang_en: "English",
|
||||
filterAll: "全部",
|
||||
sortPublished: "发布时间",
|
||||
type_ppt: "PPT",
|
||||
type_video: "视频",
|
||||
type_image: "图片",
|
||||
type_pdf: "PDF",
|
||||
type_link: "链接",
|
||||
type_text: "文字",
|
||||
type_archive: "压缩包",
|
||||
type_zip: "ZIP",
|
||||
adminLoginTitle: "管理后台登录",
|
||||
adminEditResource: "编辑资料",
|
||||
adminVideoFileHint:
|
||||
"上传视频文件(MP4/WebM/MOV 等),类型请选择「视频」;保存后前台自动播放(默认静音,可点喇叭开声音)。",
|
||||
adminStatTodayNew: "今日新增",
|
||||
adminStatFavorites: "收藏",
|
||||
adminMetricDownloads: "下载",
|
||||
adminMetricFavorites: "收藏",
|
||||
adminMetricViews: "浏览",
|
||||
edit: "编辑",
|
||||
backToList: "返回列表",
|
||||
sortOrderLabel: "排序权重",
|
||||
previewUrlLabel: "预览网址",
|
||||
tagsCommaLabel: "标签(逗号分隔)",
|
||||
uploadFile: "上传文件",
|
||||
loading: "加载中…",
|
||||
favoritesEmpty: "还没有收藏。",
|
||||
paginationPrev: "上一页",
|
||||
paginationNext: "下一页",
|
||||
listRange: "显示 {{from}}–{{to}},共 {{total}} 条",
|
||||
pageIndicator: "{{c}} / {{p}} 页",
|
||||
resourceLangFilter: "资料语言",
|
||||
filterTagClear: "清除标签",
|
||||
filterLanguageAll: "全部语言",
|
||||
aboutTitle: "关于本站",
|
||||
aboutIntro:
|
||||
"ARK 数据库汇总官方教材、公告、视频与常用文件,帮助社区快速获取一致版本的可信内容。\n\n本站仅供展示与索引;权利归属以官方公告为准。",
|
||||
footerAbout: "关于本站",
|
||||
footerAdminLogin: "管理员登录",
|
||||
adminSearchLogs: "搜索记录",
|
||||
adminMetricShares: "分享",
|
||||
adminSearchQuery: "查询词",
|
||||
adminSearchTime: "时间",
|
||||
adminSearchId: "编号",
|
||||
},
|
||||
en: {
|
||||
brand: "ARK Library",
|
||||
mainNav: "Site menu",
|
||||
home: "Home",
|
||||
all: "All assets",
|
||||
categories: "Categories",
|
||||
latest: "Latest",
|
||||
official: "Official picks",
|
||||
popular: "Popular",
|
||||
favorites: "Favorites",
|
||||
search: "Search",
|
||||
searchPlaceholder: "Search resources...",
|
||||
searchNow: "Search now",
|
||||
viewAll: "View all",
|
||||
heroTitle: "ARK Official Library",
|
||||
heroSub:
|
||||
"Centralize, organize, and manage the ARK library so you can find what you need fast and help the community grow together.",
|
||||
categorySection: "Categories",
|
||||
officialSection: "Official recommendations",
|
||||
latestSection: "Latest updates",
|
||||
popularSection: "Popular assets",
|
||||
preview: "Preview",
|
||||
download: "Download",
|
||||
favorite: "Favorite",
|
||||
share: "Share",
|
||||
profile: "Profile",
|
||||
langLabel: "Language",
|
||||
admin: "Admin",
|
||||
login: "Sign in",
|
||||
logout: "Sign out",
|
||||
email: "Email",
|
||||
password: "Password",
|
||||
dashboard: "Dashboard",
|
||||
resources: "Resources",
|
||||
newResource: "New resource",
|
||||
save: "Save",
|
||||
title: "Title",
|
||||
description: "Description",
|
||||
type: "Type",
|
||||
language: "Language",
|
||||
category: "Category",
|
||||
status: "Status",
|
||||
public: "Public",
|
||||
downloadable: "Downloadable",
|
||||
recommended: "Featured",
|
||||
cover: "Cover image URL",
|
||||
fileUrl: "File URL",
|
||||
externalUrl: "External URL",
|
||||
body: "Text body",
|
||||
badge: "Badge label",
|
||||
published: "Published",
|
||||
draft: "Draft",
|
||||
archived: "Archived",
|
||||
noResults: "No results. Try another keyword or browse categories.",
|
||||
copyLink: "Copy link",
|
||||
related: "Related",
|
||||
total: "Total items",
|
||||
views: "Views",
|
||||
downloads: "Downloads",
|
||||
wallet: "Wallet",
|
||||
walletPageTitle: "Wallet sign-in",
|
||||
walletPageIntro:
|
||||
"Connect a Web3 wallet for member features. This uses a standard signed message — no transaction and no gas.",
|
||||
walletStepExtension:
|
||||
"On desktop with a browser extension (e.g. MetaMask), connect directly.",
|
||||
walletStepQR:
|
||||
"On desktop without an extension: choose WalletConnect in the modal and scan the QR code with your mobile wallet.",
|
||||
walletStepSign:
|
||||
'After connecting, tap "Sign in" and approve the message in your wallet to verify.',
|
||||
signInWallet: "Sign in",
|
||||
walletSignedIn: "Signed in",
|
||||
walletLogout: "Disconnect",
|
||||
walletMissingProjectId:
|
||||
"Set VITE_WALLETCONNECT_PROJECT_ID (free on Reown Cloud). Required for WalletConnect / QR login.",
|
||||
walletSetupNeeded: "Wallet QR login disabled (set env on server)",
|
||||
lang_zh_TW: "Traditional Chinese",
|
||||
lang_zh_CN: "Simplified Chinese",
|
||||
lang_en: "English",
|
||||
filterAll: "All types",
|
||||
sortPublished: "Published date",
|
||||
type_ppt: "PPT",
|
||||
type_video: "Video",
|
||||
type_image: "Image",
|
||||
type_pdf: "PDF",
|
||||
type_link: "Link",
|
||||
type_text: "Text",
|
||||
type_archive: "Archive",
|
||||
type_zip: "ZIP",
|
||||
adminLoginTitle: "Admin sign in",
|
||||
adminEditResource: "Edit resource",
|
||||
adminVideoFileHint:
|
||||
"Upload a video file (MP4/WebM/MOV, etc.) and set type to Video; the site will autoplay (muted by default — user can unmute).",
|
||||
adminStatTodayNew: "New today",
|
||||
adminStatFavorites: "Favorites",
|
||||
adminMetricDownloads: "Downloads",
|
||||
adminMetricFavorites: "Favorites",
|
||||
adminMetricViews: "Views",
|
||||
edit: "Edit",
|
||||
backToList: "Back to list",
|
||||
sortOrderLabel: "Sort order",
|
||||
previewUrlLabel: "Preview URL",
|
||||
tagsCommaLabel: "Tags (comma-separated)",
|
||||
uploadFile: "Upload",
|
||||
loading: "Loading…",
|
||||
favoritesEmpty: "No favorites yet.",
|
||||
paginationPrev: "Previous",
|
||||
paginationNext: "Next",
|
||||
listRange: "Showing {{from}}–{{to}} of {{total}}",
|
||||
pageIndicator: "Page {{c}} / {{p}}",
|
||||
resourceLangFilter: "Resource language",
|
||||
filterTagClear: "Clear tag",
|
||||
filterLanguageAll: "All languages",
|
||||
aboutTitle: "About this site",
|
||||
aboutIntro:
|
||||
"The ARK library brings together official decks, announcements, videos, and common files so the community can find consistent, trustworthy versions quickly.\n\nThis site is for discovery and indexing only; rights remain with official notices.",
|
||||
footerAbout: "About",
|
||||
footerAdminLogin: "Admin sign-in",
|
||||
adminSearchLogs: "Search logs",
|
||||
adminMetricShares: "Shares",
|
||||
adminSearchQuery: "Query",
|
||||
adminSearchTime: "Time",
|
||||
adminSearchId: "ID",
|
||||
},
|
||||
};
|
||||
|
||||
/** Fixed locale lookup (for admin UI always in Traditional Chinese). */
|
||||
export function tLang(lang: Lang, key: string): string {
|
||||
return dict[lang][key] || dict["zh-TW"][key] || key;
|
||||
}
|
||||
|
||||
type Ctx = { lang: Lang; setLang: (l: Lang) => void; t: (k: string) => string };
|
||||
|
||||
const I18nCtx = createContext<Ctx | null>(null);
|
||||
|
||||
const LANG_KEY = "ark_lang";
|
||||
|
||||
export function I18nProvider({ children }: { children: React.ReactNode }) {
|
||||
const [lang, setLangState] = useState<Lang>(() => {
|
||||
const s = localStorage.getItem(LANG_KEY) as Lang | null;
|
||||
if (s === "zh-CN" || s === "en" || s === "zh-TW") return s;
|
||||
return "zh-TW";
|
||||
});
|
||||
const setLang = (l: Lang) => {
|
||||
localStorage.setItem(LANG_KEY, l);
|
||||
setLangState(l);
|
||||
};
|
||||
const t = useCallback(
|
||||
(k: string) => dict[lang][k] || dict["zh-TW"][k] || k,
|
||||
[lang],
|
||||
);
|
||||
const v = useMemo(() => ({ lang, setLang, t }), [lang, t]);
|
||||
return <I18nCtx.Provider value={v}>{children}</I18nCtx.Provider>;
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
const v = useContext(I18nCtx);
|
||||
if (!v) throw new Error("I18nProvider missing");
|
||||
return v;
|
||||
}
|
||||
|
||||
export function langQuery(lang: Lang) {
|
||||
if (lang === "zh-TW") return "zh-TW";
|
||||
if (lang === "zh-CN") return "zh-CN";
|
||||
return "en";
|
||||
}
|
||||
43
src/index.css
Normal file
@@ -0,0 +1,43 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-ark-bg text-neutral-100 antialiased;
|
||||
}
|
||||
|
||||
/* Match theme: avoid default blue accent on controls */
|
||||
:root {
|
||||
accent-color: #eeb726;
|
||||
}
|
||||
|
||||
header a,
|
||||
header button {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Desktop header nav: thin scrollbar when links overflow (still 單列) */
|
||||
.header-nav-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(238, 183, 38, 0.45) transparent;
|
||||
}
|
||||
.header-nav-scroll::-webkit-scrollbar {
|
||||
height: 4px;
|
||||
}
|
||||
.header-nav-scroll::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(238, 183, 38, 0.45);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.header-nav-scroll::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.gold-underline {
|
||||
box-shadow: inset 0 -2px 0 #eeb726;
|
||||
}
|
||||
81
src/layouts/AdminLayout.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Link, Navigate, NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { useAdminT } from "../admin/useAdminT";
|
||||
import { clearToken, getToken } from "../admin/token";
|
||||
import { adminUiPrefix } from "../adminPaths";
|
||||
import { useAdminRouterMode } from "../adminRouterMode";
|
||||
|
||||
export function AdminLayout() {
|
||||
const t = useAdminT();
|
||||
const mode = useAdminRouterMode();
|
||||
const nav = useNavigate();
|
||||
const token = getToken();
|
||||
const loginTo = mode === "basename" ? "login" : `${adminUiPrefix}/login`;
|
||||
if (!token) return <Navigate to={loginTo} replace />;
|
||||
|
||||
return (
|
||||
<div className="min-h-full bg-ark-bg">
|
||||
<div className="border-b border-ark-line">
|
||||
<div className="mx-auto max-w-6xl px-4 py-3 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-4">
|
||||
{mode === "absolute" ? (
|
||||
<Link
|
||||
to="/"
|
||||
className="text-sm text-neutral-400 hover:text-white"
|
||||
>
|
||||
← {t("home")}
|
||||
</Link>
|
||||
) : null}
|
||||
<NavLink
|
||||
to={mode === "basename" ? "." : adminUiPrefix}
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
`text-sm ${isActive ? "text-ark-gold2" : "text-neutral-300 hover:text-white"}`
|
||||
}
|
||||
>
|
||||
{t("dashboard")}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to={
|
||||
mode === "basename" ? "resources" : `${adminUiPrefix}/resources`
|
||||
}
|
||||
className={({ isActive }) =>
|
||||
`text-sm ${isActive ? "text-ark-gold2" : "text-neutral-300 hover:text-white"}`
|
||||
}
|
||||
>
|
||||
{t("resources")}
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to={
|
||||
mode === "basename"
|
||||
? "search-logs"
|
||||
: `${adminUiPrefix}/search-logs`
|
||||
}
|
||||
className={({ isActive }) =>
|
||||
`text-sm ${isActive ? "text-ark-gold2" : "text-neutral-300 hover:text-white"}`
|
||||
}
|
||||
>
|
||||
{t("adminSearchLogs")}
|
||||
</NavLink>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-neutral-400 hover:text-white"
|
||||
onClick={() => {
|
||||
clearToken();
|
||||
if (mode === "basename") {
|
||||
nav("login", { replace: true });
|
||||
} else {
|
||||
window.location.href = `${adminUiPrefix}/login`;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("logout")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
388
src/layouts/PublicLayout.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
import { Globe, Menu, Search as SearchIcon, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { ArkLogoMark } from "../components/ArkLogoMark";
|
||||
import { useI18n, type Lang } from "../i18n";
|
||||
import { adminUiPrefix } from "../adminPaths";
|
||||
|
||||
type PublicNavWhich =
|
||||
| "home"
|
||||
| "browseAll"
|
||||
| "categories"
|
||||
| "browseLatest"
|
||||
| "browseRecommended"
|
||||
| "browsePopular"
|
||||
| "favorites"
|
||||
| "wallet"
|
||||
| "about";
|
||||
|
||||
function navIsActive(
|
||||
pathname: string,
|
||||
search: string,
|
||||
hash: string,
|
||||
which: PublicNavWhich,
|
||||
): boolean {
|
||||
const sp = new URLSearchParams(search);
|
||||
switch (which) {
|
||||
case "home":
|
||||
return pathname === "/";
|
||||
case "browseAll":
|
||||
return pathname === "/browse" && !sp.has("sort");
|
||||
case "categories":
|
||||
return pathname === "/" && hash === "#categories";
|
||||
case "browseLatest":
|
||||
return pathname === "/browse" && sp.get("sort") === "latest";
|
||||
case "browseRecommended":
|
||||
return pathname === "/browse" && sp.get("sort") === "recommended";
|
||||
case "browsePopular":
|
||||
return pathname === "/browse" && sp.get("sort") === "popular";
|
||||
case "favorites":
|
||||
return pathname === "/favorites";
|
||||
case "wallet":
|
||||
return pathname === "/wallet";
|
||||
case "about":
|
||||
return pathname === "/about";
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function navClassName(active: boolean) {
|
||||
return [
|
||||
"shrink-0 rounded-sm px-2 py-2 text-[13px] font-medium leading-none whitespace-nowrap no-underline outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-ark-gold/90 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg",
|
||||
active
|
||||
? "text-ark-gold visited:text-ark-gold"
|
||||
: "text-[#d7d7dc] visited:text-[#d7d7dc] hover:text-ark-gold",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export function PublicLayout() {
|
||||
const { t, lang, setLang } = useI18n();
|
||||
const { pathname, search, hash } = useLocation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const nav = useNavigate();
|
||||
|
||||
const na = (which: PublicNavWhich) =>
|
||||
navIsActive(pathname, search, hash, which);
|
||||
|
||||
const goSearch = () => {
|
||||
const s = q.trim();
|
||||
if (!s) return;
|
||||
nav(`/search?q=${encodeURIComponent(s)}`);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-full flex flex-col pb-20 md:pb-0">
|
||||
<header className="sticky top-0 z-40 border-b border-ark-line bg-ark-nav/98 backdrop-blur-md">
|
||||
<div className="mx-auto max-w-[1280px] px-4 py-[15px] md:px-8 xl:px-0">
|
||||
{/* Single row (md+): logo | scrollable nav (左對齊,可橫向滑動) | 搜尋 + 語言 */}
|
||||
<div className="flex h-10 items-center gap-2 lg:gap-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex min-w-0 shrink-0 items-center gap-2.5 rounded-sm text-xl font-bold tracking-wide text-ark-gold outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||
>
|
||||
<ArkLogoMark className="h-10 w-10 shrink-0" />
|
||||
<span className="max-w-[9rem] truncate text-ark-gold sm:inline md:max-w-[10rem] lg:max-w-none">
|
||||
{t("brand")}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<nav
|
||||
className="header-nav-scroll hidden min-w-0 flex-1 items-center justify-center gap-4 overflow-x-auto overflow-y-hidden py-1 md:flex lg:gap-5"
|
||||
aria-label={t("mainNav")}
|
||||
>
|
||||
<Link
|
||||
to="/"
|
||||
className={navClassName(na("home"))}
|
||||
aria-current={na("home") ? "page" : undefined}
|
||||
>
|
||||
{t("home")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/browse"
|
||||
className={navClassName(na("browseAll"))}
|
||||
aria-current={na("browseAll") ? "page" : undefined}
|
||||
>
|
||||
{t("all")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/#categories"
|
||||
className={navClassName(na("categories"))}
|
||||
aria-current={na("categories") ? "page" : undefined}
|
||||
>
|
||||
{t("categories")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/browse?sort=latest"
|
||||
className={navClassName(na("browseLatest"))}
|
||||
aria-current={na("browseLatest") ? "page" : undefined}
|
||||
>
|
||||
{t("latest")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/browse?sort=recommended"
|
||||
className={navClassName(na("browseRecommended"))}
|
||||
aria-current={na("browseRecommended") ? "page" : undefined}
|
||||
>
|
||||
{t("official")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/browse?sort=popular"
|
||||
className={navClassName(na("browsePopular"))}
|
||||
aria-current={na("browsePopular") ? "page" : undefined}
|
||||
>
|
||||
{t("popular")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/favorites"
|
||||
className={navClassName(na("favorites"))}
|
||||
aria-current={na("favorites") ? "page" : undefined}
|
||||
>
|
||||
{t("favorites")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/wallet"
|
||||
className={navClassName(na("wallet"))}
|
||||
aria-current={na("wallet") ? "page" : undefined}
|
||||
>
|
||||
{t("wallet")}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-end gap-2">
|
||||
<div className="hidden h-10 items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] py-2 pl-3 pr-3 shadow-inner md:flex lg:pr-4">
|
||||
<SearchIcon size={16} className="shrink-0 text-[#c6c7cf]" />
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && goSearch()}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="w-24 rounded-md bg-transparent text-sm text-neutral-200 outline-none placeholder:text-[#777985] focus-visible:ring-2 focus-visible:ring-ark-gold/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1a1b20] md:w-28 lg:w-44 xl:w-52"
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden h-10 items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-2 py-2 md:flex lg:px-3">
|
||||
<Globe
|
||||
size={16}
|
||||
className="shrink-0 text-ark-gold/80"
|
||||
aria-hidden
|
||||
/>
|
||||
<select
|
||||
className="max-w-[6.5rem] cursor-pointer truncate rounded-md bg-transparent text-sm text-neutral-200 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/60 focus-visible:ring-offset-2 focus-visible:ring-offset-[#1a1b20] lg:max-w-none"
|
||||
value={lang}
|
||||
onChange={(e) => setLang(e.target.value as Lang)}
|
||||
aria-label={t("langLabel")}
|
||||
>
|
||||
<option value="zh-TW">繁體中文</option>
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="md:hidden inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-ark-line bg-[#1a1b20] text-neutral-200 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label="menu"
|
||||
>
|
||||
{open ? <X size={18} /> : <Menu size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div className="md:hidden border-t border-ark-line bg-ark-nav px-4 py-3 grid gap-2">
|
||||
<div className="mb-1 flex items-center gap-2 rounded-full border border-ark-line bg-[#1a1b20] px-3 py-2">
|
||||
<SearchIcon size={16} className="shrink-0 text-[#c6c7cf]" />
|
||||
<input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && goSearch()}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-[#777985]"
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
to="/"
|
||||
className={navClassName(na("home"))}
|
||||
aria-current={na("home") ? "page" : undefined}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("home")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/browse"
|
||||
className={navClassName(na("browseAll"))}
|
||||
aria-current={na("browseAll") ? "page" : undefined}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("all")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/#categories"
|
||||
className={navClassName(na("categories"))}
|
||||
aria-current={na("categories") ? "page" : undefined}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("categories")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/favorites"
|
||||
className={navClassName(na("favorites"))}
|
||||
aria-current={na("favorites") ? "page" : undefined}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("favorites")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/browse?sort=latest"
|
||||
className={navClassName(na("browseLatest"))}
|
||||
aria-current={na("browseLatest") ? "page" : undefined}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("latest")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/browse?sort=recommended"
|
||||
className={navClassName(na("browseRecommended"))}
|
||||
aria-current={na("browseRecommended") ? "page" : undefined}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("official")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/browse?sort=popular"
|
||||
className={navClassName(na("browsePopular"))}
|
||||
aria-current={na("browsePopular") ? "page" : undefined}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("popular")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/wallet"
|
||||
className={navClassName(na("wallet"))}
|
||||
aria-current={na("wallet") ? "page" : undefined}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("wallet")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
className={navClassName(na("about"))}
|
||||
aria-current={na("about") ? "page" : undefined}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("footerAbout")}
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<main className="mx-auto w-full max-w-[1280px] flex-1 px-4 py-6 md:px-8 md:py-10 xl:px-0">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<footer className="mt-auto border-t border-ark-line bg-ark-nav/90 mb-20 md:mb-0">
|
||||
<div className="mx-auto flex max-w-[1280px] flex-wrap gap-x-6 gap-y-2 px-4 py-6 text-sm text-neutral-400 md:px-8 xl:px-0">
|
||||
<Link
|
||||
to="/about"
|
||||
className="rounded-sm outline-none hover:text-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||
>
|
||||
{t("footerAbout")}
|
||||
</Link>
|
||||
<Link
|
||||
to="/wallet"
|
||||
className="rounded-sm outline-none hover:text-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||
>
|
||||
{t("profile")}
|
||||
</Link>
|
||||
{import.meta.env.VITE_DISABLE_ADMIN !== "true" ? (
|
||||
<Link
|
||||
to={`${adminUiPrefix}/login`}
|
||||
className="rounded-sm outline-none hover:text-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||
>
|
||||
{t("footerAdminLogin")}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<nav className="md:hidden fixed bottom-0 inset-x-0 z-40 border-t border-ark-line bg-ark-nav/95 backdrop-blur">
|
||||
<div className="grid grid-cols-4 gap-1 px-1 py-2 text-center text-[11px]">
|
||||
<BottomNavIcon
|
||||
to="/"
|
||||
label={t("home")}
|
||||
icon="home"
|
||||
active={pathname === "/"}
|
||||
/>
|
||||
<BottomNavIcon
|
||||
to="/browse"
|
||||
label={t("all")}
|
||||
icon="document"
|
||||
active={
|
||||
pathname === "/browse" && !new URLSearchParams(search).get("sort")
|
||||
}
|
||||
/>
|
||||
<BottomNavIcon
|
||||
to="/favorites"
|
||||
label={t("favorites")}
|
||||
icon="heart"
|
||||
active={pathname === "/favorites"}
|
||||
/>
|
||||
<BottomNavIcon
|
||||
to="/browse?sort=latest"
|
||||
label={t("latest")}
|
||||
icon="update"
|
||||
active={
|
||||
pathname === "/browse" &&
|
||||
new URLSearchParams(search).get("sort") === "latest"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const NAVBAR_ICON_BASE = "/assets/ark-library/navbar";
|
||||
|
||||
function BottomNavIcon({
|
||||
to,
|
||||
label,
|
||||
icon,
|
||||
active,
|
||||
}: {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: "home" | "document" | "heart" | "update";
|
||||
active: boolean;
|
||||
}) {
|
||||
const src = active
|
||||
? `${NAVBAR_ICON_BASE}/${icon}-active.svg`
|
||||
: `${NAVBAR_ICON_BASE}/${icon}-inactive.svg`;
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={[
|
||||
"flex flex-col items-center gap-1 rounded-lg py-1 outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg",
|
||||
active ? "text-ark-gold" : "text-neutral-400",
|
||||
].join(" ")}
|
||||
>
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className={[
|
||||
"mx-auto h-7 w-7 object-contain",
|
||||
active ? "opacity-100" : "opacity-55",
|
||||
].join(" ")}
|
||||
width={28}
|
||||
height={28}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
<span className="leading-tight">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
23
src/lib/categorySvgSlug.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/** Basename under `/assets/ark-library/media/svg/` for each category slug (matches ark-library-media/svg). */
|
||||
const slugToSvg: Record<string, string> = {
|
||||
"project-ppt": "project-details.svg",
|
||||
"daily-class": "everyday-class.svg",
|
||||
"official-announcement": "official-announcement.svg",
|
||||
"academy-materials": "educational-clips.svg",
|
||||
"global-evangelism": "global-news.svg",
|
||||
"daily-poster": "poster.svg",
|
||||
"community-tweets": "community.svg",
|
||||
"video-hub": "videos.svg",
|
||||
"subsidy-policy": "gift.svg",
|
||||
"how-to": "guidelines.svg",
|
||||
"official-assets": "directory.svg",
|
||||
"media-coverage": "news-record.svg",
|
||||
"academy-video": "educational-clips.svg",
|
||||
general: "general.svg",
|
||||
};
|
||||
|
||||
export function categorySvgUrlForSlug(slug: string): string | null {
|
||||
const file = slugToSvg[slug];
|
||||
if (!file) return null;
|
||||
return `/assets/ark-library/media/svg/${file}`;
|
||||
}
|
||||
46
src/main.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { WagmiProvider } from "wagmi";
|
||||
import { RainbowKitProvider, darkTheme } from "@rainbow-me/rainbowkit";
|
||||
import "./index.css";
|
||||
import "@rainbow-me/rainbowkit/styles.css";
|
||||
import { wagmiConfig } from "./wagmiConfig";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const adminOnly = import.meta.env.VITE_ADMIN_ONLY === "true";
|
||||
|
||||
void (async () => {
|
||||
const root = document.getElementById("root")!;
|
||||
|
||||
if (adminOnly) {
|
||||
const { default: AppAdminOnly } = await import("./AppAdminOnly");
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<AppAdminOnly />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { default: App } = await import("./App");
|
||||
ReactDOM.createRoot(root).render(
|
||||
<React.StrictMode>
|
||||
<WagmiProvider config={wagmiConfig}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RainbowKitProvider
|
||||
theme={darkTheme({
|
||||
accentColor: "#d4af37",
|
||||
accentColorForeground: "#0a0a0a",
|
||||
borderRadius: "medium",
|
||||
})}
|
||||
modalSize="wide"
|
||||
>
|
||||
<App />
|
||||
</RainbowKitProvider>
|
||||
</QueryClientProvider>
|
||||
</WagmiProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
})();
|
||||
13
src/pages/AboutPage.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { useI18n } from "../i18n";
|
||||
|
||||
export function AboutPage() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<h1 className="text-2xl font-bold">{t("aboutTitle")}</h1>
|
||||
<p className="text-neutral-300 leading-relaxed whitespace-pre-line">
|
||||
{t("aboutIntro")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
194
src/pages/Browse.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { getJSON, itemsOrEmpty, type Resource } from "../api";
|
||||
import { ResourceCard } from "../components/ResourceCard";
|
||||
import { ResourceListFooter } from "../components/ResourceListFooter";
|
||||
import { useI18n } from "../i18n";
|
||||
import { typeFilterLabel } from "../resourceTypeLabels";
|
||||
|
||||
const types = [
|
||||
"all",
|
||||
"image",
|
||||
"video",
|
||||
"ppt",
|
||||
"pdf",
|
||||
"text",
|
||||
"link",
|
||||
"archive",
|
||||
] as const;
|
||||
const resourceLangCodes = ["", "zh-TW", "zh-CN", "en"] as const;
|
||||
|
||||
function resourceLangLabel(t: (k: string) => string, code: string) {
|
||||
if (!code) return t("filterLanguageAll");
|
||||
if (code === "zh-TW") return t("lang_zh_TW");
|
||||
if (code === "zh-CN") return t("lang_zh_CN");
|
||||
return t("lang_en");
|
||||
}
|
||||
|
||||
export function Browse() {
|
||||
const { t, lang } = useI18n();
|
||||
const [sp, setSp] = useSearchParams();
|
||||
const sort = sp.get("sort") || "latest";
|
||||
const type = sp.get("type") || "all";
|
||||
const tag = (sp.get("tag") || "").trim();
|
||||
const resourceLang = sp.get("language") || "";
|
||||
const page = Math.max(1, parseInt(sp.get("page") || "1", 10) || 1);
|
||||
const limit = 24;
|
||||
|
||||
const [items, setItems] = useState<Resource[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const query = useMemo(() => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("lang", lang);
|
||||
p.set("sort", sort);
|
||||
p.set("limit", String(limit));
|
||||
p.set("page", String(page));
|
||||
if (type && type !== "all") p.set("type", type);
|
||||
if (resourceLang) p.set("language", resourceLang);
|
||||
if (tag) p.set("tag", tag);
|
||||
return p.toString();
|
||||
}, [lang, sort, type, resourceLang, tag, page]);
|
||||
|
||||
useEffect(() => {
|
||||
setErr(null);
|
||||
getJSON<{ items: Resource[]; total?: number }>(`/api/resources?${query}`)
|
||||
.then((r) => {
|
||||
setItems(itemsOrEmpty(r.items));
|
||||
setTotal(typeof r.total === "number" ? r.total : 0);
|
||||
})
|
||||
.catch((e) => setErr(String(e)));
|
||||
}, [query]);
|
||||
|
||||
const setPage = (next: number) => {
|
||||
const n = new URLSearchParams(sp);
|
||||
if (next <= 1) n.delete("page");
|
||||
else n.set("page", String(next));
|
||||
setSp(n);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<h1 className="text-2xl font-bold">{t("all")}</h1>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(
|
||||
[
|
||||
["latest", t("latest")],
|
||||
["recommended", t("official")],
|
||||
["popular", t("popular")],
|
||||
["published", t("sortPublished")],
|
||||
] as const
|
||||
).map(([k, label]) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const n = new URLSearchParams(sp);
|
||||
n.set("sort", k);
|
||||
n.delete("page");
|
||||
setSp(n);
|
||||
}}
|
||||
className={`rounded-full border px-3 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
|
||||
sort === k
|
||||
? "border-ark-gold text-ark-gold2"
|
||||
: "border-ark-line"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{types.map((tp) => (
|
||||
<button
|
||||
key={tp}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const n = new URLSearchParams(sp);
|
||||
n.delete("page");
|
||||
if (tp === "all") n.delete("type");
|
||||
else n.set("type", tp);
|
||||
setSp(n);
|
||||
}}
|
||||
className={`rounded-full border px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
|
||||
type === tp || (tp === "all" && !sp.get("type"))
|
||||
? "border-ark-gold text-ark-gold2"
|
||||
: "border-ark-line"
|
||||
}`}
|
||||
>
|
||||
{typeFilterLabel(t, tp)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-neutral-500">
|
||||
{t("resourceLangFilter")}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{resourceLangCodes.map((code) => (
|
||||
<button
|
||||
key={code || "all"}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const n = new URLSearchParams(sp);
|
||||
n.delete("page");
|
||||
if (!code) n.delete("language");
|
||||
else n.set("language", code);
|
||||
setSp(n);
|
||||
}}
|
||||
className={`rounded-full border px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
|
||||
(code === "" && !resourceLang) || resourceLang === code
|
||||
? "border-ark-gold text-ark-gold2"
|
||||
: "border-ark-line"
|
||||
}`}
|
||||
>
|
||||
{resourceLangLabel(t, code)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tag ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-neutral-400">
|
||||
{t("search")}: <span className="text-ark-gold2">{tag}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const n = new URLSearchParams(sp);
|
||||
n.delete("tag");
|
||||
n.delete("page");
|
||||
setSp(n);
|
||||
}}
|
||||
className="rounded-full border border-ark-line px-3 py-1 text-xs text-neutral-200 outline-none hover:border-ark-gold focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||
>
|
||||
{t("filterTagClear")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{err ? <div className="text-red-300">{err}</div> : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((r) => (
|
||||
<ResourceCard key={r.id} r={r} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ResourceListFooter
|
||||
page={page}
|
||||
limit={limit}
|
||||
total={total}
|
||||
t={t}
|
||||
onPrev={() => setPage(page - 1)}
|
||||
onNext={() => setPage(page + 1)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
src/pages/CategoryPage.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { getJSON, itemsOrEmpty, type Category, type Resource } from "../api";
|
||||
import { ResourceCard } from "../components/ResourceCard";
|
||||
import { ResourceListFooter } from "../components/ResourceListFooter";
|
||||
import { useI18n } from "../i18n";
|
||||
import { typeFilterLabel } from "../resourceTypeLabels";
|
||||
|
||||
const TYPE_FILTERS = [
|
||||
"all",
|
||||
"image",
|
||||
"video",
|
||||
"ppt",
|
||||
"pdf",
|
||||
"text",
|
||||
"link",
|
||||
"archive",
|
||||
] as const;
|
||||
const resourceLangCodes = ["", "zh-TW", "zh-CN", "en"] as const;
|
||||
|
||||
function resourceLangLabel(t: (k: string) => string, code: string) {
|
||||
if (!code) return t("filterLanguageAll");
|
||||
if (code === "zh-TW") return t("lang_zh_TW");
|
||||
if (code === "zh-CN") return t("lang_zh_CN");
|
||||
return t("lang_en");
|
||||
}
|
||||
|
||||
export function CategoryPage() {
|
||||
const { slug } = useParams();
|
||||
const { t, lang } = useI18n();
|
||||
const [sp, setSp] = useSearchParams();
|
||||
const type = sp.get("type") || "all";
|
||||
const resourceLang = sp.get("language") || "";
|
||||
const page = Math.max(1, parseInt(sp.get("page") || "1", 10) || 1);
|
||||
const limit = 24;
|
||||
|
||||
const [items, setItems] = useState<Resource[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [categoryTitle, setCategoryTitle] = useState<string>("");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const query = useMemo(() => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("lang", lang);
|
||||
p.set("category", slug || "");
|
||||
p.set("limit", String(limit));
|
||||
p.set("page", String(page));
|
||||
if (type !== "all") p.set("type", type);
|
||||
if (resourceLang) p.set("language", resourceLang);
|
||||
return p.toString();
|
||||
}, [lang, slug, type, resourceLang, page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
setErr(null);
|
||||
getJSON<{ items: Resource[]; total?: number }>(`/api/resources?${query}`)
|
||||
.then((r) => {
|
||||
setItems(itemsOrEmpty(r.items));
|
||||
setTotal(typeof r.total === "number" ? r.total : 0);
|
||||
})
|
||||
.catch((e) => setErr(String(e)));
|
||||
}, [query, slug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
const langQ = encodeURIComponent(lang);
|
||||
getJSON<Category[]>(`/api/categories?lang=${langQ}`)
|
||||
.then((cats) => {
|
||||
const c = itemsOrEmpty(cats).find((x) => x.slug === slug);
|
||||
setCategoryTitle(c?.name ?? slug);
|
||||
})
|
||||
.catch(() => setCategoryTitle(slug));
|
||||
}, [slug, lang]);
|
||||
|
||||
const setPage = (next: number) => {
|
||||
const n = new URLSearchParams(sp);
|
||||
if (next <= 1) n.delete("page");
|
||||
else n.set("page", String(next));
|
||||
setSp(n);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">{categoryTitle || slug}</h1>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TYPE_FILTERS.map((tp) => (
|
||||
<button
|
||||
key={tp}
|
||||
type="button"
|
||||
className={`rounded-full border px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
|
||||
type === tp || (tp === "all" && !sp.get("type"))
|
||||
? "border-ark-gold text-ark-gold2"
|
||||
: "border-ark-line"
|
||||
}`}
|
||||
onClick={() => {
|
||||
const n = new URLSearchParams(sp);
|
||||
n.delete("page");
|
||||
if (tp === "all") n.delete("type");
|
||||
else n.set("type", tp);
|
||||
setSp(n);
|
||||
}}
|
||||
>
|
||||
{typeFilterLabel(t, tp)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-neutral-500">
|
||||
{t("resourceLangFilter")}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{resourceLangCodes.map((code) => (
|
||||
<button
|
||||
key={code || "all"}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const n = new URLSearchParams(sp);
|
||||
n.delete("page");
|
||||
if (!code) n.delete("language");
|
||||
else n.set("language", code);
|
||||
setSp(n);
|
||||
}}
|
||||
className={`rounded-full border px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
|
||||
(code === "" && !resourceLang) || resourceLang === code
|
||||
? "border-ark-gold text-ark-gold2"
|
||||
: "border-ark-line"
|
||||
}`}
|
||||
>
|
||||
{resourceLangLabel(t, code)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{err ? <div className="text-red-300">{err}</div> : null}
|
||||
{!err && items.length === 0 ? (
|
||||
<p className="text-neutral-400">{t("noResults")}</p>
|
||||
) : null}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((r) => (
|
||||
<ResourceCard key={r.id} r={r} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ResourceListFooter
|
||||
page={page}
|
||||
limit={limit}
|
||||
total={total}
|
||||
t={t}
|
||||
onPrev={() => setPage(page - 1)}
|
||||
onNext={() => setPage(page + 1)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/pages/FavoritesPage.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getJSON, type Resource } from "../api";
|
||||
import { ResourceCard } from "../components/ResourceCard";
|
||||
import { readFavorites } from "../favorites";
|
||||
import { useI18n } from "../i18n";
|
||||
|
||||
export function FavoritesPage() {
|
||||
const { t, lang } = useI18n();
|
||||
const [items, setItems] = useState<Resource[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const ids = readFavorites();
|
||||
const out: Resource[] = [];
|
||||
for (const id of ids) {
|
||||
try {
|
||||
const r = await getJSON<Resource>(
|
||||
`/api/resources/${id}?lang=${encodeURIComponent(lang)}`,
|
||||
);
|
||||
out.push(r);
|
||||
} catch {
|
||||
// ignore missing
|
||||
}
|
||||
}
|
||||
if (!cancelled) setItems(out);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [lang]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">{t("favorites")}</h1>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-neutral-400">{t("favoritesEmpty")}</p>
|
||||
) : null}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((r) => (
|
||||
<ResourceCard key={r.id} r={r} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
src/pages/Home.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getJSON, itemsOrEmpty, type Category, type Resource } from "../api";
|
||||
import { CategoryIcon } from "../components/CategoryIcon";
|
||||
import { FigmaBanner } from "../components/FigmaBanner";
|
||||
import {
|
||||
ComingSoonLatestUpdateRow,
|
||||
LatestUpdateRow,
|
||||
} from "../components/LatestUpdateRow";
|
||||
import {
|
||||
ComingSoonRecommendedCard,
|
||||
RecommendedCard,
|
||||
} from "../components/RecommendedCard";
|
||||
import { SectionHeader } from "../components/SectionHeader";
|
||||
import { useI18n } from "../i18n";
|
||||
import { categoryCardLines } from "../utils/categoryDisplay";
|
||||
|
||||
export function Home() {
|
||||
const { t, lang } = useI18n();
|
||||
const [cats, setCats] = useState<Category[]>([]);
|
||||
const [rec, setRec] = useState<Resource[]>([]);
|
||||
const [latest, setLatest] = useState<Resource[]>([]);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const recRowRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const q = `?lang=${encodeURIComponent(lang)}`;
|
||||
Promise.all([
|
||||
getJSON<Category[]>(`/api/categories${q}`),
|
||||
getJSON<{ items: Resource[] }>(`/api/resources/recommended${q}&limit=12`),
|
||||
getJSON<{ items: Resource[] }>(`/api/resources/latest${q}&limit=8`),
|
||||
])
|
||||
.then(([c, r, l]) => {
|
||||
setCats(itemsOrEmpty(c));
|
||||
setRec(itemsOrEmpty(r.items));
|
||||
setLatest(itemsOrEmpty(l.items));
|
||||
})
|
||||
.catch((e) => setErr(String(e)));
|
||||
}, [lang]);
|
||||
|
||||
const iconKeyForResource = (r: Resource) =>
|
||||
cats.find((c) => c.id === r.categoryId)?.iconKey ?? "folder";
|
||||
|
||||
const scrollRec = (dir: 1 | -1) => {
|
||||
recRowRef.current?.scrollBy({ left: dir * 280, behavior: "smooth" });
|
||||
};
|
||||
|
||||
const recommendedPlaceholderCount = Math.max(0, 5 - rec.length);
|
||||
const latestPlaceholderCount = Math.max(0, 5 - latest.length);
|
||||
|
||||
if (err) {
|
||||
return (
|
||||
<div className="mt-6 rounded-xl border border-red-900 bg-red-950/40 p-4 text-red-200 md:mt-0">
|
||||
{err}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-12 pb-10 md:space-y-14 md:pb-16">
|
||||
<section className="-mt-6 md:mt-0">
|
||||
<FigmaBanner />
|
||||
</section>
|
||||
|
||||
<section id="categories" className="scroll-mt-24">
|
||||
<SectionHeader
|
||||
title={t("categorySection")}
|
||||
viewAllTo="/browse"
|
||||
viewAllLabel={t("viewAll")}
|
||||
/>
|
||||
<div className="mt-7 grid grid-cols-3 gap-3 min-[440px]:gap-3.5 md:grid-cols-5 md:gap-3 xl:grid-cols-7 xl:gap-4">
|
||||
{cats.map((c) => {
|
||||
const { line1, line2 } = categoryCardLines(c.name);
|
||||
return (
|
||||
<Link
|
||||
key={c.id}
|
||||
to={`/category/${c.slug}`}
|
||||
className="group flex min-h-[111px] min-w-0 flex-col items-center justify-center gap-3 rounded-xl border border-ark-line bg-ark-panel px-2.5 py-4 text-center transition hover:border-ark-gold/55 hover:shadow-[0_0_0_1px_rgba(238,183,38,0.12)] md:min-h-24 md:flex-row md:justify-start md:gap-4 md:px-5 md:text-left"
|
||||
>
|
||||
<CategoryIcon
|
||||
iconKey={c.iconKey}
|
||||
categorySlug={c.slug}
|
||||
className="h-9 w-9 shrink-0 text-ark-gold md:h-9 md:w-9"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[15px] font-bold leading-snug text-white line-clamp-2 md:text-sm">
|
||||
{line1}
|
||||
</div>
|
||||
{line2 ? (
|
||||
<div className="mt-0.5 text-[15px] font-bold leading-snug text-white line-clamp-2 md:text-sm">
|
||||
{line2}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
title={t("officialSection")}
|
||||
viewAllTo="/browse?sort=recommended"
|
||||
viewAllLabel={t("viewAll")}
|
||||
/>
|
||||
<div className="relative mt-7">
|
||||
<div
|
||||
ref={recRowRef}
|
||||
className="flex gap-3 overflow-x-auto pb-5 pr-0 scroll-smooth snap-x snap-mandatory [-ms-overflow-style:none] [scrollbar-width:none] md:gap-4 [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
{rec.map((r, index) => (
|
||||
<div key={r.id} className="snap-start">
|
||||
<RecommendedCard r={r} visualIndex={index} />
|
||||
</div>
|
||||
))}
|
||||
{Array.from({ length: recommendedPlaceholderCount }).map(
|
||||
(_, index) => (
|
||||
<div
|
||||
key={`recommended-coming-soon-${index}`}
|
||||
className="snap-start"
|
||||
>
|
||||
<ComingSoonRecommendedCard visualIndex={rec.length + index} />
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1 rounded-full bg-black/80 md:hidden">
|
||||
<div className="h-full w-24 rounded-full bg-[#353740]" />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollRec(1)}
|
||||
className="absolute right-0 top-[45%] hidden h-9 w-9 -translate-y-1/2 items-center justify-center rounded-lg border border-ark-line bg-[#292a31]/95 text-neutral-200 shadow-lg backdrop-blur transition hover:border-ark-gold hover:text-ark-gold md:flex"
|
||||
aria-label={t("viewAll")}
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<SectionHeader
|
||||
title={t("latestSection")}
|
||||
viewAllTo="/browse?sort=latest"
|
||||
viewAllLabel={t("viewAll")}
|
||||
/>
|
||||
<div className="mt-7 grid gap-3 min-[576px]:grid-cols-2 md:gap-4 lg:grid-cols-3 xl:grid-cols-5">
|
||||
{latest.map((r) => (
|
||||
<LatestUpdateRow key={r.id} r={r} iconKey={iconKeyForResource(r)} />
|
||||
))}
|
||||
{Array.from({ length: latestPlaceholderCount }).map((_, index) => (
|
||||
<ComingSoonLatestUpdateRow
|
||||
key={`latest-coming-soon-${index}`}
|
||||
index={latest.length + index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
229
src/pages/ResourceDetail.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
import { Copy, Download, Share2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import {
|
||||
assetUrl,
|
||||
getJSON,
|
||||
itemsOrEmpty,
|
||||
postJSON,
|
||||
postFavoriteDelta,
|
||||
type Resource,
|
||||
} from "../api";
|
||||
import {
|
||||
resourceLanguageLabel,
|
||||
resourceTypeLabel,
|
||||
} from "../resourceTypeLabels";
|
||||
import { ResourceCard } from "../components/ResourceCard";
|
||||
import { isFavorite, toggleFavorite } from "../favorites";
|
||||
import { useI18n } from "../i18n";
|
||||
import { isLikelyVideoPath } from "../video";
|
||||
|
||||
export function ResourceDetail() {
|
||||
const { id } = useParams();
|
||||
const { t, lang } = useI18n();
|
||||
const [r, setR] = useState<Resource | null>(null);
|
||||
const [rel, setRel] = useState<Resource[]>([]);
|
||||
const [fav, setFav] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
setErr(null);
|
||||
setFav(isFavorite(id));
|
||||
postJSON(`/api/resources/${id}/view`, {}).catch(() => {});
|
||||
getJSON<Resource>(`/api/resources/${id}?lang=${encodeURIComponent(lang)}`)
|
||||
.then(setR)
|
||||
.catch((e) => setErr(String(e)));
|
||||
getJSON<{ items: Resource[] }>(
|
||||
`/api/resources/${id}/related?lang=${encodeURIComponent(lang)}`,
|
||||
)
|
||||
.then((x) => setRel(itemsOrEmpty(x.items)))
|
||||
.catch(() => setRel([]));
|
||||
}, [id, lang]);
|
||||
|
||||
const share = async () => {
|
||||
if (!r) return;
|
||||
const url = window.location.href;
|
||||
try {
|
||||
await postJSON(`/api/resources/${r.id}/share`, {});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title: r.title, text: r.description, url });
|
||||
return;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
await navigator.clipboard.writeText(url);
|
||||
alert(t("copyLink"));
|
||||
};
|
||||
|
||||
const download = async () => {
|
||||
if (!r) return;
|
||||
try {
|
||||
await postJSON(`/api/resources/${r.id}/download`, {});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const u = assetUrl(r.fileUrl || r.previewUrl);
|
||||
if (u) window.open(u, "_blank");
|
||||
};
|
||||
|
||||
if (err) return <div className="text-red-300">{err}</div>;
|
||||
if (!r) return <div className="text-neutral-400">…</div>;
|
||||
|
||||
const cover = assetUrl(r.coverImage || r.previewUrl);
|
||||
const rawVideo = r.fileUrl || r.previewUrl || "";
|
||||
const showVideo =
|
||||
!!rawVideo && (r.type === "video" || isLikelyVideoPath(rawVideo));
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="rounded-3xl border border-ark-line bg-black overflow-hidden">
|
||||
{showVideo ? (
|
||||
<video
|
||||
key={rawVideo}
|
||||
className="w-full aspect-video bg-black"
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
poster={r.coverImage ? assetUrl(r.coverImage) : undefined}
|
||||
autoPlay
|
||||
muted
|
||||
src={assetUrl(rawVideo)}
|
||||
/>
|
||||
) : r.type === "image" ? (
|
||||
<img
|
||||
src={cover}
|
||||
alt=""
|
||||
className="w-full object-contain max-h-[520px]"
|
||||
/>
|
||||
) : r.previewUrl && r.previewUrl.endsWith(".pdf") ? (
|
||||
<iframe
|
||||
title="pdf"
|
||||
className="h-[520px] w-full"
|
||||
src={assetUrl(r.previewUrl)}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-6 text-neutral-300">
|
||||
{r.bodyText ? (
|
||||
<pre className="whitespace-pre-wrap font-sans">
|
||||
{r.bodyText}
|
||||
</pre>
|
||||
) : (
|
||||
<p>{r.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-ark-muted">
|
||||
<Link
|
||||
className="rounded-sm outline-none hover:text-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||
to={`/category/${r.categorySlug}`}
|
||||
>
|
||||
{r.categoryName}
|
||||
</Link>{" "}
|
||||
· {resourceTypeLabel(t, r.type)} ·{" "}
|
||||
{resourceLanguageLabel(t, r.language)}
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold leading-tight">{r.title}</h1>
|
||||
{r.description ? (
|
||||
<p className="text-neutral-300 leading-relaxed">{r.description}</p>
|
||||
) : null}
|
||||
{r.externalUrl ? (
|
||||
<a
|
||||
className="text-ark-gold2 underline"
|
||||
href={r.externalUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{r.externalUrl}
|
||||
</a>
|
||||
) : null}
|
||||
{r.tags && r.tags.length ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{r.tags.map((x) => (
|
||||
<Link
|
||||
key={x}
|
||||
to={`/browse?tag=${encodeURIComponent(x)}`}
|
||||
className="rounded-full border border-ark-line px-3 py-1 text-xs text-neutral-300 outline-none transition hover:border-ark-gold/60 hover:text-ark-gold2 focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg"
|
||||
>
|
||||
{x}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex items-center gap-2 rounded-xl border px-4 py-2 text-sm ${
|
||||
fav ? "border-ark-gold text-ark-gold2" : "border-ark-line"
|
||||
}`}
|
||||
onClick={() => {
|
||||
const on = toggleFavorite(r.id);
|
||||
setFav(on);
|
||||
void postFavoriteDelta(r.id, on);
|
||||
}}
|
||||
>
|
||||
{t("favorite")}
|
||||
</button>
|
||||
{r.isDownloadable ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={download}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-ark-gold px-4 py-2 text-sm font-semibold text-black"
|
||||
>
|
||||
<Download size={16} />
|
||||
{t("download")}
|
||||
</button>
|
||||
) : (
|
||||
<div className="text-sm text-neutral-400">
|
||||
此資料目前僅支持在線預覽,暫不提供下載。
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={share}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-ark-line px-4 py-2 text-sm"
|
||||
>
|
||||
<Share2 size={16} />
|
||||
{t("share")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await postJSON(`/api/resources/${r.id}/share`, {});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await navigator.clipboard.writeText(window.location.href);
|
||||
alert(t("copyLink"));
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-ark-line px-4 py-2 text-sm"
|
||||
>
|
||||
<Copy size={16} />
|
||||
{t("copyLink")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-xl font-semibold">{t("related")}</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{rel.map((x) => (
|
||||
<ResourceCard key={x.id} r={x} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
191
src/pages/SearchPage.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { getJSON, itemsOrEmpty, postJSON, type Resource } from "../api";
|
||||
import { ResourceCard } from "../components/ResourceCard";
|
||||
import { ResourceListFooter } from "../components/ResourceListFooter";
|
||||
import { useI18n } from "../i18n";
|
||||
import { typeFilterLabel } from "../resourceTypeLabels";
|
||||
|
||||
const types = [
|
||||
"all",
|
||||
"image",
|
||||
"video",
|
||||
"ppt",
|
||||
"pdf",
|
||||
"text",
|
||||
"link",
|
||||
"archive",
|
||||
] as const;
|
||||
const resourceLangCodes = ["", "zh-TW", "zh-CN", "en"] as const;
|
||||
|
||||
function resourceLangLabel(t: (k: string) => string, code: string) {
|
||||
if (!code) return t("filterLanguageAll");
|
||||
if (code === "zh-TW") return t("lang_zh_TW");
|
||||
if (code === "zh-CN") return t("lang_zh_CN");
|
||||
return t("lang_en");
|
||||
}
|
||||
|
||||
export function SearchPage() {
|
||||
const { t, lang } = useI18n();
|
||||
const [sp, setSp] = useSearchParams();
|
||||
const q = sp.get("q") || "";
|
||||
const sort = sp.get("sort") || "latest";
|
||||
const type = sp.get("type") || "all";
|
||||
const resourceLang = sp.get("language") || "";
|
||||
const page = Math.max(1, parseInt(sp.get("page") || "1", 10) || 1);
|
||||
const limit = 24;
|
||||
|
||||
const [items, setItems] = useState<Resource[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const query = useMemo(() => {
|
||||
const p = new URLSearchParams();
|
||||
p.set("lang", lang);
|
||||
p.set("limit", String(limit));
|
||||
p.set("page", String(page));
|
||||
p.set("sort", sort);
|
||||
if (q) p.set("q", q);
|
||||
if (type && type !== "all") p.set("type", type);
|
||||
if (resourceLang) p.set("language", resourceLang);
|
||||
return p.toString();
|
||||
}, [lang, q, sort, type, resourceLang, page]);
|
||||
|
||||
useEffect(() => {
|
||||
setErr(null);
|
||||
if (!q) {
|
||||
setItems([]);
|
||||
setTotal(0);
|
||||
return;
|
||||
}
|
||||
postJSON("/api/search-log", { query: q }).catch(() => {});
|
||||
getJSON<{ items: Resource[]; total?: number }>(`/api/resources?${query}`)
|
||||
.then((r) => {
|
||||
setItems(itemsOrEmpty(r.items));
|
||||
setTotal(typeof r.total === "number" ? r.total : 0);
|
||||
})
|
||||
.catch((e) => setErr(String(e)));
|
||||
}, [query, q]);
|
||||
|
||||
const setPage = (next: number) => {
|
||||
const n = new URLSearchParams(sp);
|
||||
if (next <= 1) n.delete("page");
|
||||
else n.set("page", String(next));
|
||||
setSp(n);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{t("search")}: {q || "—"}
|
||||
</h1>
|
||||
|
||||
{q ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(
|
||||
[
|
||||
["latest", t("latest")],
|
||||
["recommended", t("official")],
|
||||
["popular", t("popular")],
|
||||
["published", t("sortPublished")],
|
||||
] as const
|
||||
).map(([k, label]) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const n = new URLSearchParams(sp);
|
||||
n.set("sort", k);
|
||||
n.delete("page");
|
||||
setSp(n);
|
||||
}}
|
||||
className={`rounded-full border px-3 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
|
||||
sort === k
|
||||
? "border-ark-gold text-ark-gold2"
|
||||
: "border-ark-line"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{types.map((tp) => (
|
||||
<button
|
||||
key={tp}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const n = new URLSearchParams(sp);
|
||||
n.delete("page");
|
||||
if (tp === "all") n.delete("type");
|
||||
else n.set("type", tp);
|
||||
setSp(n);
|
||||
}}
|
||||
className={`rounded-full border px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
|
||||
type === tp || (tp === "all" && !sp.get("type"))
|
||||
? "border-ark-gold text-ark-gold2"
|
||||
: "border-ark-line"
|
||||
}`}
|
||||
>
|
||||
{typeFilterLabel(t, tp)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-neutral-500">
|
||||
{t("resourceLangFilter")}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{resourceLangCodes.map((code) => (
|
||||
<button
|
||||
key={code || "all"}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const n = new URLSearchParams(sp);
|
||||
n.delete("page");
|
||||
if (!code) n.delete("language");
|
||||
else n.set("language", code);
|
||||
setSp(n);
|
||||
}}
|
||||
className={`rounded-full border px-3 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ark-gold/80 focus-visible:ring-offset-2 focus-visible:ring-offset-ark-bg ${
|
||||
(code === "" && !resourceLang) || resourceLang === code
|
||||
? "border-ark-gold text-ark-gold2"
|
||||
: "border-ark-line"
|
||||
}`}
|
||||
>
|
||||
{resourceLangLabel(t, code)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{err ? <div className="text-red-300">{err}</div> : null}
|
||||
{!q ? <p className="text-neutral-400">{t("noResults")}</p> : null}
|
||||
{q && items.length === 0 && !err ? (
|
||||
<p className="text-neutral-400">{t("noResults")}</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((r) => (
|
||||
<ResourceCard key={r.id} r={r} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{q ? (
|
||||
<ResourceListFooter
|
||||
page={page}
|
||||
limit={limit}
|
||||
total={total}
|
||||
t={t}
|
||||
onPrev={() => setPage(page - 1)}
|
||||
onNext={() => setPage(page + 1)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
23
src/pages/WalletPage.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { WalletLoginControls } from "../components/WalletLoginControls";
|
||||
import { useI18n } from "../i18n";
|
||||
|
||||
export function WalletPage() {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-lg space-y-6">
|
||||
<h1 className="text-2xl font-bold">{t("walletPageTitle")}</h1>
|
||||
<p className="text-neutral-300 text-sm leading-relaxed">
|
||||
{t("walletPageIntro")}
|
||||
</p>
|
||||
<ul className="text-sm text-neutral-400 space-y-2 list-disc pl-5">
|
||||
<li>{t("walletStepExtension")}</li>
|
||||
<li>{t("walletStepQR")}</li>
|
||||
<li>{t("walletStepSign")}</li>
|
||||
</ul>
|
||||
<div className="rounded-2xl border border-ark-line bg-ark-panel p-6 space-y-4">
|
||||
<WalletLoginControls />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
src/pages/admin/AdminDashboard.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getJSONAuth } from "../../api";
|
||||
import { getToken } from "../../admin/token";
|
||||
import { useAdminT } from "../../admin/useAdminT";
|
||||
|
||||
type Dash = {
|
||||
totalResources: number;
|
||||
published: number;
|
||||
todayNew: number;
|
||||
totalViews: number;
|
||||
totalDownloads: number;
|
||||
totalFavorites: number;
|
||||
totalShares: number;
|
||||
hotResources: {
|
||||
id: string;
|
||||
title: string;
|
||||
downloads: number;
|
||||
favorites: number;
|
||||
views: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
export function AdminDashboard() {
|
||||
const t = useAdminT();
|
||||
const [d, setD] = useState<Dash | null>(null);
|
||||
const token = getToken();
|
||||
|
||||
useEffect(() => {
|
||||
getJSONAuth<Dash>("/api/admin/dashboard", token)
|
||||
.then(setD)
|
||||
.catch(() => setD(null));
|
||||
}, [token]);
|
||||
|
||||
if (!d) return <div className="text-neutral-400">{t("loading")}</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">{t("dashboard")}</h1>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<Stat label={t("total")} value={d.totalResources} />
|
||||
<Stat label={t("published")} value={d.published} />
|
||||
<Stat label={t("adminStatTodayNew")} value={d.todayNew} />
|
||||
<Stat label={t("views")} value={d.totalViews} />
|
||||
<Stat label={t("downloads")} value={d.totalDownloads} />
|
||||
<Stat label={t("adminStatFavorites")} value={d.totalFavorites} />
|
||||
<Stat label={t("adminMetricShares")} value={d.totalShares} />
|
||||
</div>
|
||||
<div className="rounded-2xl border border-ark-line bg-ark-panel p-4">
|
||||
<div className="font-semibold mb-3">{t("popular")}</div>
|
||||
<div className="divide-y divide-ark-line">
|
||||
{(d.hotResources ?? []).map((x) => (
|
||||
<div
|
||||
key={x.id}
|
||||
className="py-2 flex items-center justify-between gap-3"
|
||||
>
|
||||
<div className="text-sm">{x.title}</div>
|
||||
<div className="text-xs text-neutral-400">
|
||||
{t("adminMetricDownloads")} {x.downloads} ·{" "}
|
||||
{t("adminMetricFavorites")} {x.favorites} ·{" "}
|
||||
{t("adminMetricViews")} {x.views}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-ark-line bg-ark-panel p-4">
|
||||
<div className="text-xs text-neutral-400">{label}</div>
|
||||
<div className="text-2xl font-bold mt-1">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
src/pages/admin/AdminLogin.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { postJSON } from "../../api";
|
||||
import { setToken } from "../../admin/token";
|
||||
import { useAdminT } from "../../admin/useAdminT";
|
||||
import { useAdminRouterMode } from "../../adminRouterMode";
|
||||
import { adminUiPrefix } from "../../adminPaths";
|
||||
|
||||
export function AdminLogin() {
|
||||
const t = useAdminT();
|
||||
const mode = useAdminRouterMode();
|
||||
const nav = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="min-h-[70vh] flex items-center justify-center">
|
||||
<form
|
||||
className="w-full max-w-md space-y-4 rounded-3xl border border-ark-line bg-ark-panel p-8"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await postJSON<{ token: string }>("/api/admin/login", {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
setToken(r.token);
|
||||
nav(mode === "basename" ? "/" : adminUiPrefix);
|
||||
} catch (e) {
|
||||
setErr(String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<h1 className="text-2xl font-bold">{t("adminLoginTitle")}</h1>
|
||||
<label className="block text-sm text-neutral-300">
|
||||
{t("email")}
|
||||
<input
|
||||
className="mt-1 w-full rounded-xl border border-ark-line bg-black px-3 py-2"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-neutral-300">
|
||||
{t("password")}
|
||||
<input
|
||||
type="password"
|
||||
className="mt-1 w-full rounded-xl border border-ark-line bg-black px-3 py-2"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{err ? <div className="text-sm text-red-300">{err}</div> : null}
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-xl bg-ark-gold py-3 font-semibold text-black"
|
||||
>
|
||||
{t("login")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
405
src/pages/admin/AdminResourceForm.tsx
Normal file
@@ -0,0 +1,405 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
getJSON,
|
||||
getJSONAuth,
|
||||
postJSON,
|
||||
putJSON,
|
||||
uploadFile,
|
||||
type Category,
|
||||
} from "../../api";
|
||||
import { getToken } from "../../admin/token";
|
||||
import { useAdminT } from "../../admin/useAdminT";
|
||||
import { resourceTypeDisplay } from "../../resourceTypeLabels";
|
||||
import { adminUiPrefix } from "../../adminPaths";
|
||||
import { useAdminRouterMode } from "../../adminRouterMode";
|
||||
|
||||
const types = [
|
||||
"image",
|
||||
"video",
|
||||
"ppt",
|
||||
"pdf",
|
||||
"text",
|
||||
"link",
|
||||
"archive",
|
||||
] as const;
|
||||
|
||||
export function AdminResourceForm() {
|
||||
const { id } = useParams();
|
||||
const isNew = !id || id === "new";
|
||||
const t = useAdminT();
|
||||
const mode = useAdminRouterMode();
|
||||
const token = getToken();
|
||||
const nav = useNavigate();
|
||||
|
||||
const [cats, setCats] = useState<Category[]>([]);
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [rtype, setRtype] = useState<string>("image");
|
||||
const [language, setLanguage] = useState("zh-TW");
|
||||
const [categoryId, setCategoryId] = useState(1);
|
||||
const [coverImage, setCoverImage] = useState("");
|
||||
const [fileUrl, setFileUrl] = useState("");
|
||||
const [previewUrl, setPreviewUrl] = useState("");
|
||||
const [externalUrl, setExternalUrl] = useState("");
|
||||
const [bodyText, setBodyText] = useState("");
|
||||
const [badgeLabel, setBadgeLabel] = useState("");
|
||||
const [isPublic, setIsPublic] = useState(true);
|
||||
const [isDownloadable, setIsDownloadable] = useState(true);
|
||||
const [isRecommended, setIsRecommended] = useState(false);
|
||||
const [sortOrder, setSortOrder] = useState(0);
|
||||
const [status, setStatus] = useState("published");
|
||||
const [tags, setTags] = useState("");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getJSON<Category[]>("/api/categories?lang=zh-TW")
|
||||
.then(setCats)
|
||||
.catch(() => setCats([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew || !id) return;
|
||||
getJSONAuth<any>(`/api/admin/resources/${id}`, token)
|
||||
.then((r) => {
|
||||
setTitle(r.title || "");
|
||||
setDescription(r.description || "");
|
||||
setRtype(r.type || "image");
|
||||
setLanguage(r.language || "zh-TW");
|
||||
setCategoryId(r.categoryId || 1);
|
||||
setCoverImage(r.coverImage || "");
|
||||
setFileUrl(r.fileUrl || "");
|
||||
setPreviewUrl(r.previewUrl || "");
|
||||
setExternalUrl(r.externalUrl || "");
|
||||
setBodyText(r.bodyText || "");
|
||||
setBadgeLabel(r.badgeLabel || "");
|
||||
setIsPublic(!!r.isPublic);
|
||||
setIsDownloadable(!!r.isDownloadable);
|
||||
setIsRecommended(!!r.isRecommended);
|
||||
setSortOrder(r.sortOrder || 0);
|
||||
setStatus(r.status || "draft");
|
||||
setTags((r.tags || []).join(", "));
|
||||
})
|
||||
.catch((e) => setErr(String(e)));
|
||||
}, [id, isNew, token]);
|
||||
|
||||
const payload = useMemo(
|
||||
() => ({
|
||||
title,
|
||||
description,
|
||||
type: rtype,
|
||||
language,
|
||||
categoryId,
|
||||
coverImage,
|
||||
fileUrl,
|
||||
previewUrl,
|
||||
externalUrl,
|
||||
bodyText,
|
||||
badgeLabel,
|
||||
isPublic,
|
||||
isDownloadable,
|
||||
isRecommended,
|
||||
sortOrder,
|
||||
status,
|
||||
tags: tags
|
||||
.split(",")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean),
|
||||
}),
|
||||
[
|
||||
title,
|
||||
description,
|
||||
rtype,
|
||||
language,
|
||||
categoryId,
|
||||
coverImage,
|
||||
fileUrl,
|
||||
previewUrl,
|
||||
externalUrl,
|
||||
bodyText,
|
||||
badgeLabel,
|
||||
isPublic,
|
||||
isDownloadable,
|
||||
isRecommended,
|
||||
sortOrder,
|
||||
status,
|
||||
tags,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{isNew ? t("newResource") : t("adminEditResource")}
|
||||
</h1>
|
||||
<Link
|
||||
to={mode === "basename" ? ".." : `${adminUiPrefix}/resources`}
|
||||
className="text-sm text-neutral-400 hover:text-white"
|
||||
>
|
||||
← {t("backToList")}
|
||||
</Link>
|
||||
</div>
|
||||
{err ? <div className="text-red-300">{err}</div> : null}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Field label={t("title")}>
|
||||
<input
|
||||
className="input"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("category")}>
|
||||
<select
|
||||
className="input"
|
||||
value={categoryId}
|
||||
onChange={(e) => setCategoryId(Number(e.target.value))}
|
||||
>
|
||||
{cats.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("type")}>
|
||||
<select
|
||||
className="input"
|
||||
value={rtype}
|
||||
onChange={(e) => setRtype(e.target.value)}
|
||||
>
|
||||
{types.map((x) => (
|
||||
<option key={x} value={x}>
|
||||
{resourceTypeDisplay(t, x)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("language")}>
|
||||
<select
|
||||
className="input"
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
>
|
||||
<option value="zh-TW">{t("lang_zh_TW")}</option>
|
||||
<option value="zh-CN">{t("lang_zh_CN")}</option>
|
||||
<option value="en">{t("lang_en")}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("status")}>
|
||||
<select
|
||||
className="input"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
<option value="draft">{t("draft")}</option>
|
||||
<option value="published">{t("published")}</option>
|
||||
<option value="archived">{t("archived")}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("sortOrderLabel")}>
|
||||
<input
|
||||
className="input"
|
||||
type="number"
|
||||
value={sortOrder}
|
||||
onChange={(e) => setSortOrder(Number(e.target.value))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label={t("description")}>
|
||||
<textarea
|
||||
className="input min-h-[90px]"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={t("body")}>
|
||||
<textarea
|
||||
className="input min-h-[120px]"
|
||||
value={bodyText}
|
||||
onChange={(e) => setBodyText(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Field label={t("cover")}>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={coverImage}
|
||||
onChange={(e) => setCoverImage(e.target.value)}
|
||||
/>
|
||||
<UploadButton
|
||||
token={token}
|
||||
accept="image/*"
|
||||
onUploaded={(u) => {
|
||||
setCoverImage(u);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label={t("fileUrl")}>
|
||||
<p className="mb-1 text-xs text-neutral-500">
|
||||
{rtype === "video" ? t("adminVideoFileHint") : null}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={fileUrl}
|
||||
onChange={(e) => setFileUrl(e.target.value)}
|
||||
/>
|
||||
<UploadButton
|
||||
token={token}
|
||||
accept={
|
||||
rtype === "video"
|
||||
? "video/*,.mp4,.webm,.mov,.mkv,.m4v,.ogv"
|
||||
: undefined
|
||||
}
|
||||
onUploaded={(u, file) => {
|
||||
setFileUrl(u);
|
||||
if (file?.type.startsWith("video/")) setRtype("video");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label={t("previewUrlLabel")}>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={previewUrl}
|
||||
onChange={(e) => setPreviewUrl(e.target.value)}
|
||||
/>
|
||||
<UploadButton
|
||||
token={token}
|
||||
accept={
|
||||
rtype === "video"
|
||||
? "video/*,.mp4,.webm,.mov,.mkv,.m4v,.ogv"
|
||||
: undefined
|
||||
}
|
||||
onUploaded={(u, file) => {
|
||||
setPreviewUrl(u);
|
||||
if (file?.type.startsWith("video/")) setRtype("video");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label={t("externalUrl")}>
|
||||
<input
|
||||
className="input"
|
||||
value={externalUrl}
|
||||
onChange={(e) => setExternalUrl(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label={`${t("recommended")} / ${t("badge")}`}>
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isRecommended}
|
||||
onChange={(e) => setIsRecommended(e.target.checked)}
|
||||
/>
|
||||
{t("recommended")}
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
value={badgeLabel}
|
||||
onChange={(e) => setBadgeLabel(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isPublic}
|
||||
onChange={(e) => setIsPublic(e.target.checked)}
|
||||
/>
|
||||
{t("public")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isDownloadable}
|
||||
onChange={(e) => setIsDownloadable(e.target.checked)}
|
||||
/>
|
||||
{t("downloadable")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Field label={t("tagsCommaLabel")}>
|
||||
<input
|
||||
className="input"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-xl bg-ark-gold px-6 py-3 font-semibold text-black"
|
||||
onClick={async () => {
|
||||
setErr(null);
|
||||
try {
|
||||
if (isNew) {
|
||||
await postJSON("/api/admin/resources", payload, token);
|
||||
} else {
|
||||
await putJSON(`/api/admin/resources/${id}`, payload, token);
|
||||
}
|
||||
nav(mode === "basename" ? ".." : `${adminUiPrefix}/resources`);
|
||||
} catch (e) {
|
||||
setErr(String(e));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("save")}
|
||||
</button>
|
||||
|
||||
<style>{`
|
||||
.input { width: 100%; border: 1px solid #1f1f1f; background: #070707; border-radius: 12px; padding: 10px 12px; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block text-sm text-neutral-300">
|
||||
<div className="mb-1">{label}</div>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadButton({
|
||||
token,
|
||||
onUploaded,
|
||||
accept,
|
||||
}: {
|
||||
token: string;
|
||||
onUploaded: (url: string, file?: File) => void;
|
||||
accept?: string;
|
||||
}) {
|
||||
const t = useAdminT();
|
||||
return (
|
||||
<label className="inline-flex cursor-pointer items-center rounded-xl border border-ark-line px-3 py-2 text-xs hover:border-ark-gold">
|
||||
{t("uploadFile")}
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept={accept}
|
||||
onChange={async (e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (!f) return;
|
||||
const r = await uploadFile(f, token);
|
||||
onUploaded(r.url, f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
146
src/pages/admin/AdminResources.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
getJSON,
|
||||
getJSONAuth,
|
||||
itemsOrEmpty,
|
||||
type AdminResource,
|
||||
type Category,
|
||||
} from "../../api";
|
||||
import { getToken } from "../../admin/token";
|
||||
import { resourceTypeDisplay } from "../../resourceTypeLabels";
|
||||
import { useAdminT } from "../../admin/useAdminT";
|
||||
import { adminUiPrefix } from "../../adminPaths";
|
||||
import { useAdminRouterMode } from "../../adminRouterMode";
|
||||
|
||||
function statusLabel(t: (k: string) => string, s: string) {
|
||||
if (s === "published") return t("published");
|
||||
if (s === "draft") return t("draft");
|
||||
if (s === "archived") return t("archived");
|
||||
return s;
|
||||
}
|
||||
|
||||
const pageSize = 25;
|
||||
|
||||
export function AdminResources() {
|
||||
const t = useAdminT();
|
||||
const mode = useAdminRouterMode();
|
||||
const token = getToken();
|
||||
const [items, setItems] = useState<AdminResource[]>([]);
|
||||
const [catNames, setCatNames] = useState<Record<number, string>>({});
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
getJSON<Category[]>("/api/categories?lang=zh-TW")
|
||||
.then((cats) => {
|
||||
const m: Record<number, string> = {};
|
||||
for (const c of cats) m[c.id] = c.name;
|
||||
setCatNames(m);
|
||||
})
|
||||
.catch(() => setCatNames({}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getJSONAuth<{ items: AdminResource[]; total?: number }>(
|
||||
`/api/admin/resources?limit=${pageSize}&page=${page}`,
|
||||
token,
|
||||
)
|
||||
.then((r) => {
|
||||
setItems(itemsOrEmpty(r.items));
|
||||
setTotal(typeof r.total === "number" ? r.total : 0);
|
||||
})
|
||||
.catch(() => setItems([]));
|
||||
}, [token, page]);
|
||||
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-bold">{t("resources")}</h1>
|
||||
<Link
|
||||
to={mode === "basename" ? "new" : `${adminUiPrefix}/resources/new`}
|
||||
className="rounded-xl bg-ark-gold px-4 py-2 text-sm font-semibold text-black"
|
||||
>
|
||||
{t("newResource")}
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{t("listRange")
|
||||
.replace(
|
||||
"{{from}}",
|
||||
String(total === 0 ? 0 : (page - 1) * pageSize + 1),
|
||||
)
|
||||
.replace("{{to}}", String(Math.min(page * pageSize, total)))
|
||||
.replace("{{total}}", String(total))}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
className="rounded-full border border-ark-line px-4 py-2 text-sm text-neutral-200 outline-none hover:border-ark-gold disabled:opacity-40"
|
||||
>
|
||||
{t("paginationPrev")}
|
||||
</button>
|
||||
<span className="flex items-center px-2 text-sm text-ark-muted tabular-nums">
|
||||
{t("pageIndicator")
|
||||
.replace("{{c}}", String(page))
|
||||
.replace("{{p}}", String(pages))}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={page >= pages}
|
||||
onClick={() => setPage((p) => Math.min(pages, p + 1))}
|
||||
className="rounded-full border border-ark-line px-4 py-2 text-sm text-neutral-200 outline-none hover:border-ark-gold disabled:opacity-40"
|
||||
>
|
||||
{t("paginationNext")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-2xl border border-ark-line">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-ark-panel text-left text-neutral-400">
|
||||
<tr>
|
||||
<th className="p-3">{t("title")}</th>
|
||||
<th className="p-3">{t("category")}</th>
|
||||
<th className="p-3">{t("type")}</th>
|
||||
<th className="p-3">{t("status")}</th>
|
||||
<th className="p-3">{t("downloads")}</th>
|
||||
<th className="p-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((r) => (
|
||||
<tr key={r.id} className="border-t border-ark-line">
|
||||
<td className="p-3 font-medium">{r.title}</td>
|
||||
<td className="p-3 text-neutral-400">
|
||||
{catNames[r.categoryId] ?? r.categoryId}
|
||||
</td>
|
||||
<td className="p-3 text-neutral-400">
|
||||
{resourceTypeDisplay(t, r.type)}
|
||||
</td>
|
||||
<td className="p-3 text-neutral-400">
|
||||
{statusLabel(t, r.status)}
|
||||
</td>
|
||||
<td className="p-3 text-neutral-400">{r.downloadCount ?? 0}</td>
|
||||
<td className="p-3 text-right">
|
||||
<Link
|
||||
className="text-ark-gold2 hover:underline"
|
||||
to={
|
||||
mode === "basename"
|
||||
? String(r.id)
|
||||
: `${adminUiPrefix}/resources/${r.id}`
|
||||
}
|
||||
>
|
||||
{t("edit")}
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
src/pages/admin/AdminSearchLogs.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getJSONAuth } from "../../api";
|
||||
import { getToken } from "../../admin/token";
|
||||
import { useAdminT } from "../../admin/useAdminT";
|
||||
|
||||
type Row = { id: number; query: string; createdAt: string };
|
||||
|
||||
export function AdminSearchLogs() {
|
||||
const t = useAdminT();
|
||||
const token = getToken();
|
||||
const [items, setItems] = useState<Row[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
getJSONAuth<{ items: Row[] }>("/api/admin/search-logs?limit=300", token)
|
||||
.then((r) => setItems(Array.isArray(r.items) ? r.items : []))
|
||||
.catch(() => setItems([]));
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold">{t("adminSearchLogs")}</h1>
|
||||
<div className="overflow-x-auto rounded-2xl border border-ark-line">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-ark-panel text-left text-neutral-400">
|
||||
<tr>
|
||||
<th className="p-3">{t("adminSearchId")}</th>
|
||||
<th className="p-3">{t("adminSearchQuery")}</th>
|
||||
<th className="p-3">{t("adminSearchTime")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((x) => (
|
||||
<tr key={x.id} className="border-t border-ark-line">
|
||||
<td className="p-3 text-neutral-500">{x.id}</td>
|
||||
<td className="p-3 font-medium">{x.query}</td>
|
||||
<td className="p-3 text-neutral-400 whitespace-nowrap">
|
||||
{x.createdAt}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-neutral-500 text-sm">{t("noResults")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
src/resourceTypeLabels.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/** Labels for resource type filter chips (keys match API `type` + `all`). */
|
||||
export function typeFilterLabel(
|
||||
t: (key: string) => string,
|
||||
tp: string,
|
||||
): string {
|
||||
if (tp === "all") return t("filterAll");
|
||||
return resourceTypeLabel(t, tp);
|
||||
}
|
||||
|
||||
/** Type label without the "all" filter branch (e.g. admin tables). */
|
||||
export function resourceTypeDisplay(
|
||||
t: (key: string) => string,
|
||||
type: string,
|
||||
): string {
|
||||
const key = `type_${type}`;
|
||||
const label = t(key);
|
||||
return label !== key ? label : type;
|
||||
}
|
||||
|
||||
/** Localized label for API resource `type` (image, video, link, …). */
|
||||
export function resourceTypeLabel(
|
||||
t: (key: string) => string,
|
||||
type: string,
|
||||
): string {
|
||||
const key = `type_${type}`;
|
||||
const label = t(key);
|
||||
return label !== key ? label : type;
|
||||
}
|
||||
|
||||
/** Localized label for resource `language` code (zh-TW, en, …). */
|
||||
export function resourceLanguageLabel(
|
||||
t: (key: string) => string,
|
||||
langCode: string,
|
||||
): string {
|
||||
const lc = langCode.trim().toLowerCase();
|
||||
const key =
|
||||
lc === "zh-tw"
|
||||
? "lang_zh_TW"
|
||||
: lc === "zh-cn" || lc === "zh-hans"
|
||||
? "lang_zh_CN"
|
||||
: lc === "en"
|
||||
? "lang_en"
|
||||
: "";
|
||||
if (key) {
|
||||
const label = t(key);
|
||||
if (label !== key) return label;
|
||||
}
|
||||
return langCode.trim();
|
||||
}
|
||||
18
src/utils/categoryDisplay.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Split display name into title + parenthetical subtitle (matches design cards). */
|
||||
export function categoryCardLines(
|
||||
name: string,
|
||||
description?: string,
|
||||
): { line1: string; line2?: string } {
|
||||
const i = name.indexOf("(");
|
||||
if (i > 0 && name.includes(")")) {
|
||||
return { line1: name.slice(0, i).trim(), line2: name.slice(i).trim() };
|
||||
}
|
||||
const j = name.indexOf("(");
|
||||
if (j > 0 && name.includes(")")) {
|
||||
return { line1: name.slice(0, j).trim(), line2: name.slice(j).trim() };
|
||||
}
|
||||
if (description?.trim()) {
|
||||
return { line1: name, line2: description.trim() };
|
||||
}
|
||||
return { line1: name };
|
||||
}
|
||||
7
src/utils/format.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function formatDateYmd(iso: string) {
|
||||
const d = new Date(iso);
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
6
src/video.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/** Path looks like a video file (upload or URL path). */
|
||||
export function isLikelyVideoPath(path: string | undefined | null): boolean {
|
||||
if (!path) return false;
|
||||
const p = path.split("?")[0].toLowerCase();
|
||||
return /\.(mp4|webm|mov|m4v|mkv|ogv|avi)(\/)?$/i.test(p);
|
||||
}
|
||||
13
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
readonly VITE_WALLETCONNECT_PROJECT_ID: string;
|
||||
readonly VITE_ADMIN_UI_PREFIX?: string;
|
||||
/** When `"true"`, bundle admin UI only (no public pages); use with `VITE_ADMIN_UI_PREFIX` or default secret prefix. */
|
||||
readonly VITE_ADMIN_ONLY?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
15
src/wagmiConfig.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { getDefaultConfig } from "@rainbow-me/rainbowkit";
|
||||
import { arbitrum, bsc, mainnet, polygon, sepolia } from "wagmi/chains";
|
||||
|
||||
/**
|
||||
* Get a free Project ID: https://cloud.reown.com (WalletConnect / Reown)
|
||||
* Without it, WalletConnect (mobile / QR on desktop) will not work; browser extensions may still work in some setups.
|
||||
*/
|
||||
const projectId = import.meta.env.VITE_WALLETCONNECT_PROJECT_ID || "";
|
||||
|
||||
export const wagmiConfig = getDefaultConfig({
|
||||
appName: "ARK Database",
|
||||
projectId: projectId || "00000000000000000000000000000000",
|
||||
chains: [mainnet, bsc, arbitrum, polygon, sepolia],
|
||||
ssr: false,
|
||||
});
|
||||
13
src/walletToken.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
const KEY = "ark_wallet_token";
|
||||
|
||||
export function getWalletToken() {
|
||||
return localStorage.getItem(KEY) || "";
|
||||
}
|
||||
|
||||
export function setWalletToken(t: string) {
|
||||
localStorage.setItem(KEY, t);
|
||||
}
|
||||
|
||||
export function clearWalletToken() {
|
||||
localStorage.removeItem(KEY);
|
||||
}
|
||||
35
tailwind.config.js
Normal file
@@ -0,0 +1,35 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
ark: {
|
||||
bg: "#141319",
|
||||
nav: "#08070c",
|
||||
panel: "#1d1e23",
|
||||
line: "#2a2a32",
|
||||
gold: "#eeb726",
|
||||
gold2: "#ffd35c",
|
||||
muted: "#8f9099",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
"Noto Sans SC",
|
||||
"Noto Sans TC",
|
||||
"PingFang SC",
|
||||
"PingFang TC",
|
||||
"Microsoft YaHei",
|
||||
"Microsoft JhengHei",
|
||||
"ui-sans-serif",
|
||||
"system-ui",
|
||||
"-apple-system",
|
||||
"Segoe UI",
|
||||
"sans-serif",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
10
tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
23
vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
// Entry script at site root (/index-[hash].js); lazy chunks + CSS stay under /assets/.
|
||||
entryFileNames: "[name]-[hash].js",
|
||||
chunkFileNames: "assets/[name]-[hash].js",
|
||||
assetFileNames: "assets/[name]-[hash][extname]",
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": { target: "http://127.0.0.1:8080", changeOrigin: true },
|
||||
"/uploads": { target: "http://127.0.0.1:8080", changeOrigin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||