ref: remove packages/next
This commit is contained in:
parent
30f8a2ffe8
commit
86e1a2622b
@ -4,10 +4,6 @@ data
|
||||
*.txt
|
||||
*.md
|
||||
*config*
|
||||
Inter.css
|
||||
MiSans.css
|
||||
*.yaml
|
||||
*.yml
|
||||
*.mdx
|
||||
packages/core/drizzle/cred
|
||||
packages/core/drizzle/main
|
||||
*.mdx
|
||||
1
packages/core/.tokeignore
Normal file
1
packages/core/.tokeignore
Normal file
@ -0,0 +1 @@
|
||||
drizzle/main
|
||||
44
packages/next/.gitignore
vendored
44
packages/next/.gitignore
vendored
@ -1,44 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# fumadocs
|
||||
.source
|
||||
@ -1,3 +0,0 @@
|
||||
p {
|
||||
word-break: break-all;
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
import HeaderServer from "@/components/shell/HeaderServer";
|
||||
import tpLicense from "@/content/THIRD-PARTY-LICENSES.txt";
|
||||
import projectLicense from "@/content/LICENSE.txt";
|
||||
|
||||
export default function LicensePage() {
|
||||
return (
|
||||
<>
|
||||
<HeaderServer />
|
||||
<main className="lg:max-w-4xl lg:mx-auto">
|
||||
<p className="leading-10">中 V 档案馆的软件在 AGPL 3.0 下许可,请见:</p>
|
||||
<pre className="break-all whitespace-pre-wrap">{projectLicense}</pre>
|
||||
<p className="leading-10">本项目引入的其它项目项目的许可详情如下:</p>
|
||||
<pre className="break-all whitespace-pre-wrap">{tpLicense}</pre>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,8 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import About from "@/content/about.mdx";
|
||||
import "./content.css";
|
||||
|
||||
export default function AboutContent() {
|
||||
return <About />;
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
.content {
|
||||
h1 {
|
||||
@apply text-3xl;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-2xl;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-xl;
|
||||
}
|
||||
|
||||
h4 {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply my-4;
|
||||
}
|
||||
|
||||
ul {
|
||||
@apply list-disc list-inside my-4;
|
||||
}
|
||||
|
||||
ol {
|
||||
@apply list-decimal list-inside my-4;
|
||||
}
|
||||
|
||||
li {
|
||||
@apply my-2;
|
||||
}
|
||||
|
||||
th {
|
||||
@apply bg-gray-200 font-medium;
|
||||
}
|
||||
ul li p,
|
||||
ol li p {
|
||||
@apply inline;
|
||||
}
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
import { Header } from "@/components/shell/Header";
|
||||
import { getCurrentUser } from "@/lib/userAuth";
|
||||
import AboutContent from "./AboutContent";
|
||||
|
||||
export default async function AboutPage() {
|
||||
const user = await getCurrentUser();
|
||||
return (
|
||||
<>
|
||||
<Header user={user} />
|
||||
<main className="flex flex-col items-center min-h-screen gap-8 md:mt-12 relative z-0">
|
||||
<div className="w-full lg:w-2/3 xl:w-1/2 content px-8 md:px-12 lg:px-0">
|
||||
<AboutContent />
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-Black.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-Black.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-BlackItalic.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-BlackItalic.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-Bold.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-Bold.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-BoldItalic.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-BoldItalic.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-ExtraBold.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-ExtraBold.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-ExtraBoldItalic.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-ExtraBoldItalic.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-ExtraLight.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-ExtraLight.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-ExtraLightItalic.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-ExtraLightItalic.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-Italic.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-Italic.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-Light.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-Light.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-LightItalic.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-LightItalic.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-Medium.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-Medium.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-MediumItalic.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-MediumItalic.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-Regular.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-Regular.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-SemiBold.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-SemiBold.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-SemiBoldItalic.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-SemiBoldItalic.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-Thin.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-Thin.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-ThinItalic.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/Inter-ThinItalic.woff2
(Stored with Git LFS)
Binary file not shown.
@ -1,449 +0,0 @@
|
||||
@font-face {
|
||||
font-family: "Inter Variable";
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: fallback;
|
||||
src: url("InterVariable.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter Variable";
|
||||
font-style: italic;
|
||||
font-weight: 100 900;
|
||||
font-display: fallback;
|
||||
src: url("InterVariable-Italic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
/* static fonts */
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
font-display: fallback;
|
||||
src: url("Inter-Thin.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 100;
|
||||
font-display: fallback;
|
||||
src: url("Inter-ThinItalic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
font-display: fallback;
|
||||
src: url("Inter-ExtraLight.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 200;
|
||||
font-display: fallback;
|
||||
src: url("Inter-ExtraLightItalic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: fallback;
|
||||
src: url("Inter-Light.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
font-display: fallback;
|
||||
src: url("Inter-LightItalic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: fallback;
|
||||
src: url("Inter-Regular.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: fallback;
|
||||
src: url("Inter-Italic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: fallback;
|
||||
src: url("Inter-Medium.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-display: fallback;
|
||||
src: url("Inter-MediumItalic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: fallback;
|
||||
src: url("Inter-SemiBold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
font-display: fallback;
|
||||
src: url("Inter-SemiBoldItalic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: fallback;
|
||||
src: url("Inter-Bold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-display: fallback;
|
||||
src: url("Inter-BoldItalic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: fallback;
|
||||
src: url("Inter-ExtraBold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 800;
|
||||
font-display: fallback;
|
||||
src: url("Inter-ExtraBoldItalic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: fallback;
|
||||
src: url("Inter-Black.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
font-style: italic;
|
||||
font-weight: 900;
|
||||
font-display: fallback;
|
||||
src: url("Inter-BlackItalic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "InterDisplay";
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
font-display: fallback;
|
||||
src: url("InterDisplay-Thin.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "InterDisplay";
|
||||
font-style: italic;
|
||||
font-weight: 100;
|
||||
font-display: fallback;
|
||||
src: url("InterDisplay-ThinItalic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "InterDisplay";
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
font-display: fallback;
|
||||
src: url("InterDisplay-ExtraLight.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "InterDisplay";
|
||||
font-style: italic;
|
||||
font-weight: 200;
|
||||
font-display: fallback;
|
||||
src: url("InterDisplay-ExtraLightItalic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "InterDisplay";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: fallback;
|
||||
src: url("InterDisplay-Light.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "InterDisplay";
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
font-display: fallback;
|
||||
src: url("InterDisplay-LightItalic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "InterDisplay";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: fallback;
|
||||
src: url("InterDisplay-Regular.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "InterDisplay";
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: fallback;
|
||||
src: url("InterDisplay-Italic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "InterDisplay";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: fallback;
|
||||
src: url("InterDisplay-Medium.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "InterDisplay";
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-display: fallback;
|
||||
src: url("InterDisplay-MediumItalic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "InterDisplay";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: fallback;
|
||||
src: url("InterDisplay-SemiBold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "InterDisplay";
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
font-display: fallback;
|
||||
src: url("InterDisplay-SemiBoldItalic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "InterDisplay";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: fallback;
|
||||
src: url("InterDisplay-Bold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "InterDisplay";
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-display: fallback;
|
||||
src: url("InterDisplay-BoldItalic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "InterDisplay";
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: fallback;
|
||||
src: url("InterDisplay-ExtraBold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "InterDisplay";
|
||||
font-style: italic;
|
||||
font-weight: 800;
|
||||
font-display: fallback;
|
||||
src: url("InterDisplay-ExtraBoldItalic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "InterDisplay";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: fallback;
|
||||
src: url("InterDisplay-Black.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "InterDisplay";
|
||||
font-style: italic;
|
||||
font-weight: 900;
|
||||
font-display: fallback;
|
||||
src: url("InterDisplay-BlackItalic.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-feature-values InterVariable {
|
||||
@character-variant {
|
||||
cv01: 1;
|
||||
cv02: 2;
|
||||
cv03: 3;
|
||||
cv04: 4;
|
||||
cv05: 5;
|
||||
cv06: 6;
|
||||
cv07: 7;
|
||||
cv08: 8;
|
||||
cv09: 9;
|
||||
cv10: 10;
|
||||
cv11: 11;
|
||||
cv12: 12;
|
||||
cv13: 13;
|
||||
alt-1: 1; /* Alternate one */
|
||||
alt-3: 9; /* Flat-top three */
|
||||
open-4: 2; /* Open four */
|
||||
open-6: 3; /* Open six */
|
||||
open-9: 4; /* Open nine */
|
||||
lc-l-with-tail: 5; /* Lower-case L with tail */
|
||||
simplified-u: 6; /* Simplified u */
|
||||
alt-double-s: 7; /* Alternate German double s */
|
||||
uc-i-with-serif: 8; /* Upper-case i with serif */
|
||||
uc-g-with-spur: 10; /* Capital G with spur */
|
||||
single-story-a: 11; /* Single-story a */
|
||||
compact-lc-f: 12; /* Compact f */
|
||||
compact-lc-t: 13; /* Compact t */
|
||||
}
|
||||
@styleset {
|
||||
ss01: 1;
|
||||
ss02: 2;
|
||||
ss03: 3;
|
||||
ss04: 4;
|
||||
ss05: 5;
|
||||
ss06: 6;
|
||||
ss07: 7;
|
||||
ss08: 8;
|
||||
open-digits: 1; /* Open digits */
|
||||
disambiguation: 2; /* Disambiguation (with zero) */
|
||||
disambiguation-except-zero: 4; /* Disambiguation (no zero) */
|
||||
round-quotes-and-commas: 3; /* Round quotes & commas */
|
||||
square-punctuation: 7; /* Square punctuation */
|
||||
square-quotes: 8; /* Square quotes */
|
||||
circled-characters: 5; /* Circled characters */
|
||||
squared-characters: 6; /* Squared characters */
|
||||
}
|
||||
}
|
||||
@font-feature-values Inter {
|
||||
@character-variant {
|
||||
cv01: 1;
|
||||
cv02: 2;
|
||||
cv03: 3;
|
||||
cv04: 4;
|
||||
cv05: 5;
|
||||
cv06: 6;
|
||||
cv07: 7;
|
||||
cv08: 8;
|
||||
cv09: 9;
|
||||
cv10: 10;
|
||||
cv11: 11;
|
||||
cv12: 12;
|
||||
cv13: 13;
|
||||
alt-1: 1; /* Alternate one */
|
||||
alt-3: 9; /* Flat-top three */
|
||||
open-4: 2; /* Open four */
|
||||
open-6: 3; /* Open six */
|
||||
open-9: 4; /* Open nine */
|
||||
lc-l-with-tail: 5; /* Lower-case L with tail */
|
||||
simplified-u: 6; /* Simplified u */
|
||||
alt-double-s: 7; /* Alternate German double s */
|
||||
uc-i-with-serif: 8; /* Upper-case i with serif */
|
||||
uc-g-with-spur: 10; /* Capital G with spur */
|
||||
single-story-a: 11; /* Single-story a */
|
||||
compact-lc-f: 12; /* Compact f */
|
||||
compact-lc-t: 13; /* Compact t */
|
||||
}
|
||||
@styleset {
|
||||
ss01: 1;
|
||||
ss02: 2;
|
||||
ss03: 3;
|
||||
ss04: 4;
|
||||
ss05: 5;
|
||||
ss06: 6;
|
||||
ss07: 7;
|
||||
ss08: 8;
|
||||
open-digits: 1; /* Open digits */
|
||||
disambiguation: 2; /* Disambiguation (with zero) */
|
||||
disambiguation-except-zero: 4; /* Disambiguation (no zero) */
|
||||
round-quotes-and-commas: 3; /* Round quotes & commas */
|
||||
square-punctuation: 7; /* Square punctuation */
|
||||
square-quotes: 8; /* Square quotes */
|
||||
circled-characters: 5; /* Circled characters */
|
||||
squared-characters: 6; /* Squared characters */
|
||||
}
|
||||
}
|
||||
@font-feature-values InterDisplay {
|
||||
@character-variant {
|
||||
cv01: 1;
|
||||
cv02: 2;
|
||||
cv03: 3;
|
||||
cv04: 4;
|
||||
cv05: 5;
|
||||
cv06: 6;
|
||||
cv07: 7;
|
||||
cv08: 8;
|
||||
cv09: 9;
|
||||
cv10: 10;
|
||||
cv11: 11;
|
||||
cv12: 12;
|
||||
cv13: 13;
|
||||
alt-1: 1; /* Alternate one */
|
||||
alt-3: 9; /* Flat-top three */
|
||||
open-4: 2; /* Open four */
|
||||
open-6: 3; /* Open six */
|
||||
open-9: 4; /* Open nine */
|
||||
lc-l-with-tail: 5; /* Lower-case L with tail */
|
||||
simplified-u: 6; /* Simplified u */
|
||||
alt-double-s: 7; /* Alternate German double s */
|
||||
uc-i-with-serif: 8; /* Upper-case i with serif */
|
||||
uc-g-with-spur: 10; /* Capital G with spur */
|
||||
single-story-a: 11; /* Single-story a */
|
||||
compact-lc-f: 12; /* Compact f */
|
||||
compact-lc-t: 13; /* Compact t */
|
||||
}
|
||||
@styleset {
|
||||
ss01: 1;
|
||||
ss02: 2;
|
||||
ss03: 3;
|
||||
ss04: 4;
|
||||
ss05: 5;
|
||||
ss06: 6;
|
||||
ss07: 7;
|
||||
ss08: 8;
|
||||
open-digits: 1; /* Open digits */
|
||||
disambiguation: 2; /* Disambiguation (with zero) */
|
||||
disambiguation-except-zero: 4; /* Disambiguation (no zero) */
|
||||
round-quotes-and-commas: 3; /* Round quotes & commas */
|
||||
square-punctuation: 7; /* Square punctuation */
|
||||
square-quotes: 8; /* Square quotes */
|
||||
circled-characters: 5; /* Circled characters */
|
||||
squared-characters: 6; /* Squared characters */
|
||||
}
|
||||
}
|
||||
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-Black.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-Black.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-BlackItalic.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-BlackItalic.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-Bold.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-Bold.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-BoldItalic.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-BoldItalic.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-ExtraBold.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-ExtraBold.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-ExtraBoldItalic.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-ExtraBoldItalic.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-ExtraLight.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-ExtraLight.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-ExtraLightItalic.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-ExtraLightItalic.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-Italic.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-Italic.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-Light.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-Light.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-LightItalic.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-LightItalic.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-Medium.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-Medium.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-MediumItalic.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-MediumItalic.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-Regular.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-Regular.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-SemiBold.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-SemiBold.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-SemiBoldItalic.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-SemiBoldItalic.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-Thin.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-Thin.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-ThinItalic.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/InterDisplay-ThinItalic.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/InterVariable-Italic.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/InterVariable-Italic.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/InterFont/InterVariable.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/InterFont/InterVariable.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans VF.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans VF.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans-Bold.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans-Bold.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans-Demibold.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans-Demibold.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans-ExtraLight.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans-ExtraLight.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans-Heavy.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans-Heavy.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans-Light.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans-Light.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans-Medium.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans-Medium.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans-Normal.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans-Normal.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans-Regular.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans-Regular.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans-Semibold.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans-Semibold.woff2
(Stored with Git LFS)
Binary file not shown.
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans-Thin.woff2
(Stored with Git LFS)
BIN
packages/next/app/[locale]/fonts/MiSans/MiSans-Thin.woff2
(Stored with Git LFS)
Binary file not shown.
@ -1,87 +0,0 @@
|
||||
@font-face {
|
||||
font-family: "MiSans VF";
|
||||
font-style: normal;
|
||||
font-weight: 150 700;
|
||||
font-display: fallback;
|
||||
src: url("MiSans VF.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "MiSans";
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
font-display: fallback;
|
||||
src: url("MiSans-Thin.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "MiSans";
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
font-display: fallback;
|
||||
src: url("MiSans-ExtraLight.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "MiSans";
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: fallback;
|
||||
src: url("MiSans-Light.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "MiSans";
|
||||
font-style: normal;
|
||||
font-weight: 360;
|
||||
font-display: fallback;
|
||||
src: url("MiSans-Normal.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "MiSans";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: fallback;
|
||||
src: url("MiSans-Regular.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "MiSans";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: fallback;
|
||||
src: url("MiSans-Medium.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "MiSans";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: fallback;
|
||||
src: url("MiSans-Demibold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "MiSans";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: fallback;
|
||||
src: url("MiSans-Semibold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "MiSans";
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: fallback;
|
||||
src: url("MiSans-Bold.woff2") format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "MiSans";
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: fallback;
|
||||
src: url("MiSans-Heavy.woff2") format("woff2");
|
||||
}
|
||||
@ -1,106 +0,0 @@
|
||||
@import url("./fonts/InterFont/Inter.css");
|
||||
@import url("./fonts/MiSans/MiSans.css");
|
||||
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-background: #fff8f6;
|
||||
--color-on-background: #2a1613;
|
||||
--color-surface: #fff8f6;
|
||||
--color-surface-dim: #f7d2cc;
|
||||
--color-surface-bright: #fff8f6;
|
||||
--color-surface-container-lowest: #ffffff;
|
||||
--color-surface-container-low: #fff0ee;
|
||||
--color-surface-container: #ffe9e6;
|
||||
--color-surface-container-high: #ffe2dd;
|
||||
--color-surface-container-highest: #ffdad4;
|
||||
--color-on-surface: #2a1613;
|
||||
--color-surface-variant: #ffdad4;
|
||||
--color-on-surface-variant: #5f3e39;
|
||||
--color-inverse-surface: #422b27;
|
||||
--color-inverse-on-surface: #ffedea;
|
||||
--color-outline: #946e68;
|
||||
--color-outline-variant: #eabcb4;
|
||||
--color-shadow: #000000;
|
||||
--color-scrim: #000000;
|
||||
--color-surface-tint: #c00100;
|
||||
--color-primary: #a50100;
|
||||
--color-on-primary: #ffffff;
|
||||
--color-primary-container: #eb0000;
|
||||
--color-on-primary-container: #ffffff;
|
||||
--color-inverse-primary: #ffb4a8;
|
||||
--color-secondary: #b4271a;
|
||||
--color-on-secondary: #ffffff;
|
||||
--color-secondary-container: #ff7460;
|
||||
--color-on-secondary-container: #2f0000;
|
||||
--color-tertiary: #6f4800;
|
||||
--color-on-tertiary: #ffffff;
|
||||
--color-tertiary-container: #9f6900;
|
||||
--color-on-tertiary-container: #ffffff;
|
||||
--color-error: #ba1a1a;
|
||||
--color-on-error: #ffffff;
|
||||
--color-error-container: #ffdad6;
|
||||
--color-on-error-container: #410002;
|
||||
|
||||
--color-dark-background: #210e0b;
|
||||
--color-dark-on-background: #ffdad4;
|
||||
--color-dark-surface: #210e0b;
|
||||
--color-dark-surface-dim: #210e0b;
|
||||
--color-dark-surface-bright: #4b332f;
|
||||
--color-dark-surface-container-lowest: #1b0907;
|
||||
--color-dark-surface-container-low: #2a1613;
|
||||
--color-dark-surface-container: #2f1a17;
|
||||
--color-dark-surface-container-high: #3a2421;
|
||||
--color-dark-surface-container-highest: #462f2b;
|
||||
--color-dark-on-surface: #ffdad4;
|
||||
--color-dark-surface-variant: #5f3e39;
|
||||
--color-dark-on-surface-variant: #eabcb4;
|
||||
--color-dark-inverse-surface: #ffdad4;
|
||||
--color-dark-inverse-on-surface: #422b27;
|
||||
--color-dark-outline: #b08780;
|
||||
--color-dark-outline-variant: #5f3e39;
|
||||
--color-dark-shadow: #000000;
|
||||
--color-dark-scrim: #000000;
|
||||
--color-dark-surface-tint: #ffb4a8;
|
||||
--color-dark-primary: #ffb4a8;
|
||||
--color-dark-on-primary: #690000;
|
||||
--color-dark-primary-container: #de0000;
|
||||
--color-dark-on-primary-container: #ffffff;
|
||||
--color-dark-inverse-primary: #c00100;
|
||||
--color-dark-secondary: #ffb4a8;
|
||||
--color-dark-on-secondary: #690000;
|
||||
--color-dark-secondary-container: #870100;
|
||||
--color-dark-on-secondary-container: #ffc9c0;
|
||||
--color-dark-tertiary: #feba54;
|
||||
--color-dark-on-tertiary: #452b00;
|
||||
--color-dark-tertiary-container: #966300;
|
||||
--color-dark-on-tertiary-container: #ffffff;
|
||||
--color-dark-error: #ffb4ab;
|
||||
--color-dark-on-error: #690005;
|
||||
--color-dark-error-container: #93000a;
|
||||
--color-dark-on-error-container: #ffdad6;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-primary dark:text-dark-primary;
|
||||
}
|
||||
|
||||
:root {
|
||||
font-family: "Inter", "MiSans", sans-serif;
|
||||
font-weight: 400;
|
||||
@apply bg-surface dark:bg-dark-surface text-on-surface dark:text-dark-on-surface;
|
||||
}
|
||||
|
||||
[type="search"]::-webkit-search-cancel-button,
|
||||
[type="search"]::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
@supports (font-variation-settings: normal) {
|
||||
:root {
|
||||
font-family: "Inter Variable", "MiSans VF", sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
font-weight: 330;
|
||||
}
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./global.css";
|
||||
import React from "react";
|
||||
import { routing } from "@/i18n/routing";
|
||||
import { hasLocale } from "next-intl";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "中 V 档案馆"
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
params
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ locale: string }>;
|
||||
}>) {
|
||||
const { locale } = await params;
|
||||
if (!hasLocale(routing.locales, locale)) {
|
||||
notFound();
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
@ -1,125 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import TextField from "@/components/ui/TextField";
|
||||
import LoadingSpinner from "@/components/icons/LoadingSpinner";
|
||||
import { Portal } from "@/components/utils/Portal";
|
||||
import { Dialog } from "@/components/ui/Dialog";
|
||||
import { setLocale } from "yup";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCaptcha } from "@/components/hooks/useCaptcha";
|
||||
import useSWRMutation from "swr/mutation";
|
||||
import { FilledButton } from "@/components/ui/Buttons/FilledButton";
|
||||
import { ApiRequestError } from "@/lib/net";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { requestLogin } from "./request";
|
||||
import { ErrorDialog } from "@/components/utils/ErrorDialog";
|
||||
|
||||
setLocale({
|
||||
mixed: {
|
||||
default: "yup_errors.field_invalid",
|
||||
required: () => ({ key: "yup_errors.field_required" })
|
||||
},
|
||||
string: {
|
||||
min: ({ min }) => ({ key: "yup_errors.field_too_short", values: { min } }),
|
||||
max: ({ max }) => ({ key: "yup_errors.field_too_big", values: { max } })
|
||||
}
|
||||
});
|
||||
|
||||
export interface LocalizedMessage {
|
||||
key: string;
|
||||
values: {
|
||||
[key: string]: number | string;
|
||||
};
|
||||
}
|
||||
|
||||
interface RegistrationFormProps {
|
||||
backendURL: string;
|
||||
}
|
||||
|
||||
const SignUpForm: React.FC<RegistrationFormProps> = ({ backendURL }) => {
|
||||
const [usernameInput, setUsername] = useState("");
|
||||
const [passwordInput, setPassword] = useState("");
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [dialogContent, setDialogContent] = useState(<></>);
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const t = useTranslations("");
|
||||
const { startCaptcha, captchaResult, captchaUsed, setCaptchaUsedState, captchaError } = useCaptcha({
|
||||
backendURL,
|
||||
route: "POST-/user"
|
||||
});
|
||||
const { trigger } = useSWRMutation(`${backendURL}/login/session`, requestLogin);
|
||||
const router = useRouter();
|
||||
|
||||
const translateErrorMessage = (item: LocalizedMessage | string, path?: string) => {
|
||||
if (typeof item === "string") {
|
||||
return item;
|
||||
}
|
||||
return t(`${item.key}`, { ...item.values, field: path ? t(path) : "" });
|
||||
};
|
||||
|
||||
const register = async () => {
|
||||
try {
|
||||
if (captchaUsed || !captchaResult) {
|
||||
await startCaptcha();
|
||||
}
|
||||
|
||||
const result = await trigger({
|
||||
data: {
|
||||
username: usernameInput,
|
||||
password: passwordInput
|
||||
},
|
||||
setShowDialog,
|
||||
captchaResult,
|
||||
setCaptchaUsedState,
|
||||
translateErrorMessage,
|
||||
setDialogContent,
|
||||
t
|
||||
});
|
||||
if (result) {
|
||||
router.push("/");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!captchaError || captchaError === undefined) return;
|
||||
const err = captchaError as ApiRequestError;
|
||||
setShowDialog(true);
|
||||
if (err.code && err.code == -1) {
|
||||
setDialogContent(
|
||||
<ErrorDialog closeDialog={() => setShowDialog(false)}>
|
||||
<p>无法连接到服务器,请检查你的网络连接后重试。</p>
|
||||
</ErrorDialog>
|
||||
);
|
||||
}
|
||||
}, [captchaError]);
|
||||
|
||||
useEffect(() => {
|
||||
startCaptcha();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="w-full flex flex-col gap-6"
|
||||
onSubmit={async (e) => {
|
||||
setLoading(true);
|
||||
e.preventDefault();
|
||||
await register();
|
||||
}}
|
||||
>
|
||||
<TextField labelText="用户名" inputText={usernameInput} onInputTextChange={setUsername} />
|
||||
<TextField labelText="密码" type="password" inputText={passwordInput} onInputTextChange={setPassword} />
|
||||
<FilledButton type="submit" disabled={isLoading}>
|
||||
{isLoading ? <LoadingSpinner /> : <span>登录</span>}
|
||||
</FilledButton>
|
||||
<Portal>
|
||||
<Dialog show={showDialog}>{dialogContent}</Dialog>
|
||||
</Portal>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignUpForm;
|
||||
@ -1,42 +0,0 @@
|
||||
import { LeftArrow } from "@/components/icons/LeftArrow";
|
||||
import { RightArrow } from "@/components/icons/RightArrow";
|
||||
import LoginForm from "./LoginForm";
|
||||
import { Link, redirect } from "@/i18n/navigation";
|
||||
import { getLocale } from "next-intl/server";
|
||||
import { getCurrentUser } from "@/lib/userAuth";
|
||||
|
||||
export default async function LoginPage() {
|
||||
const user = await getCurrentUser();
|
||||
const locale = await getLocale();
|
||||
|
||||
if (user) {
|
||||
redirect({
|
||||
href: `/user/${user.uid}/profile`,
|
||||
locale: locale
|
||||
});
|
||||
}
|
||||
return (
|
||||
<main className="relative flex-grow pt-8 px-4 md:w-full md:h-full md:flex md:items-center md:justify-center">
|
||||
<div
|
||||
className="md:w-[40rem] rounded-md md:p-8 md:-translate-y-6
|
||||
md:bg-surface-container md:dark:bg-dark-surface-container"
|
||||
>
|
||||
<p className="mb-2">
|
||||
<Link href="/">
|
||||
<LeftArrow className="inline -translate-y-0.5 scale-90 mr-1" aria-hidden="true" />
|
||||
首页
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-5xl leading-[4rem] font-extralight">登录</h1>
|
||||
<p className="mt-4 mb-6">
|
||||
没有账户?
|
||||
<Link href="/singup">
|
||||
<span>注册</span>
|
||||
<RightArrow className="text-xs inline -translate-y-0.5 ml-1" aria-hidden="true" />
|
||||
</Link>
|
||||
</p>
|
||||
<LoginForm backendURL={process.env.NEXT_PUBLIC_BACKEND_URL ?? ""} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -1,148 +0,0 @@
|
||||
import { Dispatch, JSX, SetStateAction } from "react";
|
||||
import { ApiRequestError, fetcher } from "@/lib/net";
|
||||
import type { CaptchaVerificationRawResponse, ErrorResponse, SignUpResponse } from "@cvsa/backend";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { LocalizedMessage } from "./LoginForm";
|
||||
import { ErrorDialog } from "@/components/utils/ErrorDialog";
|
||||
import { string, object, ValidationError, setLocale } from "yup";
|
||||
|
||||
setLocale({
|
||||
mixed: {
|
||||
default: "yup_errors.field_invalid",
|
||||
required: () => ({ key: "yup_errors.field_required" })
|
||||
},
|
||||
string: {
|
||||
min: ({ min }) => ({ key: "yup_errors.field_too_short", values: { min } }),
|
||||
max: ({ max }) => ({ key: "yup_errors.field_too_big", values: { max } })
|
||||
}
|
||||
});
|
||||
|
||||
interface LoginFormData {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const FormSchema = object().shape({
|
||||
username: string().required().max(50),
|
||||
password: string().required().min(4).max(120)
|
||||
});
|
||||
|
||||
const validateForm = async (
|
||||
data: LoginFormData,
|
||||
setShowDialog: Dispatch<SetStateAction<boolean>>,
|
||||
setDialogContent: Dispatch<SetStateAction<JSX.Element>>,
|
||||
translateErrorMessage: (item: LocalizedMessage | string, path?: string) => string
|
||||
): Promise<LoginFormData | null> => {
|
||||
const { username: usernameInput, password: passwordInput } = data;
|
||||
try {
|
||||
const formData = await FormSchema.validate({
|
||||
username: usernameInput,
|
||||
password: passwordInput
|
||||
});
|
||||
return {
|
||||
username: formData.username,
|
||||
password: formData.password
|
||||
};
|
||||
} catch (e) {
|
||||
if (!(e instanceof ValidationError)) {
|
||||
return null;
|
||||
}
|
||||
setShowDialog(true);
|
||||
setDialogContent(
|
||||
<ErrorDialog closeDialog={() => setShowDialog(false)}>
|
||||
<p>{translateErrorMessage(e.errors[0], e.path)}</p>
|
||||
</ErrorDialog>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface RequestSignUpArgs {
|
||||
data: LoginFormData;
|
||||
setShowDialog: Dispatch<SetStateAction<boolean>>;
|
||||
setDialogContent: Dispatch<SetStateAction<JSX.Element>>;
|
||||
translateErrorMessage: (item: LocalizedMessage | string, path?: string) => string;
|
||||
setCaptchaUsedState: Dispatch<SetStateAction<boolean>>;
|
||||
captchaResult: CaptchaVerificationRawResponse | undefined;
|
||||
t: any;
|
||||
}
|
||||
|
||||
export const requestLogin = async (url: string, { arg }: { arg: RequestSignUpArgs }) => {
|
||||
const { data, setShowDialog, setDialogContent, translateErrorMessage, setCaptchaUsedState, captchaResult, t } = arg;
|
||||
const res = await validateForm(data, setShowDialog, setDialogContent, translateErrorMessage);
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
const { username, password } = res;
|
||||
|
||||
try {
|
||||
if (!captchaResult) {
|
||||
const err = new ApiRequestError("Cannot get captcha result");
|
||||
err.response = {
|
||||
code: "UNKNOWN_ERROR",
|
||||
message: "Cannot get captch verifiction result",
|
||||
i18n: {
|
||||
key: "captcha_failed_to_get"
|
||||
}
|
||||
} as ErrorResponse;
|
||||
throw err;
|
||||
}
|
||||
setCaptchaUsedState(true);
|
||||
const registrationResponse = await fetcher<SignUpResponse>(url, {
|
||||
method: "POST",
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${captchaResult!.token}`
|
||||
},
|
||||
data: {
|
||||
username: username,
|
||||
password: password
|
||||
}
|
||||
});
|
||||
return registrationResponse;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiRequestError && error.response) {
|
||||
const res = error.response as ErrorResponse;
|
||||
setShowDialog(true);
|
||||
setDialogContent(
|
||||
<ErrorDialog closeDialog={() => setShowDialog(false)} errorCode={res.code}>
|
||||
<p>
|
||||
无法登录:
|
||||
<span>
|
||||
{res.i18n
|
||||
? t.rich(res.i18n.key, {
|
||||
...res.i18n.values,
|
||||
support: (chunks: string) => <Link href="/support">{chunks}</Link>
|
||||
})
|
||||
: res.message}
|
||||
</span>
|
||||
</p>
|
||||
</ErrorDialog>
|
||||
);
|
||||
} else if (error instanceof Error) {
|
||||
setShowDialog(true);
|
||||
setDialogContent(
|
||||
<ErrorDialog closeDialog={() => setShowDialog(false)}>
|
||||
<p>无法登录。</p>
|
||||
<p>
|
||||
错误信息:
|
||||
<br />
|
||||
{error.message}
|
||||
</p>
|
||||
</ErrorDialog>
|
||||
);
|
||||
} else {
|
||||
setShowDialog(true);
|
||||
setDialogContent(
|
||||
<ErrorDialog closeDialog={() => setShowDialog(false)} errorCode="UNKNOWN_ERROR">
|
||||
<p>无法登录。</p>
|
||||
<p>
|
||||
错误信息: <br />
|
||||
<pre className="break-all">{JSON.stringify(error)}</pre>
|
||||
</p>
|
||||
</ErrorDialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1,60 +0,0 @@
|
||||
import { ApiRequestError, fetcher } from "@/lib/net";
|
||||
import { ErrorResponse } from "@cvsa/backend";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export async function POST() {
|
||||
const backendURL = process.env.BACKEND_URL || "";
|
||||
const cookieStore = await cookies();
|
||||
const sessionID = cookieStore.get("session_id");
|
||||
if (!sessionID) {
|
||||
const response: ErrorResponse<string> = {
|
||||
message: "No session_id provided",
|
||||
errors: [],
|
||||
code: "ENTITY_NOT_FOUND"
|
||||
};
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 401
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetcher(`${backendURL}/session/${sessionID.value}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
|
||||
const headers = response.headers;
|
||||
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
"Set-Cookie": (headers["set-cookie"] || [""])[0]
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ApiRequestError && error.response) {
|
||||
const res = error.response;
|
||||
const code = error.code;
|
||||
return new Response(JSON.stringify(res), {
|
||||
status: code
|
||||
});
|
||||
} else if (error instanceof Error) {
|
||||
const response: ErrorResponse<string> = {
|
||||
message: error.message,
|
||||
errors: [],
|
||||
code: "SERVER_ERROR"
|
||||
};
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 500
|
||||
});
|
||||
} else {
|
||||
const response: ErrorResponse<string> = {
|
||||
message: "Unknown error occurred",
|
||||
errors: [],
|
||||
code: "UNKNOWN_ERROR"
|
||||
};
|
||||
return new Response(JSON.stringify(response), {
|
||||
status: 500
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import { NotFound } from "@/components/utils/404";
|
||||
|
||||
export default async function NotFoundPage() {
|
||||
return <NotFound />;
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
import { Header } from "@/components/shell/Header";
|
||||
import { getCurrentUser } from "@/lib/userAuth";
|
||||
|
||||
export default async function Home() {
|
||||
const user = await getCurrentUser();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header user={user} />
|
||||
|
||||
<main className="flex flex-col items-center justify-center h-full flex-grow gap-8 px-4">
|
||||
<h1 className="text-4xl font-medium text-center">正在施工中……</h1>
|
||||
<p>在搜索栏输入BV号或AV号,可以查询目前数据库收集到的信息~</p>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,147 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import TextField from "@/components/ui/TextField";
|
||||
import LoadingSpinner from "@/components/icons/LoadingSpinner";
|
||||
import { Portal } from "@/components/utils/Portal";
|
||||
import { Dialog } from "@/components/ui/Dialog";
|
||||
import { setLocale } from "yup";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCaptcha } from "@/components/hooks/useCaptcha";
|
||||
import useSWRMutation from "swr/mutation";
|
||||
import { requestSignUp } from "./request";
|
||||
import { FilledButton } from "@/components/ui/Buttons/FilledButton";
|
||||
import { ErrorDialog } from "@/components/utils/ErrorDialog";
|
||||
import { ApiRequestError } from "@/lib/net";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
setLocale({
|
||||
mixed: {
|
||||
default: "field_invalid",
|
||||
required: () => ({ key: "field_required" })
|
||||
},
|
||||
string: {
|
||||
min: ({ min }) => ({ key: "field_too_short", values: { min } }),
|
||||
max: ({ max }) => ({ key: "field_too_big", values: { max } })
|
||||
}
|
||||
});
|
||||
|
||||
export interface LocalizedMessage {
|
||||
key: string;
|
||||
values: {
|
||||
[key: string]: number | string;
|
||||
};
|
||||
}
|
||||
|
||||
interface RegistrationFormProps {
|
||||
backendURL: string;
|
||||
}
|
||||
|
||||
const SignUpForm: React.FC<RegistrationFormProps> = ({ backendURL }) => {
|
||||
const [usernameInput, setUsername] = useState("");
|
||||
const [passwordInput, setPassword] = useState("");
|
||||
const [nicknameInput, setNickname] = useState("");
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [dialogContent, setDialogContent] = useState(<></>);
|
||||
const [isLoading, setLoading] = useState(false);
|
||||
const t = useTranslations("");
|
||||
const { startCaptcha, captchaResult, captchaUsed, setCaptchaUsedState, captchaError } = useCaptcha({
|
||||
backendURL,
|
||||
route: "POST-/user"
|
||||
});
|
||||
const { trigger } = useSWRMutation(`${backendURL}/user`, requestSignUp);
|
||||
const router = useRouter();
|
||||
|
||||
const translateErrorMessage = (item: LocalizedMessage | string, path?: string) => {
|
||||
if (typeof item === "string") {
|
||||
return item;
|
||||
}
|
||||
return t(`${item.key}`, { ...item.values, field: path ? t(path) : "" });
|
||||
};
|
||||
|
||||
const register = async () => {
|
||||
try {
|
||||
if (captchaUsed || !captchaResult) {
|
||||
await startCaptcha();
|
||||
}
|
||||
|
||||
const result = await trigger({
|
||||
data: {
|
||||
username: usernameInput,
|
||||
password: passwordInput,
|
||||
nickname: nicknameInput
|
||||
},
|
||||
setShowDialog,
|
||||
captchaResult,
|
||||
setCaptchaUsedState,
|
||||
translateErrorMessage,
|
||||
setDialogContent,
|
||||
t
|
||||
});
|
||||
if (result) {
|
||||
router.push("/");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!captchaError || captchaError === undefined) return;
|
||||
const err = captchaError as ApiRequestError;
|
||||
setShowDialog(true);
|
||||
if (err.code && err.code == -1) {
|
||||
setDialogContent(
|
||||
<ErrorDialog closeDialog={() => setShowDialog(false)}>
|
||||
<p>无法连接到服务器,请检查你的网络连接后重试。</p>
|
||||
</ErrorDialog>
|
||||
);
|
||||
}
|
||||
}, [captchaError]);
|
||||
|
||||
useEffect(() => {
|
||||
startCaptcha();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="w-full flex flex-col gap-6"
|
||||
onSubmit={async (e) => {
|
||||
setLoading(true);
|
||||
e.preventDefault();
|
||||
await register();
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
labelText="用户名"
|
||||
inputText={usernameInput}
|
||||
onInputTextChange={setUsername}
|
||||
maxChar={50}
|
||||
supportingText="*必填。用户名是唯一的,不区分大小写。"
|
||||
/>
|
||||
<TextField
|
||||
labelText="密码"
|
||||
type="password"
|
||||
inputText={passwordInput}
|
||||
onInputTextChange={setPassword}
|
||||
supportingText="*必填。密码至少为 4 个字符。"
|
||||
maxChar={120}
|
||||
/>
|
||||
<TextField
|
||||
labelText="昵称"
|
||||
inputText={nicknameInput}
|
||||
onInputTextChange={setNickname}
|
||||
supportingText="昵称可以重复。"
|
||||
maxChar={30}
|
||||
/>
|
||||
<FilledButton type="submit" disabled={isLoading}>
|
||||
{isLoading ? <LoadingSpinner /> : <span>注册</span>}
|
||||
</FilledButton>
|
||||
<Portal>
|
||||
<Dialog show={showDialog}>{dialogContent}</Dialog>
|
||||
</Portal>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignUpForm;
|
||||
@ -1,41 +0,0 @@
|
||||
import { LeftArrow } from "@/components/icons/LeftArrow";
|
||||
import { RightArrow } from "@/components/icons/RightArrow";
|
||||
import SignUpForm from "./SignUpForm";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
|
||||
export default function SignupPage() {
|
||||
return (
|
||||
<main className="relative flex-grow pt-8 px-4 md:w-full md:h-full md:flex md:items-center md:justify-center">
|
||||
<div
|
||||
className="md:w-[40rem] rounded-md md:p-8 md:-translate-y-6
|
||||
md:bg-surface-container md:dark:bg-dark-surface-container"
|
||||
>
|
||||
<p className="mb-2">
|
||||
<Link href="/">
|
||||
<LeftArrow className="inline -translate-y-0.5 scale-90 mr-1" aria-hidden="true" />
|
||||
首页
|
||||
</Link>
|
||||
</p>
|
||||
<h1 className="text-5xl leading-[4rem] font-extralight">欢迎</h1>
|
||||
<p className="mt-2 md:mt-3">
|
||||
欢迎来到中 V 档案馆。
|
||||
<br />
|
||||
这里是中文虚拟歌手相关信息的收集站与档案馆。
|
||||
</p>
|
||||
<p className="my-2">
|
||||
注册一个账号,
|
||||
<br />
|
||||
让我们一起见证中 V 的历史,现在,与未来。
|
||||
</p>
|
||||
<p className="mt-4 mb-7">
|
||||
已有账户?
|
||||
<Link href="/login">
|
||||
<span>登录</span>
|
||||
<RightArrow className="text-xs inline -translate-y-0.5 ml-1" aria-hidden="true" />
|
||||
</Link>
|
||||
</p>
|
||||
<SignUpForm backendURL={process.env.NEXT_PUBLIC_BACKEND_URL ?? ""} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -1,162 +0,0 @@
|
||||
import { Dispatch, JSX, SetStateAction } from "react";
|
||||
import { ApiRequestError, fetcher } from "@/lib/net";
|
||||
import type { CaptchaVerificationRawResponse, ErrorResponse, SignUpResponse } from "@cvsa/backend";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { LocalizedMessage } from "./SignUpForm";
|
||||
import { ErrorDialog } from "@/components/utils/ErrorDialog";
|
||||
import { string, object, ValidationError, setLocale } from "yup";
|
||||
|
||||
setLocale({
|
||||
mixed: {
|
||||
default: "yup_errors.field_invalid",
|
||||
required: () => ({ key: "yup_errors.field_required" })
|
||||
},
|
||||
string: {
|
||||
min: ({ min }) => ({ key: "yup_errors.field_too_short", values: { min } }),
|
||||
max: ({ max }) => ({ key: "yup_errors.field_too_big", values: { max } })
|
||||
}
|
||||
});
|
||||
|
||||
interface SignUpFormData {
|
||||
username: string;
|
||||
password: string;
|
||||
nickname?: string;
|
||||
}
|
||||
|
||||
const FormSchema = object().shape({
|
||||
username: string().required().max(50),
|
||||
password: string().required().min(4).max(120),
|
||||
nickname: string().optional().max(30)
|
||||
});
|
||||
|
||||
const validateForm = async (
|
||||
data: SignUpFormData,
|
||||
setShowDialog: Dispatch<SetStateAction<boolean>>,
|
||||
setDialogContent: Dispatch<SetStateAction<JSX.Element>>,
|
||||
translateErrorMessage: (item: LocalizedMessage | string, path?: string) => string
|
||||
): Promise<SignUpFormData | null> => {
|
||||
const { username: usernameInput, password: passwordInput, nickname: nicknameInput } = data;
|
||||
try {
|
||||
const formData = await FormSchema.validate(
|
||||
{
|
||||
username: usernameInput,
|
||||
password: passwordInput,
|
||||
nickname: nicknameInput
|
||||
},
|
||||
{ abortEarly: false }
|
||||
);
|
||||
return {
|
||||
username: formData.username,
|
||||
password: formData.password,
|
||||
nickname: formData.nickname
|
||||
};
|
||||
} catch (e) {
|
||||
if (!(e instanceof ValidationError)) {
|
||||
return null;
|
||||
}
|
||||
setShowDialog(true);
|
||||
setDialogContent(
|
||||
<ErrorDialog closeDialog={() => setShowDialog(false)}>
|
||||
<p>注册信息填写有误,请检查后重新提交。</p>
|
||||
<span>错误信息: </span>
|
||||
<br />
|
||||
<ol className="list-decimal list-inside">
|
||||
{e.errors.map((item, i) => {
|
||||
return <li key={i}>{translateErrorMessage(item, e.inner[i].path)}</li>;
|
||||
})}
|
||||
</ol>
|
||||
</ErrorDialog>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
interface RequestSignUpArgs {
|
||||
data: SignUpFormData;
|
||||
setShowDialog: Dispatch<SetStateAction<boolean>>;
|
||||
setDialogContent: Dispatch<SetStateAction<JSX.Element>>;
|
||||
translateErrorMessage: (item: LocalizedMessage | string, path?: string) => string;
|
||||
setCaptchaUsedState: Dispatch<SetStateAction<boolean>>;
|
||||
captchaResult: CaptchaVerificationRawResponse | undefined;
|
||||
t: any;
|
||||
}
|
||||
|
||||
export const requestSignUp = async (url: string, { arg }: { arg: RequestSignUpArgs }) => {
|
||||
const { data, setShowDialog, setDialogContent, translateErrorMessage, setCaptchaUsedState, captchaResult, t } = arg;
|
||||
const res = await validateForm(data, setShowDialog, setDialogContent, translateErrorMessage);
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
const { username, nickname, password } = res;
|
||||
|
||||
try {
|
||||
if (!captchaResult) {
|
||||
const err = new ApiRequestError("Cannot get captcha result");
|
||||
err.response = {
|
||||
code: "UNKNOWN_ERROR",
|
||||
message: "Cannot get captch verifiction result",
|
||||
i18n: {
|
||||
key: "captcha_failed_to_get"
|
||||
}
|
||||
} as ErrorResponse;
|
||||
throw err;
|
||||
}
|
||||
setCaptchaUsedState(true);
|
||||
const registrationResponse = await fetcher<SignUpResponse>(url, {
|
||||
method: "POST",
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${captchaResult!.token}`
|
||||
},
|
||||
data: {
|
||||
username: username,
|
||||
password: password,
|
||||
nickname: nickname
|
||||
}
|
||||
});
|
||||
return registrationResponse;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiRequestError && error.response) {
|
||||
const res = error.response as ErrorResponse;
|
||||
setShowDialog(true);
|
||||
setDialogContent(
|
||||
<ErrorDialog closeDialog={() => setShowDialog(false)} errorCode={res.code}>
|
||||
<p>无法为你注册账户。</p>
|
||||
<p>
|
||||
错误信息: <br />
|
||||
{res.i18n
|
||||
? t.rich(res.i18n.key, {
|
||||
...res.i18n.values,
|
||||
support: (chunks: string) => <Link href="/support">{chunks}</Link>
|
||||
})
|
||||
: res.message}
|
||||
</p>
|
||||
</ErrorDialog>
|
||||
);
|
||||
} else if (error instanceof Error) {
|
||||
setShowDialog(true);
|
||||
setDialogContent(
|
||||
<ErrorDialog closeDialog={() => setShowDialog(false)}>
|
||||
<p>无法为你注册账户。</p>
|
||||
<p>
|
||||
错误信息:
|
||||
<br />
|
||||
{error.message}
|
||||
</p>
|
||||
</ErrorDialog>
|
||||
);
|
||||
} else {
|
||||
setShowDialog(true);
|
||||
setDialogContent(
|
||||
<ErrorDialog closeDialog={() => setShowDialog(false)} errorCode="UNKNOWN_ERROR">
|
||||
<p>无法为你注册账户。</p>
|
||||
<p>
|
||||
错误信息: <br />
|
||||
<pre className="break-all">{JSON.stringify(error)}</pre>
|
||||
</p>
|
||||
</ErrorDialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -1,175 +0,0 @@
|
||||
import { format } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import { getAllSnapshots } from "@/lib/db/snapshots/getAllSnapshots";
|
||||
import { getAidFromBV } from "@/lib/db/bilibili_metadata/getAidFromBV";
|
||||
import { getVideoMetadata } from "@/lib/db/bilibili_metadata/getVideoMetadata";
|
||||
import { aidExists as idExists } from "@/lib/db/bilibili_metadata/aidExists";
|
||||
import { notFound } from "next/navigation";
|
||||
import { BiliVideoMetadataType, VideoSnapshotType } from "@cvsa/core";
|
||||
import { Metadata } from "next";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
const MetadataRow = ({ title, desc }: { title: string; desc: string | number | undefined | null }) => {
|
||||
if (!desc) return <></>;
|
||||
return (
|
||||
<tr>
|
||||
<td className="max-w-14 min-w-14 md:max-w-24 md:min-w-24 border dark:border-zinc-500 px-2 md:px-3 py-2 font-semibold">
|
||||
{title}
|
||||
</td>
|
||||
<td className="break-all max-w-[calc(100vw-4.5rem)] border dark:border-zinc-500 px-4 py-2">{desc}</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||
const backendURL = process.env.BACKEND_URL;
|
||||
const { id } = await params;
|
||||
const res = await fetch(`${backendURL}/video/${id}/info`);
|
||||
if (!res.ok) {
|
||||
return {
|
||||
title: "页面未找到 - 中 V 档案馆"
|
||||
};
|
||||
}
|
||||
const data = await res.json();
|
||||
return {
|
||||
title: `${data.title} - 歌曲信息 - 中 V 档案馆`
|
||||
};
|
||||
}
|
||||
|
||||
export default async function VideoInfoPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
let videoInfo: BiliVideoMetadataType | null = null;
|
||||
let snapshots: VideoSnapshotType[] = [];
|
||||
|
||||
async function getVideoAid(videoId: string | string[] | undefined) {
|
||||
if (!videoId) return null;
|
||||
const videoIdStr = Array.isArray(videoId) ? videoId[0] : videoId;
|
||||
if (videoIdStr?.startsWith("av")) {
|
||||
return parseInt(videoIdStr.slice(2));
|
||||
} else if (videoIdStr?.startsWith("BV")) {
|
||||
return getAidFromBV(videoIdStr);
|
||||
}
|
||||
return parseInt(videoIdStr);
|
||||
}
|
||||
|
||||
const aid = await getVideoAid(id);
|
||||
|
||||
if (!aid) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const exists = await idExists(aid);
|
||||
|
||||
if (!exists) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
try {
|
||||
const videoData = await getVideoMetadata(aid);
|
||||
const snapshotsData = await getAllSnapshots(aid);
|
||||
videoInfo = videoData;
|
||||
if (snapshotsData) {
|
||||
snapshots = snapshotsData;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
if (!videoInfo) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex flex-col items-center min-h-screen gap-8 mt-10 md:mt-6 relative z-0 overflow-x-auto pb-8">
|
||||
<div className="w-full lg:max-w-4xl lg:mx-auto lg:p-6">
|
||||
<h1 className="text-2xl font-medium ml-2 mb-4">
|
||||
视频信息:{" "}
|
||||
<a href={`https://www.bilibili.com/video/av${videoInfo.aid}`} className="underline">
|
||||
av{videoInfo.aid}
|
||||
</a>
|
||||
</h1>
|
||||
|
||||
<div className="mb-6">
|
||||
<h2 className="px-2 mb-2 text-xl font-medium">基本信息</h2>
|
||||
<div className="overflow-x-auto max-w-full px-2">
|
||||
<table className="table-fixed">
|
||||
<tbody>
|
||||
<MetadataRow title="ID" desc={videoInfo.id} />
|
||||
<MetadataRow title="av 号" desc={videoInfo.aid} />
|
||||
<MetadataRow title="BV 号" desc={videoInfo.bvid} />
|
||||
<MetadataRow title="标题" desc={videoInfo.title} />
|
||||
<MetadataRow title="描述" desc={videoInfo.description} />
|
||||
<MetadataRow title="UID" desc={videoInfo.uid} />
|
||||
<MetadataRow title="标签" desc={videoInfo.tags} />
|
||||
<MetadataRow
|
||||
title="发布时间"
|
||||
desc={
|
||||
videoInfo.published_at
|
||||
? DateTime.fromJSDate(videoInfo.published_at).toFormat(
|
||||
"yyyy-MM-dd HH:mm:ss"
|
||||
)
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<MetadataRow title="时长 (秒)" desc={videoInfo.duration} />
|
||||
<MetadataRow
|
||||
title="创建时间"
|
||||
desc={DateTime.fromJSDate(videoInfo.created_at).toFormat("yyyy-MM-dd HH:mm:ss")}
|
||||
/>
|
||||
<MetadataRow title="封面" desc={videoInfo?.cover_url} />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="px-2 mb-2 text-xl font-medium">播放量历史数据</h2>
|
||||
{snapshots && snapshots.length > 0 ? (
|
||||
<div className="overflow-x-auto px-2">
|
||||
<table className="table-auto w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border dark:border-zinc-500 px-4 py-2 font-medium">创建时间</th>
|
||||
<th className="border dark:border-zinc-500 px-4 py-2 font-medium">观看</th>
|
||||
<th className="border dark:border-zinc-500 px-4 py-2 font-medium">硬币</th>
|
||||
<th className="border dark:border-zinc-500 px-4 py-2 font-medium">点赞</th>
|
||||
<th className="border dark:border-zinc-500 px-4 py-2 font-medium">收藏</th>
|
||||
<th className="border dark:border-zinc-500 px-4 py-2 font-medium">分享</th>
|
||||
<th className="border dark:border-zinc-500 px-4 py-2 font-medium">弹幕</th>
|
||||
<th className="border dark:border-zinc-500 px-4 py-2 font-medium">评论</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{snapshots.map((snapshot) => (
|
||||
<tr key={snapshot.id}>
|
||||
<td className="border dark:border-zinc-500 px-4 py-2">
|
||||
{DateTime.fromJSDate(snapshot.created_at).toFormat(
|
||||
"yyyy-MM-dd HH:mm:ss"
|
||||
)}
|
||||
</td>
|
||||
<td className="border dark:border-zinc-500 px-4 py-2">{snapshot.views}</td>
|
||||
<td className="border dark:border-zinc-500 px-4 py-2">{snapshot.coins}</td>
|
||||
<td className="border dark:border-zinc-500 px-4 py-2">{snapshot.likes}</td>
|
||||
<td className="border dark:border-zinc-500 px-4 py-2">
|
||||
{snapshot.favorites}
|
||||
</td>
|
||||
<td className="border dark:border-zinc-500 px-4 py-2">{snapshot.shares}</td>
|
||||
<td className="border dark:border-zinc-500 px-4 py-2">
|
||||
{snapshot.danmakus}
|
||||
</td>
|
||||
<td className="border dark:border-zinc-500 px-4 py-2">
|
||||
{snapshot.replies}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p>暂无历史数据。</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
import HeaderServer from "@/components/shell/HeaderServer";
|
||||
import React from "react";
|
||||
|
||||
export default async function RootLayout({
|
||||
children
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<>
|
||||
<HeaderServer />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,206 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { N_ARRAY } from "@/lib/const";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { useEffect, useState } from "react";
|
||||
import { computeVdfInWorker } from "@/lib/vdf";
|
||||
import { FilledButton } from "@/components/ui/Buttons/FilledButton";
|
||||
|
||||
let bigintSupported = typeof BigInt !== "undefined";
|
||||
|
||||
function generateRandomBigInt(min: bigint, max: bigint) {
|
||||
const range = max - min;
|
||||
const bitLength = range.toString(2).length;
|
||||
const byteLength = Math.ceil(bitLength / 8);
|
||||
const mask = (1n << BigInt(bitLength)) - 1n;
|
||||
let result;
|
||||
do {
|
||||
const randomBytes = new Uint8Array(byteLength);
|
||||
crypto.getRandomValues(randomBytes);
|
||||
result = 0n;
|
||||
for (let i = 0; i < byteLength; i++) {
|
||||
result = (result << 8n) | BigInt(randomBytes[i]);
|
||||
}
|
||||
result = result & mask;
|
||||
} while (result > range);
|
||||
return min + result;
|
||||
}
|
||||
|
||||
function generateValidG(N: bigint) {
|
||||
if (N <= 4n) throw new Error("N must be > 4");
|
||||
while (true) {
|
||||
const r = generateRandomBigInt(2n, N - 1n);
|
||||
const g = (r * r) % N;
|
||||
if (g !== 1n && g !== 0n && g !== N - 1n) {
|
||||
return g;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const VDFtestCard = () => {
|
||||
const [browserInfo, setBrowserInfo] = useState<string | null>(null);
|
||||
const [isBenchmarking, setIsBenchmarking] = useState(false);
|
||||
const [benchmarkResults, setBenchmarkResults] = useState<{ N: bigint; difficulty: bigint; time: number }[]>([]);
|
||||
const [currentProgress, setCurrentProgress] = useState(0);
|
||||
const [currentN, setCurrentN] = useState<bigint | null>(null);
|
||||
const [currentDifficulty, setCurrentDifficulty] = useState<bigint | null>(null);
|
||||
const [currentTestIndex, setCurrentTestIndex] = useState(0);
|
||||
const difficulties = [BigInt(20000), BigInt(200000)];
|
||||
const [testCombinations, setTestCombinations] = useState<{ N: bigint; difficulty: bigint }[]>([]);
|
||||
const speedSampleIndex = 1;
|
||||
const [speedSample, setSpeedSample] = useState<{ N: bigint; difficulty: bigint; time: number } | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// 创建需要测试的 N 和难度的组合
|
||||
const combinations: { N: bigint; difficulty: bigint }[] = [];
|
||||
N_ARRAY.forEach((n) => {
|
||||
difficulties.forEach((difficulty) => {
|
||||
combinations.push({ N: n, difficulty });
|
||||
});
|
||||
});
|
||||
setTestCombinations(combinations);
|
||||
|
||||
const ua = navigator ? navigator.userAgent : "";
|
||||
const { browser } = UAParser(ua);
|
||||
setBrowserInfo(browser.name + " " + browser.version);
|
||||
}, []);
|
||||
|
||||
async function startBenchmark() {
|
||||
if (testCombinations.length === 0) {
|
||||
alert("No N values provided in src/const N_ARRAY.");
|
||||
return;
|
||||
}
|
||||
setIsBenchmarking(true);
|
||||
setBenchmarkResults([]);
|
||||
setCurrentTestIndex(0);
|
||||
|
||||
async function runTest(index: number) {
|
||||
if (index >= testCombinations.length) {
|
||||
setIsBenchmarking(false);
|
||||
setCurrentN(null);
|
||||
setCurrentDifficulty(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const { N, difficulty } = testCombinations[index];
|
||||
const g = generateValidG(N);
|
||||
|
||||
try {
|
||||
const { time } = await computeVdfInWorker(g, N, difficulty, (progress) => {
|
||||
setCurrentProgress(progress);
|
||||
setCurrentN(N);
|
||||
setCurrentDifficulty(difficulty);
|
||||
});
|
||||
setBenchmarkResults((prevResults) => [...prevResults, { N, difficulty, time }]);
|
||||
setCurrentProgress(0);
|
||||
setCurrentTestIndex((prevIndex) => prevIndex + 1);
|
||||
runTest(index + 1);
|
||||
} catch (error) {
|
||||
setIsBenchmarking(false);
|
||||
setCurrentN(null);
|
||||
setCurrentDifficulty(null);
|
||||
}
|
||||
}
|
||||
|
||||
runTest(0);
|
||||
}
|
||||
|
||||
function getAccumulatedTime() {
|
||||
return benchmarkResults.reduce((acc, result) => acc + result.time, 0);
|
||||
}
|
||||
|
||||
function calculateSpeed() {
|
||||
const sample = benchmarkResults[speedSampleIndex];
|
||||
if (!sample) return 0;
|
||||
return (Number(sample.difficulty) / sample.time) * 1000;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (benchmarkResults.length > speedSampleIndex) {
|
||||
setSpeedSample(benchmarkResults[speedSampleIndex]);
|
||||
}
|
||||
}, [benchmarkResults]);
|
||||
|
||||
return (
|
||||
<div className="relative mt-8 mb-12 h-auto duration-300">
|
||||
<h2 className="text-2xl font-medium mb-5">VDF 基准测试</h2>
|
||||
|
||||
{!bigintSupported ? (
|
||||
<p className="text-red-500 dark:text-red-400">⚠️ 您的浏览器不支持 BigInt,无法运行基准测试。</p>
|
||||
) : !isBenchmarking ? (
|
||||
<FilledButton onClick={startBenchmark} disabled={!bigintSupported} shape="square">
|
||||
开始测试
|
||||
</FilledButton>
|
||||
) : null}
|
||||
|
||||
{isBenchmarking && (
|
||||
<>
|
||||
<p className="mb-8">
|
||||
正在测试: {currentTestIndex + 1}/{testCombinations.length}
|
||||
</p>
|
||||
{currentN !== null && currentDifficulty !== null && (
|
||||
<>
|
||||
<p className="mb-2">密钥长度: {currentN.toString(2).length} 比特</p>
|
||||
<p className="mb-2">难度: {currentDifficulty.toLocaleString()}</p>
|
||||
<div className="w-full rounded-full h-1 relative overflow-hidden">
|
||||
<div
|
||||
className="bg-primary dark:bg-dark-primary h-full rounded-full absolute"
|
||||
style={{ width: `${currentProgress}%` }}
|
||||
></div>
|
||||
<div
|
||||
className="bg-secondary-container dark:bg-dark-secondary-container h-full rounded-full absolute right-0"
|
||||
style={{ width: `calc(${100 - currentProgress}% - 0.25rem)` }}
|
||||
></div>
|
||||
<div className="bg-primary dark:bg-dark-primary h-full w-1 rounded-full absolute right-0"></div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{benchmarkResults.length > 0 && !isBenchmarking && (
|
||||
<>
|
||||
<h3 className="text-lg font-medium mt-4 mb-2">测试结果</h3>
|
||||
<p className="mb-4 text-sm">
|
||||
测试在 {(getAccumulatedTime() / 1000).toFixed(3)} 秒内完成. <br />
|
||||
速度: {Math.round(calculateSpeed()).toLocaleString()} 迭代 / 秒. <br />
|
||||
<span className="text-sm text-on-surface-variant dark:text-dark-on-surface-variant">
|
||||
速度是在 N = {speedSample?.N.toString(2).length} bits, T = {speedSample?.difficulty}{" "}
|
||||
的测试中测量的.
|
||||
</span>
|
||||
<br />
|
||||
{browserInfo && <>浏览器版本:{browserInfo}</>}
|
||||
</p>
|
||||
<table className="w-full text-sm text-left rtl:text-right mt-4">
|
||||
<thead className="text-sm uppercase font-medium border-b border-outline dark:border-dark-outline">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3">
|
||||
耗时 (ms)
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3">
|
||||
N (bits)
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3">
|
||||
T (迭代)
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{benchmarkResults.map((result) => (
|
||||
<tr
|
||||
key={`${result.N}-${result.difficulty}-${result.time}`}
|
||||
className="border-b border-outline-variant dark:border-dark-outline-variant"
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">{result.time.toFixed(2)}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">{result.N.toString(2).length}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">{Number(result.difficulty)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,44 +0,0 @@
|
||||
import { VDFtestCard } from "./TestCard";
|
||||
import HeaderServer from "@/components/shell/HeaderServer";
|
||||
|
||||
export default async function VdfBenchmarkPage() {
|
||||
return (
|
||||
<>
|
||||
<HeaderServer />
|
||||
<div className="md:w-2/3 lg:w-1/2 xl:w-[37%] md:mx-auto mx-6 mb-12">
|
||||
<VDFtestCard />
|
||||
<div>
|
||||
<h2 className="text-xl font-medium leading-10">关于本页</h2>
|
||||
<div className="text-on-surface-variant dark:text-dark-on-surface-variant">
|
||||
<p>
|
||||
这是一个性能测试页面,
|
||||
<br />
|
||||
旨在测试我们的一个 VDF (Verifiable Delayed Function, 可验证延迟函数) 实现的性能。
|
||||
<br />
|
||||
这是一个数学函数,它驱动了整个网站的验证码(CAPTCHA)。
|
||||
<br />
|
||||
通过使用该函数,我们可以让您无需通过点选图片或滑动滑块既可完成验证,
|
||||
同时防御我们的网站,使其免受自动程序的攻击。
|
||||
<br />
|
||||
</p>
|
||||
<p>
|
||||
点击按钮,会自动测试并展示结果。
|
||||
<br />
|
||||
</p>
|
||||
<p>
|
||||
你可以将结果发送至邮箱:
|
||||
<a href="mailto:contact@alikia2x.com">contact@alikia2x.com</a> 或 QQ:
|
||||
<a href="https://qm.qq.com/q/WS8zyhlcEU">1559913735</a>,并附上自己的设备信息
|
||||
(例如,手机型号、电脑的 CPU 型号等)。
|
||||
<br />
|
||||
我们会根据测试结果,优化我们的实现,使性能更优。
|
||||
<br />
|
||||
感谢你的支持!
|
||||
<br />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FilledButton } from "@/components/ui/Buttons/FilledButton";
|
||||
import { Dialog, DialogButton, DialogButtonGroup, DialogHeadline, DialogSupportingText } from "@/components/ui/Dialog";
|
||||
import { Portal } from "@/components/utils/Portal";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export const LogoutButton: React.FC = () => {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const router = useRouter();
|
||||
return (
|
||||
<>
|
||||
<FilledButton
|
||||
shape="square"
|
||||
className="mt-5 !text-on-error dark:!text-dark-on-error !bg-error dark:!bg-dark-error font-medium"
|
||||
onClick={() => setShowDialog(true)}
|
||||
>
|
||||
登出
|
||||
</FilledButton>
|
||||
<Portal>
|
||||
<Dialog show={showDialog}>
|
||||
<DialogHeadline>确认登出</DialogHeadline>
|
||||
<DialogSupportingText>确认要退出登录吗?</DialogSupportingText>
|
||||
<DialogButtonGroup close={() => setShowDialog(false)}>
|
||||
<DialogButton onClick={() => setShowDialog(false)}>取消</DialogButton>
|
||||
<DialogButton
|
||||
onClick={async () => {
|
||||
try {
|
||||
await fetch("/logout", {
|
||||
method: "POST"
|
||||
});
|
||||
router.push("/");
|
||||
} finally {
|
||||
setShowDialog(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
确认
|
||||
</DialogButton>
|
||||
</DialogButtonGroup>
|
||||
</Dialog>
|
||||
</Portal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,64 +0,0 @@
|
||||
import { getUserProfile, User } from "@/lib/userAuth";
|
||||
import { notFound } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import { LogoutButton } from "./LogoutButton";
|
||||
import { numeric } from "yup-numeric";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import HeaderServer from "@/components/shell/HeaderServer";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
const uidSchema = numeric().integer().min(0);
|
||||
|
||||
interface SignupTimeProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
const SignupTime: React.FC<SignupTimeProps> = ({ user }: SignupTimeProps) => {
|
||||
return (
|
||||
<p className="mt-4">
|
||||
于
|
||||
{DateTime.fromJSDate(user.createdAt).toFormat("yyyy-MM-dd HH:mm:ss")}
|
||||
注册。
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export default async function ProfilePage({ params }: { params: Promise<{ uid: string }> }) {
|
||||
const { uid } = await params;
|
||||
const t = await getTranslations("profile_page");
|
||||
let parsedUID: number;
|
||||
|
||||
try {
|
||||
uidSchema.validate(uid);
|
||||
parsedUID = parseInt(uid);
|
||||
} catch (error) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const user = await getUserProfile(parsedUID);
|
||||
|
||||
if (!user) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const displayName = user.nickname || user.username;
|
||||
const loggedIn = user.isLoggedIn;
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderServer />
|
||||
<main className="md:w-xl lg:w-2xl xl:w-3xl md:mx-auto pt-6">
|
||||
<h1>
|
||||
<span className="text-4xl font-extralight">{displayName}</span>
|
||||
<span className="ml-2 text-on-surface-variant dark:text-dark-on-surface-variant">
|
||||
UID{user.uid}
|
||||
</span>
|
||||
</h1>
|
||||
<SignupTime user={user} />
|
||||
<p className="mt-4">权限组:{t(`role.${user.role}`)}</p>
|
||||
{loggedIn && <LogoutButton />}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,103 +0,0 @@
|
||||
import { Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Metadata } from "next";
|
||||
import type { VideoInfoData } from "@cvsa/core";
|
||||
import { DateTime } from "luxon";
|
||||
|
||||
const StatRow = ({ title, description }: { title: string; description?: number }) => {
|
||||
return (
|
||||
<div className="flex justify-between w-36">
|
||||
<span>{title}</span>
|
||||
<span>{description?.toLocaleString() ?? "N/A"}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||
const backendURL = process.env.BACKEND_URL;
|
||||
const { id } = await params;
|
||||
const res = await fetch(`${backendURL}/video/${id}/info`);
|
||||
if (!res.ok) {
|
||||
return {
|
||||
title: "页面未找到 - 中 V 档案馆"
|
||||
};
|
||||
}
|
||||
const data = await res.json();
|
||||
return {
|
||||
title: `${data.title} - 视频信息 - 中 V 档案馆`
|
||||
};
|
||||
}
|
||||
|
||||
const VideoInfo = async ({ id }: { id: string }) => {
|
||||
const backendURL = process.env.BACKEND_URL;
|
||||
|
||||
const res = await fetch(`${backendURL}/video/${id}/info`);
|
||||
|
||||
if (!res.ok) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const data: VideoInfoData = await res.json();
|
||||
|
||||
return (
|
||||
<div className="w-full lg:max-w-4xl lg:mx-auto lg:p-6 px-4">
|
||||
<h2 className="text-lg md:text-2xl mb-2">
|
||||
<Link href={`https://www.bilibili.com/video/${data.bvid}`}>{data.title || data.bvid}</Link>
|
||||
</h2>
|
||||
|
||||
<p className="text-sm md:text-base font-normal text-on-surface-variant dark:text-dark-on-surface-variant mb-4">
|
||||
<span>
|
||||
{data.bvid} · av{data.aid}
|
||||
</span>
|
||||
<br />
|
||||
<span>发布于 {DateTime.fromSeconds(data.pubdate).toFormat("yyyy-MM-dd HH:mm:ss")}</span>
|
||||
<br />
|
||||
<span>播放:{(data.stat?.view ?? 0).toLocaleString()}</span> ·{" "}
|
||||
<span>弹幕:{(data.stat?.danmaku ?? 0).toLocaleString()}</span>
|
||||
<br />
|
||||
<span>
|
||||
分区: {data.tname}, tid{data.tid} · v2: {data.tname_v2}, tid
|
||||
{data.tid_v2}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<img src={data.pic} referrerPolicy="no-referrer" className="rounded-lg" alt="Video cover" />
|
||||
|
||||
<h3 className="font-medium text-lg mt-6 mb-1">简介</h3>
|
||||
<pre className="max-w-full wrap-anywhere break-all text-on-surface-variant text-sm md:text-base whitespace-pre-wrap dark:text-dark-on-surface-variant font-zh">
|
||||
{data.desc}
|
||||
</pre>
|
||||
|
||||
<div className="mb-6 mt-4">
|
||||
<h2 className="mb-2 text-xl font-medium">统计数据</h2>
|
||||
<div className="flex flex-col gap-1">
|
||||
<StatRow title="播放" description={data.stat?.view} />
|
||||
<StatRow title="点赞" description={data.stat?.like} />
|
||||
<StatRow title="收藏" description={data.stat?.favorite} />
|
||||
<StatRow title="硬币" description={data.stat?.coin} />
|
||||
<StatRow title="评论" description={data.stat?.reply} />
|
||||
<StatRow title="弹幕" description={data.stat?.danmaku} />
|
||||
<StatRow title="分享" description={data.stat?.share} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default async function VideoPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return (
|
||||
<main className="flex flex-col items-center flex-grow gap-8 mt-10 md:mt-6 relative z-0 overflow-x-auto pb-8">
|
||||
<Suspense
|
||||
fallback={
|
||||
<main className="flex flex-col flex-grow items-center justify-center gap-8">
|
||||
<h1 className="text-4xl font-extralight">正在努力加载中……</h1>
|
||||
</main>
|
||||
}
|
||||
>
|
||||
<VideoInfo id={id} />
|
||||
</Suspense>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
import HeaderServer from "@/components/shell/HeaderServer";
|
||||
import React from "react";
|
||||
|
||||
export default async function RootLayout({
|
||||
children
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<>
|
||||
<HeaderServer />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./[locale]/global.css";
|
||||
import React from "react";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "中 V 档案馆"
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charSet="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</head>
|
||||
<body className="min-h-screen flex flex-col">
|
||||
<NextIntlClientProvider>
|
||||
{children}
|
||||
<div id="portal-root"></div>
|
||||
</NextIntlClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
import { NotFound } from "@/components/utils/404";
|
||||
|
||||
export default async function NotFoundPage() {
|
||||
return <NotFound />;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,51 +0,0 @@
|
||||
import useSWRMutation from "swr/mutation";
|
||||
import { useState } from "react";
|
||||
import type { CaptchaVerificationRawResponse, CaptchaSessionRawResponse } from "@cvsa/backend";
|
||||
import { fetcher } from "@/lib/net";
|
||||
import { computeVdfInWorker } from "@/lib/vdf";
|
||||
|
||||
interface UseCaptchaOptions {
|
||||
backendURL: string;
|
||||
route: string;
|
||||
}
|
||||
|
||||
export function useCaptcha({ backendURL, route }: UseCaptchaOptions) {
|
||||
const fullUrl = `${backendURL}/captcha/session`;
|
||||
const [isUsed, setIsUsed] = useState(false);
|
||||
|
||||
const { trigger, data, isMutating, error } = useSWRMutation<CaptchaVerificationRawResponse, Error>(
|
||||
fullUrl,
|
||||
async (url: string) => {
|
||||
const sessionRes = await fetcher<CaptchaSessionRawResponse>(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
data: { route }
|
||||
});
|
||||
|
||||
const { g, n, t, id } = sessionRes;
|
||||
if (!g || !n || !t || !id) {
|
||||
throw new Error("Missing required CAPTCHA parameters");
|
||||
}
|
||||
|
||||
const ans = await computeVdfInWorker(BigInt(g), BigInt(n), BigInt(t));
|
||||
|
||||
const resultUrl = new URL(`${backendURL}/captcha/${id}/result`);
|
||||
resultUrl.searchParams.set("ans", ans.result.toString());
|
||||
|
||||
const result = await fetcher<CaptchaVerificationRawResponse>(resultUrl.toString());
|
||||
setIsUsed(false);
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
startCaptcha: trigger,
|
||||
captchaResult: data,
|
||||
isLoadingCaptcha: isMutating,
|
||||
captchaError: error,
|
||||
captchaUsed: isUsed,
|
||||
setCaptchaUsedState: setIsUsed
|
||||
};
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export const AccountIcon: React.FC<React.HTMLAttributes<HTMLDivElement>> = (props) => (
|
||||
<div {...props}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.85 17.1q1.275-.975 2.85-1.537T12 15t3.3.563t2.85 1.537q.875-1.025 1.363-2.325T20 12q0-3.325-2.337-5.663T12 4T6.337 6.338T4 12q0 1.475.488 2.775T5.85 17.1M12 13q-1.475 0-2.488-1.012T8.5 9.5t1.013-2.488T12 6t2.488 1.013T15.5 9.5t-1.012 2.488T12 13m0 9q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
@ -1,12 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export const CloseIcon: React.FC<React.HTMLAttributes<HTMLDivElement>> = (props) => (
|
||||
<div {...props}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M6.4 19L5 17.6l5.6-5.6L5 6.4L6.4 5l5.6 5.6L17.6 5L19 6.4L13.4 12l5.6 5.6l-1.4 1.4l-5.6-5.6z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
@ -1,12 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export const HomeIcon: React.FC<React.HTMLAttributes<HTMLDivElement>> = (props) => (
|
||||
<div {...props}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M10 19v-5h4v5c0 .55.45 1 1 1h3c.55 0 1-.45 1-1v-7h1.7c.46 0 .68-.57.33-.87L12.67 3.6c-.38-.34-.96-.34-1.34 0l-8.36 7.53c-.34.3-.13.87.33.87H5v7c0 .55.45 1 1 1h3c.55 0 1-.45 1-1"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
@ -1,12 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export const InfoIcon: React.FC<React.HTMLAttributes<HTMLDivElement>> = (props) => (
|
||||
<div {...props}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M11 17h2v-6h-2zm1-8q.425 0 .713-.288T13 8t-.288-.712T12 7t-.712.288T11 8t.288.713T12 9m0 13q-2.075 0-3.9-.788t-3.175-2.137T2.788 15.9T2 12t.788-3.9t2.137-3.175T8.1 2.788T12 2t3.9.788t3.175 2.137T21.213 8.1T22 12t-.788 3.9t-2.137 3.175t-3.175 2.138T12 22"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
@ -1,13 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export const LeftArrow: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg {...props} width="1em" height="1em" viewBox="0 0 10.72 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
id="path"
|
||||
d="M10.64 4.34C10.69 4.23 10.72 4.11 10.72 3.97C10.72 3.79 10.67 3.65 10.59 3.55C10.55 3.51 10.5 3.46 10.46 3.44C10.39 3.4 10.31 3.38 10.22 3.38L2.16 3.38L4.57 0.97C4.61 0.9 4.7 0.73 4.7 0.63C4.7 0.6 4.7 0.57 4.7 0.52C4.67 0.4 4.6 0.28 4.5 0.16C4.39 0.06 4.29 0 4.17 -0.02C4.13 -0.04 4.09 -0.05 4.04 -0.05C3.95 -0.05 3.87 -0.02 3.81 0.01C3.76 0.04 3.73 0.06 3.7 0.09L0.22 3.58C0.07 3.72 0 3.85 0 3.97C0 4.09 0.07 4.23 0.22 4.38L3.7 7.87C3.82 7.95 3.93 8 4.04 8C4.18 7.98 4.37 7.91 4.5 7.79C4.6 7.68 4.67 7.56 4.7 7.44C4.7 7.4 4.7 7.36 4.7 7.32C4.7 7.26 4.7 7.21 4.67 7.16C4.66 7.1 4.62 7.04 4.57 7L2.16 4.59L10.22 4.59C10.3 4.59 10.37 4.57 10.43 4.54C10.49 4.51 10.59 4.4 10.64 4.34Z"
|
||||
fill="currentColor"
|
||||
fillOpacity="1"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@ -1,39 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
const LoadingSpinner: React.FC<React.HTMLAttributes<HTMLDivElement>> = (props) => (
|
||||
<div {...props}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<g stroke="currentColor" strokeWidth="1">
|
||||
<circle cx="12" cy="12" r="9.5" fill="none" strokeLinecap="round" strokeWidth="3">
|
||||
<animate
|
||||
attributeName="stroke-dasharray"
|
||||
calcMode="spline"
|
||||
dur="1.5s"
|
||||
keySplines="0.42,0,0.58,1;0.42,0,0.58,1;0.42,0,0.58,1"
|
||||
keyTimes="0;0.475;0.95;1"
|
||||
repeatCount="indefinite"
|
||||
values="0 150;42 150;42 150;42 150"
|
||||
/>
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
calcMode="spline"
|
||||
dur="1.5s"
|
||||
keySplines="0.42,0,0.58,1;0.42,0,0.58,1;0.42,0,0.58,1"
|
||||
keyTimes="0;0.475;0.95;1"
|
||||
repeatCount="indefinite"
|
||||
values="0;-16;-59;-59"
|
||||
/>
|
||||
</circle>
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
type="rotate"
|
||||
values="0 12 12;360 12 12"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default LoadingSpinner;
|
||||
@ -1,12 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export const LoginIcon: React.FC<React.HTMLAttributes<HTMLDivElement>> = (props) => (
|
||||
<div {...props}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M13 21q-.425 0-.712-.288T12 20t.288-.712T13 19h6V5h-6q-.425 0-.712-.288T12 4t.288-.712T13 3h6q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm-1.825-8H4q-.425 0-.712-.288T3 12t.288-.712T4 11h7.175L9.3 9.125q-.275-.275-.275-.675t.275-.7t.7-.313t.725.288L14.3 11.3q.3.3.3.7t-.3.7l-3.575 3.575q-.3.3-.712.288T9.3 16.25q-.275-.3-.262-.712t.287-.688z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
@ -1,15 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export const MenuIcon: React.FC<React.HTMLAttributes<HTMLDivElement>> = (props) => (
|
||||
<div {...props}>
|
||||
<svg width="1em" height="1em" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
id="path"
|
||||
d="M4.66 21C4.33 21 4.05 20.88 3.83 20.66C3.61 20.44 3.5 20.16 3.5 19.83C3.49 19.5 3.61 19.22 3.83 19C4.06 18.77 4.33 18.66 4.66 18.66L23.33 18.66C23.66 18.66 23.94 18.77 24.16 19C24.38 19.22 24.5 19.5 24.5 19.83C24.49 20.16 24.38 20.44 24.16 20.66C23.94 20.88 23.66 21 23.33 21L4.66 21ZM4.66 15.16C4.33 15.16 4.05 15.05 3.83 14.83C3.61 14.6 3.5 14.32 3.5 14C3.49 13.67 3.61 13.39 3.83 13.16C4.06 12.94 4.33 12.83 4.66 12.83L23.33 12.83C23.66 12.83 23.94 12.94 24.16 13.16C24.38 13.39 24.5 13.67 24.5 14C24.49 14.32 24.38 14.6 24.16 14.83C23.94 15.05 23.66 15.16 23.33 15.16L4.66 15.16ZM4.66 9.33C4.33 9.33 4.05 9.22 3.83 8.99C3.61 8.77 3.5 8.49 3.5 8.16C3.49 7.83 3.61 7.56 3.83 7.33C4.06 7.11 4.33 7 4.66 7L23.33 7C23.66 7 23.94 7.11 24.16 7.33C24.38 7.56 24.5 7.83 24.5 8.16C24.49 8.49 24.38 8.77 24.16 8.99C23.94 9.22 23.66 9.33 23.33 9.33L4.66 9.33Z"
|
||||
fill="currentColor"
|
||||
fillOpacity="1"
|
||||
fillRule="nonzero"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
@ -1,12 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export const RegisterIcon: React.FC<React.HTMLAttributes<HTMLDivElement>> = (props) => (
|
||||
<div {...props}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M15 14c-2.67 0-8 1.33-8 4v2h16v-2c0-2.67-5.33-4-8-4m-9-4V7H4v3H1v2h3v3h2v-3h3v-2m6 2a4 4 0 0 0 4-4a4 4 0 0 0-4-4a4 4 0 0 0-4 4a4 4 0 0 0 4 4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
@ -1,12 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export const RightArrow: React.FC<React.SVGProps<SVGSVGElement>> = (props) => (
|
||||
<svg {...props} width="1em" height="1em" viewBox="0 0 10.72 8" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M0.08 3.66Q0 3.82 0 4.03Q0 4.29 0.13 4.45Q0.13 4.46 0.13 4.46Q0.19 4.52 0.26 4.56Q0.36 4.62 0.49 4.62L8.56 4.62L6.15 7.03Q6.1 7.07 6.08 7.12Q6.01 7.22 6.01 7.36Q6.01 7.41 6.02 7.47Q6.06 7.66 6.22 7.83Q6.37 7.98 6.54 8.02Q6.61 8.04 6.67 8.04Q6.81 8.04 6.91 7.98Q6.97 7.95 7.01 7.9L10.5 4.42Q10.72 4.2 10.72 4.03Q10.72 3.84 10.5 3.62L7.01 0.13Q6.84 0 6.67 0Q6.64 0 6.61 0Q6.41 0.02 6.22 0.21Q6.06 0.37 6.02 0.56Q6.01 0.62 6.01 0.68Q6.01 0.76 6.04 0.84Q6.07 0.93 6.15 1L8.56 3.41L0.49 3.41Q0.38 3.41 0.29 3.46Q0.2 3.5 0.13 3.59Q0.1 3.63 0.08 3.66Z"
|
||||
fill="currentColor"
|
||||
fillOpacity="1"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@ -1,12 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
export const SearchIcon: React.FC<React.HTMLAttributes<HTMLDivElement>> = (props) => (
|
||||
<div {...props}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
@ -1,157 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import TitleLight from "@/public/icons/标题-浅色.svg";
|
||||
import TitleDark from "@/public/icons/标题-深色.svg";
|
||||
import LogoMobileLight from "@/public/icons/TitleBar Mobile Light.svg";
|
||||
import LogoMobileDark from "@/public/icons/TitleBar Mobile Dark.svg";
|
||||
import DarkModeImage from "@/components/utils/DarkModeImage";
|
||||
import React, { useState } from "react";
|
||||
import { NavigationDrawer } from "@/components/ui/NavigatinDrawer";
|
||||
import { Portal } from "@/components/utils/Portal";
|
||||
import { SearchBox } from "@/components/ui/SearchBox";
|
||||
import { MenuIcon } from "@/components/icons/MenuIcon";
|
||||
import { SearchIcon } from "@/components/icons/SearchIcon";
|
||||
import { InfoIcon } from "@/components/icons/InfoIcon";
|
||||
import { HomeIcon } from "@/components/icons/HomeIcon";
|
||||
import { TextButton } from "@/components/ui/Buttons/TextButton";
|
||||
import { Link } from "@/i18n/navigation";
|
||||
import { LoginIcon } from "../icons/LoginIcon";
|
||||
import { AccountIcon } from "../icons/AccountIcon";
|
||||
import { User } from "@/lib/userAuth";
|
||||
|
||||
interface HeaderProps {
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
export const HeaderDestop = ({ user }: HeaderProps) => {
|
||||
return (
|
||||
<div className="hidden md:flex relative top-0 left-0 w-full h-28 z-20 justify-between">
|
||||
<div className="w-[305px] xl:ml-8 inline-flex items-center">
|
||||
<Link href="/">
|
||||
<DarkModeImage
|
||||
lightSrc={TitleLight}
|
||||
darkSrc={TitleDark}
|
||||
alt="logo"
|
||||
className="w-[305px] h-24 inline-block max-w-[15rem] lg:max-w-[305px]"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<SearchBox />
|
||||
|
||||
<div
|
||||
className="inline-flex relative gap-6 h-full lg:right-12
|
||||
text-xl font-medium items-center w-[15rem] min-w-[8rem] mr-4 lg:mr-0 lg:w-[305px] justify-end"
|
||||
>
|
||||
{user ? (
|
||||
<Link href={`/user/${user.uid}/profile`}>{user.nickname || user.username}</Link>
|
||||
) : (
|
||||
<Link href="/login">登录</Link>
|
||||
)}
|
||||
|
||||
<Link href="/about">关于</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const HeaderMobile = ({ user }: HeaderProps) => {
|
||||
const [showDrawer, setShowDrawer] = useState(false);
|
||||
const [showsearchBox, setShowsearchBox] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Portal>
|
||||
<NavigationDrawer show={showDrawer} onClose={() => setShowDrawer(false)}>
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<div className="w-full h-14 flex items-center px-4 mt-3 pl-6">
|
||||
<DarkModeImage
|
||||
lightSrc={LogoMobileLight}
|
||||
darkSrc={LogoMobileDark}
|
||||
alt="Logo"
|
||||
className="w-30 h-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Link href="/">
|
||||
<TextButton className="w-full h-14 flex px-4 justify-start" size="m">
|
||||
<div className="flex items-center">
|
||||
<HomeIcon className="text-2xl pr-4" />
|
||||
<span>首页</span>
|
||||
</div>
|
||||
</TextButton>
|
||||
</Link>
|
||||
<Link href="/about">
|
||||
<TextButton className="w-full h-14 flex px-4 justify-start" size="m">
|
||||
<div className="flex items-center">
|
||||
<InfoIcon className="text-2xl pr-4" />
|
||||
<span>关于</span>
|
||||
</div>
|
||||
</TextButton>
|
||||
</Link>
|
||||
|
||||
{user ? (
|
||||
<Link href={`/user/${user.uid}/profile`}>
|
||||
<TextButton className="w-full h-14 flex justify-start" size="m">
|
||||
<div className="flex items-center w-72">
|
||||
<AccountIcon className="text-2xl pr-4" />
|
||||
<span>{user.nickname || user.username}</span>
|
||||
</div>
|
||||
</TextButton>
|
||||
</Link>
|
||||
) : (
|
||||
<Link href="/login">
|
||||
<TextButton className="w-full h-14 flex px-4 justify-start" size="m">
|
||||
<div className="flex items-center w-72">
|
||||
<LoginIcon className="text-2xl pr-4" />
|
||||
<span>登录</span>
|
||||
</div>
|
||||
</TextButton>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</NavigationDrawer>
|
||||
</Portal>
|
||||
<div className="md:hidden relative top-0 left-0 w-full h-16 z-20">
|
||||
{!showsearchBox && (
|
||||
<button
|
||||
className="inline-flex absolute left-0 ml-4 h-full items-center dark:text-white text-2xl"
|
||||
onClick={() => setShowDrawer(true)}
|
||||
>
|
||||
<MenuIcon />
|
||||
</button>
|
||||
)}
|
||||
{!showsearchBox && (
|
||||
<div className="absolute left-1/2 -translate-x-1/2 -translate-y-0.5 inline-flex h-full items-center">
|
||||
<Link href="/">
|
||||
<DarkModeImage
|
||||
lightSrc={LogoMobileLight}
|
||||
darkSrc={LogoMobileDark}
|
||||
alt="Logo"
|
||||
className="w-24 h-8 translate-y-[2px]"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{showsearchBox && <SearchBox close={() => setShowsearchBox(false)} />}
|
||||
{!showsearchBox && (
|
||||
<button
|
||||
className="inline-flex absolute right-0 h-full items-center mr-4"
|
||||
onClick={() => setShowsearchBox(!showsearchBox)}
|
||||
>
|
||||
<SearchIcon className="text-[1.625rem]" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Header = (props: HeaderProps) => {
|
||||
return (
|
||||
<>
|
||||
<HeaderDestop {...props} />
|
||||
<HeaderMobile {...props} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,7 +0,0 @@
|
||||
import { Header } from "@/components/shell/Header";
|
||||
import { getCurrentUser } from "@/lib/userAuth";
|
||||
|
||||
export default async function HeaderServer() {
|
||||
const user = await getCurrentUser();
|
||||
return <Header user={user} />;
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import useRipple from "@/components/utils/useRipple";
|
||||
|
||||
interface FilledButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
size?: "xs" | "s" | "m" | "l" | "xl";
|
||||
shape?: "round" | "square";
|
||||
children?: React.ReactNode;
|
||||
ripple?: boolean;
|
||||
}
|
||||
|
||||
export const FilledButton = ({
|
||||
children,
|
||||
size = "s",
|
||||
shape = "round",
|
||||
className,
|
||||
ripple = true,
|
||||
...rest
|
||||
}: FilledButtonProps) => {
|
||||
let sizeClasses = "text-sm leading-5 h-10 px-4";
|
||||
let shapeClasses = shape === "round" ? "rounded-full" : "rounded-xl";
|
||||
|
||||
if (size === "m") {
|
||||
sizeClasses = "text-base leading-6 h-14 px-6";
|
||||
shapeClasses = shape === "round" ? "rounded-full" : "rounded-2xl";
|
||||
}
|
||||
|
||||
const { onMouseDown, onTouchStart } = useRipple({ ripple });
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`bg-primary dark:bg-dark-primary text-on-primary dark:text-dark-on-primary duration-150 select-none
|
||||
flex items-center justify-center relative overflow-hidden
|
||||
${sizeClasses} ${shapeClasses} ${className}`}
|
||||
{...rest}
|
||||
onMouseDown={onMouseDown}
|
||||
onTouchStart={onTouchStart}
|
||||
>
|
||||
<div className="absolute w-full h-full hover:bg-on-surface-variant/10"></div>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@ -1,53 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import useRipple from "@/components/utils/useRipple";
|
||||
|
||||
interface TextButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
size?: "xs" | "s" | "m" | "l" | "xl";
|
||||
shape?: "round" | "square";
|
||||
children?: React.ReactNode;
|
||||
ripple?: boolean;
|
||||
ref?: React.Ref<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
export const TextButton = ({
|
||||
children,
|
||||
size = "s",
|
||||
shape = "round",
|
||||
className = "",
|
||||
disabled,
|
||||
ref,
|
||||
ripple = true,
|
||||
...rest
|
||||
}: TextButtonProps) => {
|
||||
let sizeClasses = "text-sm leading-5 h-10 px-4";
|
||||
let shapeClasses = "rounded-full";
|
||||
|
||||
if (size === "m") {
|
||||
sizeClasses = "text-base leading-6 h-14 px-6";
|
||||
shapeClasses = shape === "round" ? "rounded-full" : "rounded-2xl";
|
||||
}
|
||||
|
||||
const { onMouseDown, onTouchStart } = useRipple({ ripple });
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`text-primary dark:text-dark-primary duration-150 select-none
|
||||
flex items-center justify-center relative overflow-hidden
|
||||
disabled:text-on-surface/40 disabled:dark:text-dark-on-surface/40
|
||||
${sizeClasses} ${shapeClasses} ${className}`}
|
||||
{...rest}
|
||||
onMouseDown={onMouseDown}
|
||||
onTouchStart={onTouchStart}
|
||||
disabled={disabled}
|
||||
ref={ref}
|
||||
>
|
||||
<div
|
||||
className={`absolute w-full h-full enabled:hover:bg-primary/10 enabled:dark:hover:bg-dark-primary/10
|
||||
${disabled && "bg-on-surface/10 dark:bg-dark-on-surface/10"}
|
||||
left-0 top-0`}
|
||||
></div>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@ -1,252 +0,0 @@
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import React, { useRef } from "react";
|
||||
import { TextButton } from "./Buttons/TextButton";
|
||||
import { useEffect, useState } from "react";
|
||||
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { useKeyboardShortcuts } from "@/components/utils/useKeyboardEvents";
|
||||
import { UAParser } from "ua-parser-js";
|
||||
|
||||
const focusedButtonAtom = atom(-1);
|
||||
|
||||
export const useDisableBodyScroll = (open: boolean) => {
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "unset";
|
||||
}
|
||||
}, [open]);
|
||||
};
|
||||
|
||||
type OptionalChidrenProps<T = React.HTMLAttributes<HTMLElement>> = T & {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
type HeadElementAttr = React.HTMLAttributes<HTMLHeadElement>;
|
||||
type DivElementAttr = React.HTMLAttributes<HTMLDivElement>;
|
||||
type ButtonElementAttr = React.HTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
type DialogHeadlineProps = OptionalChidrenProps<HeadElementAttr>;
|
||||
type DialogSupportingTextProps = OptionalChidrenProps<DivElementAttr>;
|
||||
type DialogButtonGroupProps = DivElementAttr & {
|
||||
children: React.ReactElement<DialogButtonProps> | React.ReactElement<DialogButtonProps>[];
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
interface DialogButtonProps extends OptionalChidrenProps<ButtonElementAttr> {
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
index?: number;
|
||||
}
|
||||
interface DialogProps extends OptionalChidrenProps<DivElementAttr> {
|
||||
show: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DialogHeadline: React.FC<DialogHeadlineProps> = ({
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: DialogHeadlineProps) => {
|
||||
return (
|
||||
<h2 className={"text-2xl leading-8 text-on-surface dark:text-dark-on-surface " + className || ""} {...rest}>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
};
|
||||
|
||||
export const DialogSupportingText: React.FC<DialogSupportingTextProps> = ({
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: DialogHeadlineProps) => {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"mt-4 text-sm leading-5 mb-6 text-on-surface-variant dark:text-dark-on-surface-variant " + className ||
|
||||
""
|
||||
}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DialogButton: React.FC<DialogButtonProps> = ({ children, onClick, index, ...rest }: DialogButtonProps) => {
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const focusedButton = useAtomValue(focusedButtonAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (!buttonRef.current) return;
|
||||
if (focusedButton === index) buttonRef.current.focus();
|
||||
}, [focusedButton]);
|
||||
|
||||
return (
|
||||
<TextButton onClick={onClick} {...rest} ref={buttonRef}>
|
||||
{children}
|
||||
</TextButton>
|
||||
);
|
||||
};
|
||||
|
||||
export const DialogButtonGroup: React.FC<DialogButtonGroupProps> = ({
|
||||
children,
|
||||
close,
|
||||
...rest
|
||||
}: DialogButtonGroupProps) => {
|
||||
const [focusedButton, setFocusedButton] = useAtom(focusedButtonAtom);
|
||||
const count = React.Children.count(children);
|
||||
|
||||
useKeyboardShortcuts([
|
||||
{
|
||||
key: "Tab",
|
||||
callback: () => {
|
||||
setFocusedButton((focusedButton + 1) % count);
|
||||
},
|
||||
preventDefault: true
|
||||
},
|
||||
{
|
||||
key: "Escape",
|
||||
callback: close,
|
||||
preventDefault: true
|
||||
}
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-end gap-2" {...rest}>
|
||||
{React.Children.map(children, (child, index) => {
|
||||
if (React.isValidElement<DialogButtonProps>(child) && child.type === DialogButton) {
|
||||
return React.cloneElement(child, {
|
||||
index: index
|
||||
});
|
||||
}
|
||||
return child;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const useCompabilityCheck = () => {
|
||||
const [supported, setSupported] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const parser = new UAParser(navigator.userAgent);
|
||||
const result = parser.getResult();
|
||||
|
||||
const { name: browserName, version: browserVersion } = result.browser;
|
||||
|
||||
let isSupported = false;
|
||||
|
||||
if (!browserVersion) {
|
||||
return;
|
||||
}
|
||||
const [major] = browserVersion.split(".").map(Number);
|
||||
|
||||
switch (browserName) {
|
||||
case "Chromium":
|
||||
isSupported = major >= 107;
|
||||
break;
|
||||
case "Firefox":
|
||||
isSupported = major >= 66;
|
||||
break;
|
||||
case "Safari":
|
||||
isSupported = major >= 16;
|
||||
break;
|
||||
default:
|
||||
isSupported = false;
|
||||
break;
|
||||
}
|
||||
|
||||
setSupported(isSupported);
|
||||
}, []);
|
||||
|
||||
return supported;
|
||||
};
|
||||
|
||||
export const Dialog: React.FC<DialogProps> = ({ show, children, className }: DialogProps) => {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const setFocusedButton = useSetAtom(focusedButtonAtom);
|
||||
const isSupported = useCompabilityCheck();
|
||||
|
||||
useEffect(() => {
|
||||
if (!contentRef.current || !dialogRef.current) return;
|
||||
|
||||
const contentHeight = contentRef.current.offsetHeight;
|
||||
const halfSize = (contentHeight + 48) / 2;
|
||||
dialogRef.current.style.top = `calc(50% - ${halfSize}px)`;
|
||||
|
||||
if (!isSupported) {
|
||||
return;
|
||||
}
|
||||
|
||||
dialogRef.current.style.transition = "grid-template-rows cubic-bezier(0.05, 0.7, 0.1, 1.0) 0.35s";
|
||||
|
||||
if (show) {
|
||||
dialogRef.current.style.gridTemplateRows = "1fr";
|
||||
} else {
|
||||
dialogRef.current.style.gridTemplateRows = "0.6fr";
|
||||
}
|
||||
}, [show]);
|
||||
|
||||
useEffect(() => {
|
||||
setFocusedButton(-1);
|
||||
}, [show]);
|
||||
|
||||
useDisableBodyScroll(show);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<div className="w-full h-full top-0 left-0 absolute flex justify-center">
|
||||
<motion.div
|
||||
className="fixed top-0 left-0 w-full h-full z-40 bg-black/20 pointer-none"
|
||||
aria-hidden="true"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.35 }}
|
||||
/>
|
||||
<motion.div
|
||||
className={`fixed min-w-[17.5rem] sm:max-w-[35rem] h-auto z-50 bg-surface-container-high
|
||||
shadow-2xl shadow-shadow/15 rounded-[1.75rem] p-6 dark:bg-dark-surface-container-high mx-2
|
||||
origin-top ${className} overflow-hidden grid ${isSupported && "grid-rows-[0fr]"}`}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
transform: "translateY(-24px)",
|
||||
gridTemplateRows: isSupported ? undefined : "0fr"
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
transform: "translateY(0px)",
|
||||
gridTemplateRows: isSupported ? undefined : "1fr"
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
transform: "translateY(-24px)",
|
||||
gridTemplateRows: isSupported ? undefined : "0fr"
|
||||
}}
|
||||
transition={{ ease: [0.05, 0.7, 0.1, 1.0], duration: 0.35 }}
|
||||
aria-modal="true"
|
||||
ref={dialogRef}
|
||||
>
|
||||
<div className="min-h-0">
|
||||
<motion.div
|
||||
className="origin-top"
|
||||
initial={{ opacity: 0, transform: "translateY(5px)" }}
|
||||
animate={{ opacity: 1, transform: "translateY(0px)" }}
|
||||
exit={{ opacity: 0, transform: "translateY(5px)" }}
|
||||
transition={{
|
||||
ease: [0.05, 0.7, 0.1, 1.0],
|
||||
duration: 0.35
|
||||
}}
|
||||
ref={contentRef}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
@ -1,62 +0,0 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface DrawerProps {
|
||||
show?: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const NavigationDrawer = ({ show = false, onClose, children }: DrawerProps) => {
|
||||
const scrimRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOutsideClick = (event: MouseEvent) => {
|
||||
if (show && scrimRef.current && event.target === scrimRef.current) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("click", handleOutsideClick);
|
||||
return () => {
|
||||
window.removeEventListener("click", handleOutsideClick);
|
||||
};
|
||||
}, [show, onClose]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<>
|
||||
{/* Scrim - Fade in/out */}
|
||||
<motion.div
|
||||
ref={scrimRef}
|
||||
className="fixed top-0 left-0 w-full h-full z-40 bg-black/10"
|
||||
aria-hidden="true"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Drawer - Slide from left */}
|
||||
<motion.div
|
||||
className="fixed top-0 left-0 h-full bg-surface-container-low dark:bg-dark-surface-container-low
|
||||
z-50 rounded-r-2xl"
|
||||
style={{ width: "min(22.5rem, 70vw)" }}
|
||||
initial={{ x: -500, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: -500, opacity: 0 }}
|
||||
transition={{ duration: 0.25, ease: ["easeOut", "easeOut"] }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavigationDrawer;
|
||||
@ -1,92 +0,0 @@
|
||||
import React, { useState, useRef, useCallback } from "react";
|
||||
import { SearchIcon } from "@/components/icons/SearchIcon";
|
||||
import { CloseIcon } from "@/components/icons/CloseIcon";
|
||||
|
||||
interface SearchBoxProps {
|
||||
close?: () => void;
|
||||
}
|
||||
|
||||
export const SearchBox: React.FC<SearchBoxProps> = ({ close = () => {} }) => {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const inputElement = useRef<HTMLInputElement>(null);
|
||||
|
||||
const search = useCallback((query: string) => {
|
||||
if (query.trim()) {
|
||||
window.location.href = `/song/${query.trim()}/info`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
search(inputValue);
|
||||
}
|
||||
},
|
||||
[inputValue, search]
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setInputValue("");
|
||||
close();
|
||||
}, [close]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute md:relative left-0 h-full mr-0 inline-flex items-center w-full px-4
|
||||
md:px-0 md:w-full xl:max-w-[50rem] md:mx-4"
|
||||
>
|
||||
<div
|
||||
className="w-full h-10 lg:h-12 px-4 rounded-full bg-surface-container-high
|
||||
dark:bg-dark-surface-container-high backdrop-blur-lg flex justify-between md:px-5"
|
||||
>
|
||||
<button className="w-6" onClick={() => search(inputValue)}>
|
||||
<SearchIcon
|
||||
className="h-full inline-flex items-center text-[1.5rem]
|
||||
text-on-surface-variant dark:text-dark-on-surface-variant"
|
||||
/>
|
||||
</button>
|
||||
<div className="md:hidden flex-grow px-4 top-0 h-full">
|
||||
<input
|
||||
ref={inputElement}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
type="search"
|
||||
placeholder="搜索"
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
className="bg-transparent h-full w-full focus:outline-none"
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden md:block flex-grow px-4 top-0 h-full">
|
||||
<input
|
||||
ref={inputElement}
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
type="search"
|
||||
placeholder="搜索"
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
className="bg-transparent h-full w-full focus:outline-none"
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={`w-6 duration-100 ${inputValue ? "md:opacity-100" : "md:opacity-0"}`}
|
||||
onClick={handleClear}
|
||||
>
|
||||
<CloseIcon className="h-full w-6 inline-flex items-center text-[1.5rem] text-on-surface-variant dark:text-dark-on-surface-variant" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user