mirror of
https://github.com/AnimeThemes/animethemes-web.git
synced 2026-07-11 01:24:31 +02:00
feat: Added Prettier (#220)
* Reformated all files with Prettier. * Updated GraphQL codegen. * Updated ESLint (including rule config). * Updated TypeScript. * Eliminated most ESLint warnings (especially usage of the `any` type). * Rewrote parts of the API resolver logic to be more type-safe. * Added IP forwarding for API requests.
This commit is contained in:
+22
-75
@@ -1,44 +1,11 @@
|
||||
{
|
||||
"extends": [
|
||||
"next/core-web-vitals",
|
||||
"plugin:react/recommended",
|
||||
"eslint:recommended"
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"src/generated"
|
||||
],
|
||||
"plugins": ["simple-import-sort"],
|
||||
"plugins": ["@typescript-eslint", "simple-import-sort"],
|
||||
"extends": ["eslint:recommended", "next/core-web-vitals", "plugin:@typescript-eslint/recommended", "prettier"],
|
||||
"parserOptions": {
|
||||
"project": ["./tsconfig.json"]
|
||||
},
|
||||
"ignorePatterns": ["src/generated"],
|
||||
"rules": {
|
||||
"semi": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"quotes": [
|
||||
"error",
|
||||
"double",
|
||||
{
|
||||
"allowTemplateLiterals": true
|
||||
}
|
||||
],
|
||||
"curly": [
|
||||
"error",
|
||||
"all"
|
||||
],
|
||||
"object-curly-spacing": [
|
||||
"error",
|
||||
"always"
|
||||
],
|
||||
"indent": [
|
||||
"error",
|
||||
4,
|
||||
{
|
||||
"SwitchCase": 1
|
||||
}
|
||||
],
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/self-closing-comp": "error",
|
||||
// For now we don't habe prop-types validation
|
||||
"react/prop-types": "off",
|
||||
// Sort imports
|
||||
"simple-import-sort/imports": [
|
||||
"error",
|
||||
@@ -56,41 +23,21 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"simple-import-sort/exports": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": [
|
||||
"**/*.{ts,tsx}"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"extends": [
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"parserOptions": {
|
||||
"project": [
|
||||
"./tsconfig.json"
|
||||
]
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/no-empty-interface": [
|
||||
"error",
|
||||
{
|
||||
"allowSingleExtends": true
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{
|
||||
"fixStyle": "inline-type-imports"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/consistent-type-exports": "error"
|
||||
"simple-import-sort/exports": "error",
|
||||
// TypeScript rules
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/no-empty-interface": [
|
||||
"error",
|
||||
{
|
||||
"allowSingleExtends": true
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{
|
||||
"fixStyle": "inline-type-imports"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/consistent-type-exports": "error"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
npm run graphql-codegen
|
||||
git add src/generated
|
||||
npm run lint
|
||||
npm run type-check
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"printWidth": 120
|
||||
}
|
||||
@@ -86,12 +86,12 @@ For more information on environment variables see the [Next.js documentation](ht
|
||||
|
||||
## Used technologies
|
||||
|
||||
- [Next.js](https://www.nextjs.org/)
|
||||
- [styled-components](https://styled-components.com/)
|
||||
- [react-query](https://react-query.tanstack.com/)
|
||||
- [Font Awesome](https://fontawesome.com/)
|
||||
- ...some other small packages, see the `package.json`.
|
||||
- [Next.js](https://www.nextjs.org/)
|
||||
- [styled-components](https://styled-components.com/)
|
||||
- [react-query](https://react-query.tanstack.com/)
|
||||
- [Font Awesome](https://fontawesome.com/)
|
||||
- ...some other small packages, see the `package.json`.
|
||||
|
||||
### APIs
|
||||
|
||||
- [AnimeThemes API](https://api-docs.animethemes.moe/)
|
||||
- [AnimeThemes API](https://api-docs.animethemes.moe/)
|
||||
|
||||
+47
-9
@@ -7,17 +7,10 @@ const config: CodegenConfig = {
|
||||
"src/lib/server/animebracket/type-defs.ts",
|
||||
"src/lib/client/search.ts",
|
||||
],
|
||||
documents: [
|
||||
"src/**/*.js",
|
||||
"src/**/*.ts",
|
||||
"src/**/*.tsx",
|
||||
],
|
||||
documents: ["src/**/*.js", "src/**/*.ts", "src/**/*.tsx"],
|
||||
generates: {
|
||||
"src/generated/graphql.ts": {
|
||||
plugins: [
|
||||
"typescript",
|
||||
"typescript-operations",
|
||||
],
|
||||
plugins: ["typescript", "typescript-operations"],
|
||||
config: {
|
||||
avoidOptionals: {
|
||||
object: true,
|
||||
@@ -26,8 +19,53 @@ const config: CodegenConfig = {
|
||||
},
|
||||
enumsAsTypes: true,
|
||||
skipTypename: true,
|
||||
useTypeImports: true,
|
||||
},
|
||||
},
|
||||
"src/generated/graphql-resolvers.ts": {
|
||||
plugins: ["typescript", "typescript-resolvers"],
|
||||
config: {
|
||||
useTypeImports: true,
|
||||
// This fixes an issue with the Maybe type, leading to unclear error messages.
|
||||
// Taken from here: https://github.com/dotansimha/graphql-code-generator/issues/3174#issuecomment-595398571
|
||||
maybeValue: "T extends PromiseLike<infer U> ? Promise<U | null> : T | null",
|
||||
mappers: {
|
||||
Anime: "@/lib/common/animethemes/types#ApiAnime",
|
||||
Announcement: "@/lib/common/animethemes/types#ApiAnnouncement",
|
||||
Artist: "@/lib/common/animethemes/types#ApiArtist",
|
||||
Audio: "@/lib/common/animethemes/types#ApiAudio",
|
||||
Dump: "@/lib/common/animethemes/types#ApiDump",
|
||||
Entry: "@/lib/common/animethemes/types#ApiEntry",
|
||||
FeaturedTheme: "@/lib/common/animethemes/types#ApiFeaturedTheme",
|
||||
Group: "@/lib/common/animethemes/types#ApiGroup",
|
||||
Page: "@/lib/common/animethemes/types#ApiPage",
|
||||
Performance: "@/lib/common/animethemes/types#ApiPerformance",
|
||||
Playlist: "@/lib/common/animethemes/types#ApiPlaylist",
|
||||
PlaylistTrack: "@/lib/common/animethemes/types#ApiPlaylistTrack",
|
||||
Season: "@/lib/common/animethemes/types#ApiSeason",
|
||||
Series: "@/lib/common/animethemes/types#ApiSeries",
|
||||
Song: "@/lib/common/animethemes/types#ApiSong",
|
||||
Studio: "@/lib/common/animethemes/types#ApiStudio",
|
||||
Synonym: "@/lib/common/animethemes/types#ApiSynonym",
|
||||
Theme: "@/lib/common/animethemes/types#ApiTheme",
|
||||
UserAuth: "@/lib/common/animethemes/types#ApiUser",
|
||||
UserPublic: "@/lib/common/animethemes/types#ApiUser",
|
||||
UserRole: "@/lib/common/animethemes/types#ApiUserRole",
|
||||
UserScopedQuery: "{}",
|
||||
Video: "@/lib/common/animethemes/types#ApiVideo",
|
||||
VideoOverlap: "string",
|
||||
VideoScript: "@/lib/common/animethemes/types#ApiVideoScript",
|
||||
Year: "@/lib/common/animethemes/types#ApiYear",
|
||||
Bracket: "@/lib/server/animebracket/resolvers#ModelBracket",
|
||||
BracketRound: "@/lib/server/animebracket/resolvers#ModelBracketRound",
|
||||
BracketPairing: "@/lib/server/animebracket/resolvers#ModelBracketPairing",
|
||||
BracketCharacter: "@/lib/server/animebracket/resolvers#ModelBracketCharacter",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
afterAllFileWrite: ["prettier --write"],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
+5
-5
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
"compilerOptions": {
|
||||
"baseUrl": "src",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-14
@@ -22,7 +22,7 @@ const nextConfig: NextConfig = {
|
||||
basePath: BASE_PATH,
|
||||
reactStrictMode: true,
|
||||
compiler: {
|
||||
styledComponents: true
|
||||
styledComponents: true,
|
||||
},
|
||||
staticPageGenerationTimeout: 3600,
|
||||
experimental: {
|
||||
@@ -31,9 +31,7 @@ const nextConfig: NextConfig = {
|
||||
workerThreads: false,
|
||||
cpus: 1,
|
||||
},
|
||||
transpilePackages: [
|
||||
"ahooks"
|
||||
],
|
||||
transpilePackages: ["ahooks"],
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
@@ -41,30 +39,30 @@ const nextConfig: NextConfig = {
|
||||
headers: [
|
||||
{
|
||||
key: "X-DNS-Prefetch-Control",
|
||||
value: "on"
|
||||
value: "on",
|
||||
},
|
||||
{
|
||||
key: "Strict-Transport-Security",
|
||||
value: "max-age=63072000; includeSubDomains; preload"
|
||||
value: "max-age=63072000; includeSubDomains; preload",
|
||||
},
|
||||
{
|
||||
key: "X-XSS-Protection",
|
||||
value: "1; mode=block"
|
||||
value: "1; mode=block",
|
||||
},
|
||||
{
|
||||
key: "X-Frame-Options",
|
||||
value: "SAMEORIGIN"
|
||||
value: "SAMEORIGIN",
|
||||
},
|
||||
{
|
||||
key: "X-Content-Type-Options",
|
||||
value: "nosniff"
|
||||
value: "nosniff",
|
||||
},
|
||||
{
|
||||
key: "Referrer-Policy",
|
||||
value: "origin-when-cross-origin"
|
||||
}
|
||||
]
|
||||
}
|
||||
value: "origin-when-cross-origin",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
async redirects() {
|
||||
@@ -80,7 +78,7 @@ const nextConfig: NextConfig = {
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default withBundleAnalyzer(nextConfig);
|
||||
|
||||
Generated
+23000
-21459
File diff suppressed because it is too large
Load Diff
+81
-79
@@ -1,81 +1,83 @@
|
||||
{
|
||||
"name": "animethemes-web",
|
||||
"version": "3.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"predev": "npm run compile-config",
|
||||
"prebuild": "npm run compile-config",
|
||||
"prestart": "npm run compile-config",
|
||||
"compile-config": "esbuild next.config.ts --bundle --outfile=next.config.mjs --platform=node --format=esm --external:./node_modules/* --target=esnext",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc",
|
||||
"graphql-codegen": "graphql-codegen --config codegen.ts",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.5.2",
|
||||
"@fortawesome/pro-solid-svg-icons": "^6.5.2",
|
||||
"@graphql-tools/merge": "^8.2.1",
|
||||
"@graphql-tools/schema": "^8.2.0",
|
||||
"@graphql-tools/utils": "^8.6.5",
|
||||
"@next/bundle-analyzer": "^12.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.4",
|
||||
"@radix-ui/react-select": "^1.2.0",
|
||||
"@radix-ui/react-slider": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.0.1",
|
||||
"ahooks": "^3.7.6",
|
||||
"axios": "^1.2.2",
|
||||
"common-tags": "^1.8.0",
|
||||
"framer-motion": "^11.2.4",
|
||||
"graphql": "^15.8.0",
|
||||
"graphql-parse-resolve-info": "^4.12.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"hast-util-has-property": "^2.0.1",
|
||||
"hast-util-heading-rank": "^2.1.1",
|
||||
"hast-util-to-string": "^2.0.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"md5": "^2.3.0",
|
||||
"next": "^13.3.0",
|
||||
"next-mdx-remote": "^4.3.0",
|
||||
"p-limit": "^3.1.0",
|
||||
"picocolors": "^1.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-query": "^3.29.0",
|
||||
"rehype-pretty-code": "^0.9.4",
|
||||
"rehype-slug": "^5.1.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"sass": "^1.43.4",
|
||||
"shiki": "^0.14.1",
|
||||
"styled-components": "^5.3.3",
|
||||
"swr": "^2.2.4",
|
||||
"unist-util-visit": "^4.1.2",
|
||||
"use-local-storage-state": "^18.3.0",
|
||||
"use-session-storage-state": "^18.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "^2.9.1",
|
||||
"@graphql-codegen/typescript": "2.5.0",
|
||||
"@graphql-codegen/typescript-operations": "2.4.1",
|
||||
"@types/common-tags": "^1.8.1",
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/md5": "^2.3.2",
|
||||
"@types/node": "^17.0.41",
|
||||
"@types/react": "^18.0.12",
|
||||
"@types/styled-components": "^5.1.25",
|
||||
"@typescript-eslint/eslint-plugin": "^5.27.1",
|
||||
"@typescript-eslint/parser": "^5.27.1",
|
||||
"esbuild": "^0.21.3",
|
||||
"eslint": "7.32.0",
|
||||
"eslint-config-next": "^13.3.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.0",
|
||||
"husky": "^9.0.11",
|
||||
"ts-node": "^10.8.1",
|
||||
"typescript": "^4.9.5"
|
||||
}
|
||||
"name": "animethemes-web",
|
||||
"version": "3.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"predev": "npm run compile-config",
|
||||
"prebuild": "npm run compile-config",
|
||||
"prestart": "npm run compile-config",
|
||||
"compile-config": "esbuild next.config.ts --bundle --outfile=next.config.mjs --platform=node --format=esm --external:./node_modules/* --target=esnext",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc",
|
||||
"graphql-codegen": "graphql-codegen --config codegen.ts",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
||||
"@fortawesome/free-brands-svg-icons": "^6.5.2",
|
||||
"@fortawesome/pro-solid-svg-icons": "^6.5.2",
|
||||
"@graphql-tools/merge": "^8.2.1",
|
||||
"@graphql-tools/schema": "^8.2.0",
|
||||
"@graphql-tools/utils": "^8.6.5",
|
||||
"@next/bundle-analyzer": "^12.0.3",
|
||||
"@radix-ui/react-dialog": "^1.0.2",
|
||||
"@radix-ui/react-dropdown-menu": "^2.0.4",
|
||||
"@radix-ui/react-select": "^1.2.0",
|
||||
"@radix-ui/react-slider": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.0.1",
|
||||
"ahooks": "^3.7.6",
|
||||
"axios": "^1.2.2",
|
||||
"common-tags": "^1.8.0",
|
||||
"framer-motion": "^11.2.4",
|
||||
"graphql": "^15.8.0",
|
||||
"graphql-parse-resolve-info": "^4.12.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"hast-util-has-property": "^2.0.1",
|
||||
"hast-util-heading-rank": "^2.1.1",
|
||||
"hast-util-to-string": "^2.0.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"md5": "^2.3.0",
|
||||
"next": "^13.3.0",
|
||||
"next-mdx-remote": "^4.3.0",
|
||||
"p-limit": "^3.1.0",
|
||||
"picocolors": "^1.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-query": "^3.29.0",
|
||||
"rehype-pretty-code": "^0.9.4",
|
||||
"rehype-slug": "^5.1.0",
|
||||
"remark-gfm": "^3.0.1",
|
||||
"sass": "^1.43.4",
|
||||
"shiki": "^0.14.1",
|
||||
"styled-components": "^5.3.3",
|
||||
"swr": "^2.2.4",
|
||||
"unist-util-visit": "^4.1.2",
|
||||
"use-local-storage-state": "^18.3.0",
|
||||
"use-session-storage-state": "^18.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@graphql-codegen/cli": "^5.0.2",
|
||||
"@graphql-codegen/typescript": "^4.0.7",
|
||||
"@graphql-codegen/typescript-operations": "^4.2.1",
|
||||
"@graphql-codegen/typescript-resolvers": "^4.1.0",
|
||||
"@types/common-tags": "^1.8.1",
|
||||
"@types/lodash-es": "^4.17.6",
|
||||
"@types/md5": "^2.3.2",
|
||||
"@types/node": "^17.0.41",
|
||||
"@types/react": "^18.0.12",
|
||||
"@types/styled-components": "^5.1.25",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"esbuild": "^0.21.3",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^13.5.6",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.0",
|
||||
"husky": "^9.0.11",
|
||||
"prettier": "^3.2.5",
|
||||
"ts-node": "^10.8.1",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,7 @@ export function LoginGate({ children }: LoginGateProps) {
|
||||
|
||||
if (me.user) {
|
||||
// User is already logged in, so we can safely show the content.
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,21 +3,21 @@ import styled from "styled-components";
|
||||
import type { Property } from "csstype";
|
||||
|
||||
const Flex = styled.div<{
|
||||
$wrap?: boolean
|
||||
$wrap?: boolean;
|
||||
style?: {
|
||||
"--justify-content"?: Property.JustifyContent
|
||||
"--align-items"?: Property.AlignItems
|
||||
"--gap"?: Property.Gap
|
||||
}
|
||||
"--justify-content"?: Property.JustifyContent;
|
||||
"--align-items"?: Property.AlignItems;
|
||||
"--gap"?: Property.Gap;
|
||||
};
|
||||
}>`
|
||||
--justify-content: initial;
|
||||
--align-items: initial;
|
||||
--gap: initial;
|
||||
|
||||
|
||||
display: flex;
|
||||
|
||||
flex-wrap: ${(props) => props.$wrap && "wrap"};
|
||||
|
||||
|
||||
justify-content: var(--justify-content);
|
||||
align-items: var(--align-items);
|
||||
gap: var(--gap);
|
||||
|
||||
@@ -18,13 +18,13 @@ const StyledBracketContainer = styled.div`
|
||||
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
|
||||
|
||||
cursor: grab;
|
||||
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
|
||||
&:fullscreen {
|
||||
background-color: ${theme.colors["background"]};
|
||||
}
|
||||
@@ -33,7 +33,7 @@ const StyledBracketContainer = styled.div`
|
||||
const StyledBracket = styled(m.div)`
|
||||
display: flex;
|
||||
position: relative;
|
||||
|
||||
|
||||
width: min-content;
|
||||
gap: 128px;
|
||||
padding: 16px;
|
||||
@@ -54,7 +54,7 @@ const StyledRound = styled.div`
|
||||
|
||||
const StyledPairing = styled.div`
|
||||
flex: 1;
|
||||
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
@@ -131,7 +131,7 @@ export function BracketChart({ bracket }: BracketChartProps) {
|
||||
nextRect.left - (nextRect.left - (currentRect.left + currentRect.width)) / 2,
|
||||
nextRect.top + nextRect.height / 2,
|
||||
nextRect.left,
|
||||
nextRect.top + nextRect.height / 2
|
||||
nextRect.top + nextRect.height / 2,
|
||||
);
|
||||
ctx.stroke();
|
||||
}
|
||||
@@ -141,16 +141,18 @@ export function BracketChart({ bracket }: BracketChartProps) {
|
||||
return (
|
||||
<>
|
||||
<Button variant="primary" onClick={() => setShowBracketChart(true)} style={{ "--gap": "8px" }}>
|
||||
<Icon icon={faDiagramProject}/>
|
||||
<Icon icon={faDiagramProject} />
|
||||
<span>Open Bracket Chart</span>
|
||||
</Button>
|
||||
{showBracketChart ? (
|
||||
<StyledBracketContainer ref={onBracketInit}>
|
||||
<StyledBracket drag dragConstraints={bracketRef as RefObject<HTMLDivElement>}>
|
||||
<StyledCanvas ref={onCanvasInit}/>
|
||||
{bracket.rounds.sort((a, b) => a.tier - b.tier).map((round) => (
|
||||
<BracketRound key={round.tier} round={round}/>
|
||||
))}
|
||||
<StyledCanvas ref={onCanvasInit} />
|
||||
{bracket.rounds
|
||||
.sort((a, b) => a.tier - b.tier)
|
||||
.map((round) => (
|
||||
<BracketRound key={round.tier} round={round} />
|
||||
))}
|
||||
</StyledBracket>
|
||||
</StyledBracketContainer>
|
||||
) : null}
|
||||
@@ -159,7 +161,7 @@ export function BracketChart({ bracket }: BracketChartProps) {
|
||||
}
|
||||
|
||||
interface BracketRoundProps {
|
||||
round: BracketChartProps["bracket"]["rounds"][number]
|
||||
round: BracketChartProps["bracket"]["rounds"][number];
|
||||
}
|
||||
|
||||
const BracketRound = memo(function BracketRound({ round }: BracketRoundProps) {
|
||||
@@ -170,49 +172,57 @@ const BracketRound = memo(function BracketRound({ round }: BracketRoundProps) {
|
||||
return (
|
||||
<StyledRound>
|
||||
<Text>{round.name}</Text>
|
||||
<BracketPairings pairings={round.pairings}/>
|
||||
<BracketPairings pairings={round.pairings} />
|
||||
</StyledRound>
|
||||
);
|
||||
});
|
||||
|
||||
interface BracketPairingsProps {
|
||||
pairings: BracketRoundProps["round"]["pairings"]
|
||||
pairings: BracketRoundProps["round"]["pairings"];
|
||||
}
|
||||
|
||||
function BracketPairings({ pairings }: BracketPairingsProps) {
|
||||
const sortedPairings = pairings.sort((a, b) => (a.group - b.group) || (a.order - b.order)).map((pairing, index) => (
|
||||
<StyledPairing key={index}>
|
||||
<ContestantCard
|
||||
key={pairing.characterA.id}
|
||||
contestant={pairing.characterA}
|
||||
opponent={pairing.characterB}
|
||||
contestantVotes={pairing.votesA}
|
||||
opponentVotes={pairing.votesB}
|
||||
/>
|
||||
<Text variant="small">VS</Text>
|
||||
<ContestantCard
|
||||
key={pairing.characterB.id}
|
||||
contestant={pairing.characterB}
|
||||
opponent={pairing.characterA}
|
||||
contestantVotes={pairing.votesB}
|
||||
opponentVotes={pairing.votesA}
|
||||
/>
|
||||
</StyledPairing>
|
||||
));
|
||||
const sortedPairings = pairings
|
||||
.sort((a, b) => a.group - b.group || a.order - b.order)
|
||||
.map((pairing, index) => (
|
||||
<StyledPairing key={index}>
|
||||
<ContestantCard
|
||||
key={pairing.characterA.id}
|
||||
contestant={pairing.characterA}
|
||||
opponent={pairing.characterB}
|
||||
contestantVotes={pairing.votesA}
|
||||
opponentVotes={pairing.votesB}
|
||||
/>
|
||||
<Text variant="small">VS</Text>
|
||||
<ContestantCard
|
||||
key={pairing.characterB.id}
|
||||
contestant={pairing.characterB}
|
||||
opponent={pairing.characterA}
|
||||
contestantVotes={pairing.votesB}
|
||||
opponentVotes={pairing.votesA}
|
||||
/>
|
||||
</StyledPairing>
|
||||
));
|
||||
|
||||
return <>{sortedPairings}</>;
|
||||
}
|
||||
|
||||
interface ContestantCardProps {
|
||||
contestant: BracketPairingsProps["pairings"][number]["characterA"] | BracketPairingsProps["pairings"][number]["characterB"]
|
||||
opponent: BracketPairingsProps["pairings"][number]["characterA"] | BracketPairingsProps["pairings"][number]["characterB"]
|
||||
contestantVotes: number | null
|
||||
opponentVotes: number | null
|
||||
contestant:
|
||||
| BracketPairingsProps["pairings"][number]["characterA"]
|
||||
| BracketPairingsProps["pairings"][number]["characterB"];
|
||||
opponent:
|
||||
| BracketPairingsProps["pairings"][number]["characterA"]
|
||||
| BracketPairingsProps["pairings"][number]["characterB"];
|
||||
contestantVotes: number | null;
|
||||
opponentVotes: number | null;
|
||||
}
|
||||
|
||||
function ContestantCard({ contestant, opponent, contestantVotes, opponentVotes }: ContestantCardProps) {
|
||||
const isVoted = !!contestantVotes && !!opponentVotes;
|
||||
const isWinner = isVoted && (contestantVotes !== opponentVotes ? contestantVotes > opponentVotes : contestant.seed < opponent.seed);
|
||||
const isWinner =
|
||||
isVoted &&
|
||||
(contestantVotes !== opponentVotes ? contestantVotes > opponentVotes : contestant.seed < opponent.seed);
|
||||
|
||||
return (
|
||||
<StyledBracketThemeSummaryCard
|
||||
|
||||
@@ -18,13 +18,13 @@ import extractImages from "@/utils/extractImages";
|
||||
|
||||
const StyledSummaryCardWrapper = styled.div`
|
||||
position: relative;
|
||||
|
||||
|
||||
justify-self: stretch;
|
||||
`;
|
||||
|
||||
const StyledSummaryCard = styled(SummaryCard)`
|
||||
padding-inline-end: 24px;
|
||||
|
||||
|
||||
opacity: var(--opacity);
|
||||
`;
|
||||
|
||||
@@ -34,14 +34,21 @@ const StyledRank = styled(Text)`
|
||||
`;
|
||||
|
||||
interface BracketThemeSummaryCardProps extends ComponentPropsWithoutRef<typeof StyledSummaryCardWrapper> {
|
||||
contestant: BracketThemeSummaryCardConstestantFragment
|
||||
isVoted: boolean
|
||||
isWinner: boolean
|
||||
seed: number | null
|
||||
votes: number | null
|
||||
contestant: BracketThemeSummaryCardConstestantFragment;
|
||||
isVoted: boolean;
|
||||
isWinner: boolean;
|
||||
seed: number | null;
|
||||
votes: number | null;
|
||||
}
|
||||
|
||||
export function BracketThemeSummaryCard({ contestant, isVoted, isWinner, seed, votes, ...props }: BracketThemeSummaryCardProps) {
|
||||
export function BracketThemeSummaryCard({
|
||||
contestant,
|
||||
isVoted,
|
||||
isWinner,
|
||||
seed,
|
||||
votes,
|
||||
...props
|
||||
}: BracketThemeSummaryCardProps) {
|
||||
const theme = contestant.theme;
|
||||
const { smallCover } = extractImages(theme?.anime);
|
||||
|
||||
@@ -66,7 +73,7 @@ export function BracketThemeSummaryCard({ contestant, isVoted, isWinner, seed, v
|
||||
return (
|
||||
<StyledSummaryCardWrapper {...props}>
|
||||
<StyledSummaryCard
|
||||
title={theme ? <SongTitleWithArtists song={theme.song} songTitleLinkTo={to}/> : contestant.name}
|
||||
title={theme ? <SongTitleWithArtists song={theme.song} songTitleLinkTo={to} /> : contestant.name}
|
||||
description={description}
|
||||
image={smallCover ?? contestant.image}
|
||||
to={to}
|
||||
@@ -74,21 +81,18 @@ export function BracketThemeSummaryCard({ contestant, isVoted, isWinner, seed, v
|
||||
>
|
||||
<Column style={{ "--gap": "8px" }}>
|
||||
<Text variant="small" color="text-muted" noWrap title="Seed">
|
||||
<Icon icon={faSeedling}/>
|
||||
<Icon icon={faSeedling} />
|
||||
<StyledRank> {seed}</StyledRank>
|
||||
</Text>
|
||||
{isVoted && (
|
||||
<Text variant="small" color={isWinner ? "text-primary" : "text-muted"} noWrap title="Votes">
|
||||
<Icon icon={faUsers}/>
|
||||
<Icon icon={faUsers} />
|
||||
<StyledRank> {votes}</StyledRank>
|
||||
</Text>
|
||||
)}
|
||||
</Column>
|
||||
|
||||
</StyledSummaryCard>
|
||||
{isWinner && (
|
||||
<CornerIcon icon={faAward} title="Winner"/>
|
||||
)}
|
||||
{isWinner && <CornerIcon icon={faAward} title="Winner" />}
|
||||
</StyledSummaryCardWrapper>
|
||||
);
|
||||
}
|
||||
@@ -96,7 +100,7 @@ export function BracketThemeSummaryCard({ contestant, isVoted, isWinner, seed, v
|
||||
BracketThemeSummaryCard.fragments = {
|
||||
contestant: gql`
|
||||
${ThemeSummaryCard.fragments.theme}
|
||||
|
||||
|
||||
fragment BracketThemeSummaryCardConstestant on BracketCharacter {
|
||||
name
|
||||
source
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useContext,useEffect, useState } from "react";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
import { faChevronUp } from "@fortawesome/pro-solid-svg-icons";
|
||||
@@ -11,12 +11,12 @@ import { withHover } from "@/styles/mixins";
|
||||
import theme from "@/theme";
|
||||
|
||||
const ScrollButton = styled(m(Button))<{ $bottomOffset: number }>`
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: ${(props) => 16 + props.$bottomOffset}px;
|
||||
padding: 16px;
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
bottom: ${(props) => 16 + props.$bottomOffset}px;
|
||||
padding: 16px;
|
||||
|
||||
${withHover`
|
||||
${withHover`
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
color: ${theme.colors["text-primary"]};
|
||||
`}
|
||||
|
||||
@@ -9,19 +9,16 @@ import theme from "@/theme";
|
||||
export const Button = forwardRef(ButtonWithRef);
|
||||
|
||||
interface ButtonProps extends ComponentPropsWithoutRef<typeof BaseButton> {
|
||||
children?: ReactNode
|
||||
variant?: "solid" | "primary" | "warning" | "silent"
|
||||
isCircle?: boolean
|
||||
disabled?: boolean
|
||||
children?: ReactNode;
|
||||
variant?: "solid" | "primary" | "warning" | "silent";
|
||||
isCircle?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function ButtonWithRef({
|
||||
variant = "solid",
|
||||
isCircle = false,
|
||||
disabled = false,
|
||||
title,
|
||||
...props
|
||||
}: ButtonProps, ref: ForwardedRef<HTMLButtonElement>) {
|
||||
function ButtonWithRef(
|
||||
{ variant = "solid", isCircle = false, disabled = false, title, ...props }: ButtonProps,
|
||||
ref: ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
let Component;
|
||||
if (variant === "solid") {
|
||||
Component = SolidButton;
|
||||
@@ -35,48 +32,39 @@ function ButtonWithRef({
|
||||
throw new Error(`Unknown button variant "${variant}"!`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Component
|
||||
$isCircle={isCircle}
|
||||
disabled={disabled}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <Component $isCircle={isCircle} disabled={disabled} title={title} aria-label={title} ref={ref} {...props} />;
|
||||
}
|
||||
|
||||
const BaseButton = styled.button<{ $isCircle: boolean }>`
|
||||
--gap: 0;
|
||||
--focus-ring-color: ${theme.colors["text-primary"]};
|
||||
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: ${(props) => props.disabled ? "not-allowed" : "pointer"};
|
||||
cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
|
||||
pointer-events: ${(props) => props.disabled && "none"};
|
||||
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1rem;
|
||||
|
||||
padding: ${(props) => props.$isCircle ? "8px" : "8px 16px"};
|
||||
|
||||
padding: ${(props) => (props.$isCircle ? "8px" : "8px 16px")};
|
||||
border-radius: 999px;
|
||||
gap: var(--gap, 0);
|
||||
aspect-ratio: ${(props) => props.$isCircle && "1 / 1"};
|
||||
|
||||
|
||||
opacity: ${(props) => props.disabled && "0.5"};
|
||||
box-shadow: ${theme.shadows.low};
|
||||
transition: background-color 250ms;
|
||||
|
||||
|
||||
// Buttons within other buttons should have a special margin and no shadow.
|
||||
& & {
|
||||
box-shadow: none;
|
||||
margin: -8px 8px -8px -16px;
|
||||
}
|
||||
|
||||
|
||||
&:focus:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--focus-ring-color);
|
||||
}
|
||||
@@ -90,7 +78,7 @@ const PrimaryButton = styled(BaseButton)`
|
||||
background-color: ${theme.colors["text-on-primary"]};
|
||||
color: ${theme.colors["text-primary"]};
|
||||
`}
|
||||
|
||||
|
||||
&:focus:focus-visible {
|
||||
background-color: ${theme.colors["text-on-primary"]};
|
||||
color: ${theme.colors["text-primary"]};
|
||||
@@ -99,7 +87,7 @@ const PrimaryButton = styled(BaseButton)`
|
||||
|
||||
const WarningButton = styled(BaseButton)`
|
||||
--focus-ring-color: ${theme.colors["text-warning"]};
|
||||
|
||||
|
||||
background-color: ${theme.colors["solid-warning"]};
|
||||
color: ${theme.colors["text-on-warning"]};
|
||||
|
||||
@@ -107,7 +95,7 @@ const WarningButton = styled(BaseButton)`
|
||||
background-color: ${theme.colors["text-on-warning"]};
|
||||
color: ${theme.colors["text-warning"]};
|
||||
`}
|
||||
|
||||
|
||||
&:focus:focus-visible {
|
||||
background-color: ${theme.colors["text-on-warning"]};
|
||||
color: ${theme.colors["text-warning"]};
|
||||
@@ -121,7 +109,7 @@ const SolidButton = styled(BaseButton)`
|
||||
${withHover`
|
||||
color: ${theme.colors["text"]};
|
||||
`}
|
||||
|
||||
|
||||
${Solid} & {
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
}
|
||||
@@ -153,4 +141,3 @@ const SilentButton = styled(BaseButton)`
|
||||
`}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { IconTextButton } from "@/components/button/IconTextButton";
|
||||
|
||||
export function FilterToggleButton(props: Partial<ComponentPropsWithoutRef<typeof IconTextButton>>) {
|
||||
return (
|
||||
<IconTextButton icon={faFilter} collapsible {...props}>Filter</IconTextButton>
|
||||
<IconTextButton icon={faFilter} collapsible {...props}>
|
||||
Filter
|
||||
</IconTextButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,11 +10,11 @@ import theme from "@/theme";
|
||||
|
||||
const StyledButton = styled(Button)<{ $collapseBreakpoint: string }>`
|
||||
gap: 8px;
|
||||
|
||||
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
|
||||
@media (max-width: ${(props) => props.$collapseBreakpoint}) {
|
||||
aspect-ratio: 1 / 1;
|
||||
padding: 8px;
|
||||
@@ -28,33 +28,32 @@ const StyledText = styled.span<{ $collapseBreakpoint: string }>`
|
||||
`;
|
||||
|
||||
interface IconTextButtonProps extends ComponentPropsWithoutRef<typeof StyledButton> {
|
||||
icon: IconDefinition | ReactNode
|
||||
children?: ReactNode
|
||||
collapsible?: true | keyof typeof theme.breakpoints
|
||||
icon: IconDefinition | ReactNode;
|
||||
children?: ReactNode;
|
||||
collapsible?: true | keyof typeof theme.breakpoints;
|
||||
}
|
||||
|
||||
export const IconTextButton = forwardRef(
|
||||
function IconTextButton({ icon, children, collapsible, ...props }: IconTextButtonProps, ref) {
|
||||
let collapseBreakpoint = "0px";
|
||||
export const IconTextButton = forwardRef(function IconTextButton(
|
||||
{ icon, children, collapsible, ...props }: IconTextButtonProps,
|
||||
ref,
|
||||
) {
|
||||
let collapseBreakpoint = "0px";
|
||||
|
||||
if (collapsible === true) {
|
||||
collapseBreakpoint = theme.breakpoints.mobileMax;
|
||||
} else if (collapsible) {
|
||||
collapseBreakpoint = theme.breakpoints[collapsible];
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledButton ref={ref} variant="silent" $collapseBreakpoint={collapseBreakpoint} {...props}>
|
||||
{isIconDefinition(icon) ? (
|
||||
<Icon icon={icon} color="text-disabled"/>
|
||||
) : icon}
|
||||
{(children !== null && children !== undefined) ? (
|
||||
<StyledText $collapseBreakpoint={collapseBreakpoint}>{children}</StyledText>
|
||||
) : null}
|
||||
</StyledButton>
|
||||
);
|
||||
if (collapsible === true) {
|
||||
collapseBreakpoint = theme.breakpoints.mobileMax;
|
||||
} else if (collapsible) {
|
||||
collapseBreakpoint = theme.breakpoints[collapsible];
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledButton ref={ref} variant="silent" $collapseBreakpoint={collapseBreakpoint} {...props}>
|
||||
{isIconDefinition(icon) ? <Icon icon={icon} color="text-disabled" /> : icon}
|
||||
{children !== null && children !== undefined ? (
|
||||
<StyledText $collapseBreakpoint={collapseBreakpoint}>{children}</StyledText>
|
||||
) : null}
|
||||
</StyledButton>
|
||||
);
|
||||
});
|
||||
|
||||
function isIconDefinition(icon: IconDefinition | ReactNode): icon is IconDefinition {
|
||||
return !!icon && typeof icon === "object" && "icon" in icon;
|
||||
|
||||
@@ -13,32 +13,31 @@ import type {
|
||||
VideoButtonAnimeFragment,
|
||||
VideoButtonEntryFragment,
|
||||
VideoButtonThemeFragment,
|
||||
VideoButtonVideoFragment
|
||||
VideoButtonVideoFragment,
|
||||
} from "@/generated/graphql";
|
||||
import createVideoSlug, { getVideoSlugByWatchListItem } from "@/utils/createVideoSlug";
|
||||
|
||||
interface VideoButtonProps extends ComponentPropsWithoutRef<typeof Button> {
|
||||
anime: VideoButtonAnimeFragment
|
||||
theme: VideoButtonThemeFragment
|
||||
entry: VideoButtonEntryFragment
|
||||
video: VideoButtonVideoFragment
|
||||
anime: VideoButtonAnimeFragment;
|
||||
theme: VideoButtonThemeFragment;
|
||||
entry: VideoButtonEntryFragment;
|
||||
video: VideoButtonVideoFragment;
|
||||
}
|
||||
|
||||
export function VideoButton({ anime, theme, entry, video, ...props }: VideoButtonProps) {
|
||||
const { currentWatchListItem } = useContext(PlayerContext);
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
const isPlaying = currentWatchListItem ? getVideoSlugByWatchListItem(currentWatchListItem) === videoSlug : false;
|
||||
const isPlaying = currentWatchListItem
|
||||
? getVideoSlugByWatchListItem(currentWatchListItem) === `${anime.slug}/${videoSlug}`
|
||||
: false;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/anime/${anime.slug}/${videoSlug}`}
|
||||
passHref
|
||||
legacyBehavior>
|
||||
<Link href={`/anime/${anime.slug}/${videoSlug}`} passHref legacyBehavior>
|
||||
<Button as="a" {...props}>
|
||||
<Button as="span" variant="primary" isCircle>
|
||||
<Icon icon={isPlaying ? faCompactDisc : faPlay} className={isPlaying ? "fa-spin" : undefined}/>
|
||||
<Icon icon={isPlaying ? faCompactDisc : faPlay} className={isPlaying ? "fa-spin" : undefined} />
|
||||
</Button>
|
||||
<VideoTags video={video} hideTextOnMobile/>
|
||||
<VideoTags video={video} hideTextOnMobile />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
@@ -67,7 +66,7 @@ VideoButton.fragments = {
|
||||
video: gql`
|
||||
${createVideoSlug.fragments.video}
|
||||
${VideoTags.fragments.video}
|
||||
|
||||
|
||||
fragment VideoButtonVideo on Video {
|
||||
...createVideoSlugVideo
|
||||
...VideoTagsVideo
|
||||
|
||||
@@ -19,7 +19,7 @@ import theme from "@/theme";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: relative
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledThemeContainerInline = styled.div`
|
||||
@@ -32,7 +32,7 @@ const StyledThemeContainerInline = styled.div`
|
||||
right: 16px;
|
||||
opacity: 0;
|
||||
transition-property: opacity;
|
||||
|
||||
|
||||
user-select: none;
|
||||
|
||||
${StyledWrapper}:hover & {
|
||||
@@ -40,7 +40,7 @@ const StyledThemeContainerInline = styled.div`
|
||||
opacity: 1;
|
||||
transition-duration: 250ms;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
position: static;
|
||||
opacity: 1;
|
||||
@@ -60,20 +60,25 @@ const StyledThemeGroupContainer = styled.div`
|
||||
margin-top: 8px;
|
||||
`;
|
||||
|
||||
type AnimeSummaryCardProps = {
|
||||
anime: AnimeSummaryCardAnimeFragment
|
||||
expandable?: false
|
||||
} | {
|
||||
anime: AnimeSummaryCardAnimeFragment & AnimeSummaryCardAnimeExpandableFragment
|
||||
expandable: true
|
||||
};
|
||||
type AnimeSummaryCardProps =
|
||||
| {
|
||||
anime: AnimeSummaryCardAnimeFragment;
|
||||
expandable?: false;
|
||||
}
|
||||
| {
|
||||
anime: AnimeSummaryCardAnimeFragment & AnimeSummaryCardAnimeExpandableFragment;
|
||||
expandable: true;
|
||||
};
|
||||
|
||||
export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeSummaryCardProps) {
|
||||
const [isExpanded, toggleExpanded] = useToggle();
|
||||
const { smallCover } = extractImages(anime);
|
||||
const isMobile = useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
|
||||
|
||||
const groups = uniqBy(anime.themes.map((theme) => theme.group), (group) => group?.slug);
|
||||
const groups = uniqBy(
|
||||
anime.themes.map((theme) => theme.group),
|
||||
(group) => group?.slug,
|
||||
);
|
||||
|
||||
const animeLink = `/anime/${anime.slug}`;
|
||||
|
||||
@@ -87,11 +92,7 @@ export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeS
|
||||
const description = (
|
||||
<SummaryCard.Description>
|
||||
<span>{anime.media_format ?? "Anime"}</span>
|
||||
{!!anime.year && (
|
||||
<TextLink href={premiereLink}>
|
||||
{premiere}
|
||||
</TextLink>
|
||||
)}
|
||||
{!!anime.year && <TextLink href={premiereLink}>{premiere}</TextLink>}
|
||||
<span>{anime.themes.length} themes</span>
|
||||
</SummaryCard.Description>
|
||||
);
|
||||
@@ -140,10 +141,12 @@ export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeS
|
||||
<StyledThemeGroupContainer>
|
||||
{groups.map((group) => (
|
||||
<Fragment key={group?.slug}>
|
||||
{!!group && (
|
||||
<Text variant="h2">{group.name}</Text>
|
||||
)}
|
||||
<ThemeTable themes={(anime as AnimeSummaryCardAnimeExpandableFragment).themes.filter((theme) => theme.group?.slug === group?.slug)}/>
|
||||
{!!group && <Text variant="h2">{group.name}</Text>}
|
||||
<ThemeTable
|
||||
themes={(anime as AnimeSummaryCardAnimeExpandableFragment).themes.filter(
|
||||
(theme) => theme.group?.slug === group?.slug,
|
||||
)}
|
||||
/>
|
||||
</Fragment>
|
||||
))}
|
||||
</StyledThemeGroupContainer>
|
||||
@@ -156,7 +159,7 @@ export function AnimeSummaryCard({ anime, expandable = false, ...props }: AnimeS
|
||||
AnimeSummaryCard.fragments = {
|
||||
anime: gql`
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
|
||||
|
||||
fragment AnimeSummaryCardAnime on Anime {
|
||||
...extractImagesResourceWithImages
|
||||
slug
|
||||
@@ -184,5 +187,5 @@ AnimeSummaryCard.fragments = {
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -8,7 +8,5 @@ interface AnnouncementCardProps {
|
||||
}
|
||||
|
||||
export function AnnouncementCard({ announcementSource }: AnnouncementCardProps) {
|
||||
return (
|
||||
<Markdown source={announcementSource} components={{ AnimeAwardsNowAvailable }} />
|
||||
);
|
||||
return <Markdown source={announcementSource} components={{ AnimeAwardsNowAvailable }} />;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import type { ArtistSummaryCardArtistFragment } from "@/generated/graphql";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
|
||||
type ArtistSummaryCardProps = {
|
||||
artist: ArtistSummaryCardArtistFragment
|
||||
as?: string | null
|
||||
artist: ArtistSummaryCardArtistFragment;
|
||||
as?: string | null;
|
||||
};
|
||||
|
||||
export function ArtistSummaryCard({ artist, as }: ArtistSummaryCardProps) {
|
||||
@@ -15,19 +15,12 @@ export function ArtistSummaryCard({ artist, as }: ArtistSummaryCardProps) {
|
||||
const description = (
|
||||
<SummaryCard.Description>
|
||||
<span>Artist</span>
|
||||
{!!as && (
|
||||
<span>As {as}</span>
|
||||
)}
|
||||
{!!as && <span>As {as}</span>}
|
||||
</SummaryCard.Description>
|
||||
);
|
||||
|
||||
return (
|
||||
<SummaryCard
|
||||
title={artist.name}
|
||||
description={description}
|
||||
image={smallCover}
|
||||
to={`/artist/${artist.slug}`}
|
||||
/>
|
||||
<SummaryCard title={artist.name} description={description} image={smallCover} to={`/artist/${artist.slug}`} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,5 +34,5 @@ ArtistSummaryCard.fragments = {
|
||||
facet
|
||||
}
|
||||
}
|
||||
`
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -6,12 +6,12 @@ import theme from "@/theme";
|
||||
import type { Colors } from "@/theme/colors";
|
||||
|
||||
export const Card = styled(Solid)<{
|
||||
hoverable?: boolean
|
||||
color?: keyof Colors
|
||||
hoverable?: boolean;
|
||||
color?: keyof Colors;
|
||||
}>`
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
|
||||
padding: 16px 24px 16px 28px;
|
||||
border-radius: ${theme.scalars.borderRadiusCard};
|
||||
overflow: hidden;
|
||||
@@ -22,14 +22,16 @@ export const Card = styled(Solid)<{
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
}
|
||||
|
||||
${(props) => props.hoverable && css`
|
||||
cursor: pointer;
|
||||
${(props) =>
|
||||
props.hoverable &&
|
||||
css`
|
||||
cursor: pointer;
|
||||
|
||||
${withHover`
|
||||
${withHover`
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
`}
|
||||
`}
|
||||
|
||||
`}
|
||||
|
||||
&:before {
|
||||
content: " ";
|
||||
display: block;
|
||||
@@ -38,6 +40,6 @@ export const Card = styled(Solid)<{
|
||||
left: 0;
|
||||
width: 4px;
|
||||
height: 100%;
|
||||
background-color: ${(props) => props.color ? theme.colors[props.color] : theme.colors["text-primary"]};
|
||||
background-color: ${(props) => (props.color ? theme.colors[props.color] : theme.colors["text-primary"])};
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -21,7 +21,7 @@ const StyledErrorMessage = styled(Text).attrs({ variant: "code" })`
|
||||
`;
|
||||
|
||||
interface ErrorCardProps {
|
||||
error: unknown
|
||||
error: unknown;
|
||||
}
|
||||
|
||||
export function ErrorCard({ error }: ErrorCardProps) {
|
||||
@@ -29,14 +29,15 @@ export function ErrorCard({ error }: ErrorCardProps) {
|
||||
<StyledCard color="text-warning">
|
||||
<Row style={{ "--gap": "1rem" }}>
|
||||
<Text color="text-warning">
|
||||
<Icon icon={faExclamation}/>
|
||||
<Icon icon={faExclamation} />
|
||||
</Text>
|
||||
<Text block>
|
||||
An error occurred while searching! Help improving the site by sending us the following error
|
||||
message:
|
||||
</Text>
|
||||
<Text block>An error occurred while searching! Help improving the site by sending us the following error message:</Text>
|
||||
</Row>
|
||||
<pre>
|
||||
<StyledErrorMessage>
|
||||
{JSON.stringify(error, null, 2)}
|
||||
</StyledErrorMessage>
|
||||
<StyledErrorMessage>{JSON.stringify(error, null, 2)}</StyledErrorMessage>
|
||||
</pre>
|
||||
</StyledCard>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { PlaylistSummaryCardPlaylistFragment, PlaylistSummaryCardShowOwnerF
|
||||
import theme from "@/theme";
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: relative
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledOverlayButtons = styled.div`
|
||||
@@ -31,26 +31,32 @@ const StyledOverlayButtons = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
type PlaylistSummaryCardProps = {
|
||||
playlist: PlaylistSummaryCardPlaylistFragment;
|
||||
menu?: ReactNode;
|
||||
showOwner?: false;
|
||||
} | {
|
||||
playlist: PlaylistSummaryCardPlaylistFragment & PlaylistSummaryCardShowOwnerFragment;
|
||||
menu?: ReactNode;
|
||||
showOwner: true;
|
||||
};
|
||||
type PlaylistSummaryCardProps =
|
||||
| {
|
||||
playlist: PlaylistSummaryCardPlaylistFragment;
|
||||
menu?: ReactNode;
|
||||
showOwner?: false;
|
||||
}
|
||||
| {
|
||||
playlist: PlaylistSummaryCardPlaylistFragment & PlaylistSummaryCardShowOwnerFragment;
|
||||
menu?: ReactNode;
|
||||
showOwner: true;
|
||||
};
|
||||
|
||||
export default function PlaylistSummaryCard({ playlist, children, menu, showOwner, ...props }: PropsWithChildren<PlaylistSummaryCardProps>) {
|
||||
export default function PlaylistSummaryCard({
|
||||
playlist,
|
||||
children,
|
||||
menu,
|
||||
showOwner,
|
||||
...props
|
||||
}: PropsWithChildren<PlaylistSummaryCardProps>) {
|
||||
const description = (
|
||||
<SummaryCard.Description>
|
||||
<span>Playlist</span>
|
||||
{showOwner ? (
|
||||
<Text link>{playlist.user.name}</Text>
|
||||
) : (
|
||||
<span>{playlist.visibility}</span>
|
||||
)}
|
||||
<span>{playlist.tracks_count} theme{playlist.tracks_count !== 1 ? "s" : null}</span>
|
||||
{showOwner ? <Text link>{playlist.user.name}</Text> : <span>{playlist.visibility}</span>}
|
||||
<span>
|
||||
{playlist.tracks_count} theme{playlist.tracks_count !== 1 ? "s" : null}
|
||||
</span>
|
||||
</SummaryCard.Description>
|
||||
);
|
||||
|
||||
@@ -59,9 +65,7 @@ export default function PlaylistSummaryCard({ playlist, children, menu, showOwne
|
||||
<SummaryCard title={playlist.name} description={description} to={`/playlist/${playlist.id}`} {...props}>
|
||||
{children}
|
||||
{menu ? (
|
||||
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
|
||||
{menu}
|
||||
</StyledOverlayButtons>
|
||||
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>{menu}</StyledOverlayButtons>
|
||||
) : null}
|
||||
</SummaryCard>
|
||||
</StyledWrapper>
|
||||
@@ -75,7 +79,7 @@ PlaylistSummaryCard.fragments = {
|
||||
name
|
||||
visibility
|
||||
tracks_count
|
||||
}
|
||||
}
|
||||
`,
|
||||
showOwner: gql`
|
||||
fragment PlaylistSummaryCardShowOwner on Playlist {
|
||||
|
||||
@@ -33,7 +33,7 @@ export function StudioSummaryCard({ studio }: StudioSummaryCardProps) {
|
||||
imageProps={{
|
||||
objectFit: "contain",
|
||||
backgroundColor,
|
||||
onLoad: handleLoad
|
||||
onLoad: handleLoad,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@@ -42,11 +42,11 @@ export function StudioSummaryCard({ studio }: StudioSummaryCardProps) {
|
||||
StudioSummaryCard.fragments = {
|
||||
studio: gql`
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
|
||||
|
||||
fragment StudioSummaryCardStudio on Studio {
|
||||
slug
|
||||
name
|
||||
...extractImagesResourceWithImages
|
||||
}
|
||||
`,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -24,60 +24,54 @@ const StyledSummaryCard = styled(Card)`
|
||||
`;
|
||||
|
||||
const StyledCover = styled.img.attrs({
|
||||
loading: "lazy"
|
||||
loading: "lazy",
|
||||
})<{
|
||||
objectFit?: Property.ObjectFit
|
||||
backgroundColor?: Property.Background
|
||||
isLoading?: boolean
|
||||
isPlaceholder?: boolean
|
||||
objectFit?: Property.ObjectFit;
|
||||
backgroundColor?: Property.Background;
|
||||
isLoading?: boolean;
|
||||
isPlaceholder?: boolean;
|
||||
}>`
|
||||
width: 48px;
|
||||
height: 64px;
|
||||
object-fit: ${(props) => props.objectFit ?? "cover"};
|
||||
background: ${(props) => props.backgroundColor};
|
||||
|
||||
${(props) => props.isPlaceholder ? css`
|
||||
padding: 0.5rem;
|
||||
object-fit: contain;
|
||||
background-color: white;
|
||||
` : (props.isLoading ? loadingAnimation : null)}
|
||||
|
||||
${(props) =>
|
||||
props.isPlaceholder
|
||||
? css`
|
||||
padding: 0.5rem;
|
||||
object-fit: contain;
|
||||
background-color: white;
|
||||
`
|
||||
: props.isLoading
|
||||
? loadingAnimation
|
||||
: null}
|
||||
`;
|
||||
|
||||
const StyledBody = styled(Column)`
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
|
||||
word-break: break-all;
|
||||
`;
|
||||
|
||||
type SummaryCardProps = ComponentPropsWithoutRef<typeof StyledSummaryCard> & {
|
||||
title: string | ReactNode
|
||||
description?: string | ReactNode
|
||||
image?: string
|
||||
imageProps?: ComponentPropsWithoutRef<typeof StyledCover>
|
||||
to?: string
|
||||
children?: ReactNode
|
||||
title: string | ReactNode;
|
||||
description?: string | ReactNode;
|
||||
image?: string;
|
||||
imageProps?: ComponentPropsWithoutRef<typeof StyledCover>;
|
||||
to?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function SummaryCard({
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
imageProps,
|
||||
to,
|
||||
children,
|
||||
...props
|
||||
}: SummaryCardProps) {
|
||||
const [ imageNotFound, setImageNotFound ] = useState(false);
|
||||
const [ imageLoading, setImageLoading ] = useState(true);
|
||||
export function SummaryCard({ title, description, image, imageProps, to, children, ...props }: SummaryCardProps) {
|
||||
const [imageNotFound, setImageNotFound] = useState(false);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
|
||||
return (
|
||||
<StyledSummaryCard {...props}>
|
||||
<ConditionalWrapper
|
||||
condition={!!to}
|
||||
wrap={(children) => <Link href={to as string}>{children}</Link>}
|
||||
>
|
||||
<ConditionalWrapper condition={!!to} wrap={(children) => <Link href={to as string}>{children}</Link>}>
|
||||
<StyledCover
|
||||
alt="Cover"
|
||||
src={(!imageNotFound && image) || withBasePath("/img/logo.svg")}
|
||||
@@ -102,16 +96,15 @@ export function SummaryCard({
|
||||
</ConditionalWrapper>
|
||||
<StyledBody>
|
||||
<Text maxLines={1} title={typeof title === "string" ? title : undefined}>
|
||||
{typeof title === "string" && to ?
|
||||
<TextLink href={to}>{title}</TextLink> : title}
|
||||
{typeof title === "string" && to ? <TextLink href={to}>{title}</TextLink> : title}
|
||||
</Text>
|
||||
{!!description && (
|
||||
<Text variant="small" maxLines={1}>
|
||||
{typeof description === "string" ? (
|
||||
<SummaryCard.Description>
|
||||
{[description]}
|
||||
</SummaryCard.Description>
|
||||
) : description}
|
||||
<SummaryCard.Description>{[description]}</SummaryCard.Description>
|
||||
) : (
|
||||
description
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</StyledBody>
|
||||
@@ -121,20 +114,20 @@ export function SummaryCard({
|
||||
}
|
||||
|
||||
type SummaryCardDescriptionProps = {
|
||||
children: Array<ReactNode>
|
||||
children: Array<ReactNode>;
|
||||
};
|
||||
|
||||
SummaryCard.Description = function SummaryCardDescription({ children }: SummaryCardDescriptionProps) {
|
||||
return (
|
||||
<>
|
||||
{children.filter((child) => child).map((child, index, { length }) => (
|
||||
<Text key={index} color="text-muted">
|
||||
<span>{child}</span>
|
||||
{index < length - 1 && (
|
||||
<span> • </span>
|
||||
)}
|
||||
</Text>
|
||||
))}
|
||||
{children
|
||||
.filter((child) => child)
|
||||
.map((child, index, { length }) => (
|
||||
<Text key={index} color="text-muted">
|
||||
<span>{child}</span>
|
||||
{index < length - 1 && <span> • </span>}
|
||||
</Text>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,43 +21,44 @@ const StyledSummaryCard = styled(Card)`
|
||||
`;
|
||||
|
||||
const StyledCover = styled.img.attrs({
|
||||
loading: "lazy"
|
||||
loading: "lazy",
|
||||
})<{
|
||||
objectFit?: Property.ObjectFit
|
||||
backgroundColor?: Property.Background
|
||||
isLoading?: boolean
|
||||
isPlaceholder?: boolean
|
||||
objectFit?: Property.ObjectFit;
|
||||
backgroundColor?: Property.Background;
|
||||
isLoading?: boolean;
|
||||
isPlaceholder?: boolean;
|
||||
}>`
|
||||
width: 48px;
|
||||
height: 64px;
|
||||
object-fit: ${(props) => props.objectFit ?? "cover"};
|
||||
background: ${(props) => props.backgroundColor};
|
||||
|
||||
${(props) => props.isPlaceholder ? css`
|
||||
padding: 0.5rem;
|
||||
object-fit: contain;
|
||||
background-color: white;
|
||||
` : (props.isLoading ? loadingAnimation : null)}
|
||||
|
||||
${(props) =>
|
||||
props.isPlaceholder
|
||||
? css`
|
||||
padding: 0.5rem;
|
||||
object-fit: contain;
|
||||
background-color: white;
|
||||
`
|
||||
: props.isLoading
|
||||
? loadingAnimation
|
||||
: null}
|
||||
`;
|
||||
|
||||
const StyledBody = styled(Column)`
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
|
||||
word-break: break-all;
|
||||
`;
|
||||
|
||||
type SummaryCardProps = ComponentPropsWithoutRef<typeof StyledSummaryCard> & {
|
||||
children?: ReactNode
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export function SummaryCard({ children, ...props }: SummaryCardProps) {
|
||||
return (
|
||||
<StyledSummaryCard {...props}>
|
||||
{children}
|
||||
</StyledSummaryCard>
|
||||
);
|
||||
return <StyledSummaryCard {...props}>{children}</StyledSummaryCard>;
|
||||
}
|
||||
|
||||
SummaryCard.Body = StyledBody;
|
||||
@@ -75,20 +76,22 @@ SummaryCard.Title = function SummaryCardTitle({ children, ...props }: SummaryCar
|
||||
};
|
||||
|
||||
interface SummaryCardDescriptionProps {
|
||||
children: string | Array<ReactNode>
|
||||
children: string | Array<ReactNode>;
|
||||
}
|
||||
|
||||
SummaryCard.Description = function SummaryCardDescription({ children }: SummaryCardDescriptionProps) {
|
||||
return (
|
||||
<Text variant="small" maxLines={1} color="text-muted">
|
||||
{typeof children === "string" ? children : children.filter((child) => child).map((child, index, { length }) => (
|
||||
<>
|
||||
{child}
|
||||
{index < length - 1 && (
|
||||
<span> • </span>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
{typeof children === "string"
|
||||
? children
|
||||
: children
|
||||
.filter((child) => child)
|
||||
.map((child, index, { length }) => (
|
||||
<>
|
||||
{child}
|
||||
{index < length - 1 && <span> • </span>}
|
||||
</>
|
||||
))}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
@@ -98,8 +101,8 @@ interface SummaryCardCoverProps extends ComponentPropsWithoutRef<typeof StyledCo
|
||||
}
|
||||
|
||||
SummaryCard.Cover = function SummaryCardCover({ src, ...props }: SummaryCardCoverProps) {
|
||||
const [ imageNotFound, setImageNotFound ] = useState(false);
|
||||
const [ imageLoading, setImageLoading ] = useState(true);
|
||||
const [imageNotFound, setImageNotFound] = useState(false);
|
||||
const [imageLoading, setImageLoading] = useState(true);
|
||||
|
||||
return (
|
||||
<StyledCover
|
||||
|
||||
@@ -18,7 +18,7 @@ import { entryVersionComparator } from "@/utils/comparators";
|
||||
const StyledThemeCard = styled(Card)`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
|
||||
gap: 1rem;
|
||||
`;
|
||||
|
||||
@@ -26,7 +26,7 @@ const StyledRow = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 2rem 1fr auto;
|
||||
align-items: baseline;
|
||||
|
||||
|
||||
grid-gap: 1rem;
|
||||
`;
|
||||
|
||||
@@ -39,14 +39,14 @@ const StyledVideoListContainer = styled.div`
|
||||
const StyledVideoList = styled(Row)`
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
|
||||
|
||||
@media (min-width: 721px) {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`;
|
||||
|
||||
interface ThemeDetailCardProps {
|
||||
theme: ThemeDetailCardThemeFragment
|
||||
theme: ThemeDetailCardThemeFragment;
|
||||
}
|
||||
|
||||
export function ThemeDetailCard({ theme }: ThemeDetailCardProps) {
|
||||
@@ -59,30 +59,29 @@ export function ThemeDetailCard({ theme }: ThemeDetailCardProps) {
|
||||
return (
|
||||
<StyledThemeCard>
|
||||
<StyledRow>
|
||||
<Text variant="small" color="text">{theme.type}{theme.sequence || null}</Text>
|
||||
<Text>
|
||||
<SongTitle song={theme.song}/>
|
||||
<Performances song={theme.song} expandable/>
|
||||
<Text variant="small" color="text">
|
||||
{theme.type}
|
||||
{theme.sequence || null}
|
||||
</Text>
|
||||
<ThemeMenu theme={theme}/>
|
||||
<Text>
|
||||
<SongTitle song={theme.song} />
|
||||
<Performances song={theme.song} expandable />
|
||||
</Text>
|
||||
<ThemeMenu theme={theme} />
|
||||
</StyledRow>
|
||||
{theme.entries.sort(entryVersionComparator).map(entry => (
|
||||
{theme.entries.sort(entryVersionComparator).map((entry) => (
|
||||
<StyledRow key={entry.version || 0}>
|
||||
<Text variant="small" color="text-muted">{!!entry.version && `v${entry.version}`}</Text>
|
||||
<Text variant="small" color="text-muted">
|
||||
{!!entry.version && `v${entry.version}`}
|
||||
</Text>
|
||||
<Text color="text-muted">
|
||||
<ThemeEntryTags entry={entry}/>
|
||||
<ThemeEntryTags entry={entry} />
|
||||
</Text>
|
||||
<StyledVideoListContainer>
|
||||
{!!entry.videos && (
|
||||
<StyledVideoList>
|
||||
{entry.videos.map((video, index) => (
|
||||
<VideoButton
|
||||
key={index}
|
||||
anime={anime}
|
||||
theme={theme}
|
||||
entry={entry}
|
||||
video={video}
|
||||
/>
|
||||
<VideoButton key={index} anime={anime} theme={theme} entry={entry} video={video} />
|
||||
))}
|
||||
</StyledVideoList>
|
||||
)}
|
||||
@@ -97,7 +96,7 @@ ThemeDetailCard.fragments = {
|
||||
theme: gql`
|
||||
${ThemeMenu.fragments.theme}
|
||||
${VideoTags.fragments.video}
|
||||
|
||||
|
||||
fragment ThemeDetailCardTheme on Theme {
|
||||
...ThemeMenuTheme
|
||||
type
|
||||
@@ -132,5 +131,5 @@ ThemeDetailCard.fragments = {
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ import type {
|
||||
ThemeSummaryCardArtistFragment,
|
||||
ThemeSummaryCardQuery,
|
||||
ThemeSummaryCardThemeExpandableFragment,
|
||||
ThemeSummaryCardThemeFragment
|
||||
ThemeSummaryCardThemeFragment,
|
||||
} from "@/generated/graphql";
|
||||
import useMediaQuery from "@/hooks/useMediaQuery";
|
||||
import useToggle from "@/hooks/useToggle";
|
||||
@@ -31,7 +31,7 @@ import createVideoSlug from "@/utils/createVideoSlug";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
|
||||
const StyledWrapper = styled.div`
|
||||
position: relative
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledOverlayButtons = styled.div`
|
||||
@@ -67,20 +67,29 @@ const StyledPerformedWith = styled.div`
|
||||
|
||||
const useIsMobile = () => useMediaQuery(`(max-width: ${theme.breakpoints.mobileMax})`);
|
||||
|
||||
type ThemeSummaryCardProps = {
|
||||
theme: ThemeSummaryCardThemeFragment
|
||||
artist?: ThemeSummaryCardArtistFragment
|
||||
expandable?: false
|
||||
onPlay?(entryIndex?: number, videoIndex?: number): void
|
||||
} | {
|
||||
theme: ThemeSummaryCardThemeFragment & ThemeSummaryCardThemeExpandableFragment
|
||||
artist?: ThemeSummaryCardArtistFragment
|
||||
expandable: true
|
||||
onPlay?(entryIndex?: number, videoIndex?: number): void
|
||||
};
|
||||
type ThemeSummaryCardProps =
|
||||
| {
|
||||
theme: ThemeSummaryCardThemeFragment;
|
||||
artist?: ThemeSummaryCardArtistFragment;
|
||||
expandable?: false;
|
||||
onPlay?(entryIndex?: number, videoIndex?: number): void;
|
||||
}
|
||||
| {
|
||||
theme: ThemeSummaryCardThemeFragment & ThemeSummaryCardThemeExpandableFragment;
|
||||
artist?: ThemeSummaryCardArtistFragment;
|
||||
expandable: true;
|
||||
onPlay?(entryIndex?: number, videoIndex?: number): void;
|
||||
};
|
||||
|
||||
// Specify an artist if you want to display this in an artist context (e.g. artist page)
|
||||
export function ThemeSummaryCard({ theme, artist, children, expandable, onPlay, ...props }: PropsWithChildren<ThemeSummaryCardProps>) {
|
||||
export function ThemeSummaryCard({
|
||||
theme,
|
||||
artist,
|
||||
children,
|
||||
expandable,
|
||||
onPlay,
|
||||
...props
|
||||
}: PropsWithChildren<ThemeSummaryCardProps>) {
|
||||
const [isExpanded, toggleExpanded] = useToggle();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@@ -125,13 +134,17 @@ export function ThemeSummaryCard({ theme, artist, children, expandable, onPlay,
|
||||
<Performances song={theme.song} artist={artist} />
|
||||
</SummaryCard.Title>
|
||||
<SummaryCard.Description>
|
||||
<span>{theme.type}{theme.sequence || null}{theme.group && ` (${theme.group.name})`}</span>
|
||||
<span>
|
||||
{theme.type}
|
||||
{theme.sequence || null}
|
||||
{theme.group && ` (${theme.group.name})`}
|
||||
</span>
|
||||
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
|
||||
</SummaryCard.Description>
|
||||
</SummaryCard.Body>
|
||||
{children}
|
||||
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
|
||||
<ThemeMenu theme={theme}/>
|
||||
<ThemeMenu theme={theme} />
|
||||
{expandable && (
|
||||
<StyledExpandButton
|
||||
variant="silent"
|
||||
@@ -151,7 +164,10 @@ export function ThemeSummaryCard({ theme, artist, children, expandable, onPlay,
|
||||
{expandable && (
|
||||
<Collapse collapse={!isExpanded}>
|
||||
<StyledPerformedWith>
|
||||
<ThemeTable themes={[theme]} onPlay={(_, entryIndex, videoIndex) => onPlay?.(entryIndex, videoIndex)}/>
|
||||
<ThemeTable
|
||||
themes={[theme]}
|
||||
onPlay={(_, entryIndex, videoIndex) => onPlay?.(entryIndex, videoIndex)}
|
||||
/>
|
||||
{(theme.song?.performances.length ?? 0) > (artist ? 1 : 0) && (
|
||||
<Table style={{ "--columns": "1fr" }}>
|
||||
<TableHead>
|
||||
@@ -166,11 +182,14 @@ export function ThemeSummaryCard({ theme, artist, children, expandable, onPlay,
|
||||
key={performance.artist.slug}
|
||||
href={`/artist/${performance.artist.slug}`}
|
||||
passHref
|
||||
legacyBehavior>
|
||||
legacyBehavior
|
||||
>
|
||||
<TableRow as="a">
|
||||
<TableCell>
|
||||
<Text color="text-primary" weight="600">
|
||||
{performance.as ? `${performance.as} (CV: ${performance.artist.name})` : performance.artist.name}
|
||||
{performance.as
|
||||
? `${performance.as} (CV: ${performance.artist.name})`
|
||||
: performance.artist.name}
|
||||
</Text>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -222,7 +241,7 @@ ThemeSummaryCard.fragments = {
|
||||
`,
|
||||
artist: gql`
|
||||
${SongTitleWithArtists.fragments.artist}
|
||||
|
||||
|
||||
fragment ThemeSummaryCardArtist on Artist {
|
||||
...SongTitleWithArtistsArtist
|
||||
}
|
||||
@@ -233,23 +252,26 @@ ThemeSummaryCard.fragments = {
|
||||
fragment ThemeSummaryCardThemeExpandable on Theme {
|
||||
...ThemeTableTheme
|
||||
}
|
||||
`
|
||||
`,
|
||||
};
|
||||
|
||||
export type FetchThemeSummaryCardData = ThemeSummaryCardQuery["theme"] | null;
|
||||
|
||||
export const fetchThemeSummaryCardData = async function (id: number): Promise<FetchThemeSummaryCardData> {
|
||||
return fetchDataClient<ThemeSummaryCardQuery, { themeId: number }>(gql`
|
||||
${ThemeSummaryCard.fragments.theme}
|
||||
|
||||
query ThemeSummaryCard($themeId: Int!) {
|
||||
theme(id: $themeId) {
|
||||
...ThemeSummaryCardTheme
|
||||
anime {
|
||||
year
|
||||
season
|
||||
return fetchDataClient<ThemeSummaryCardQuery, { themeId: number }>(
|
||||
gql`
|
||||
${ThemeSummaryCard.fragments.theme}
|
||||
|
||||
query ThemeSummaryCard($themeId: Int!) {
|
||||
theme(id: $themeId) {
|
||||
...ThemeSummaryCardTheme
|
||||
anime {
|
||||
year
|
||||
season
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`, { themeId: id }).then((result) => result.data?.theme ?? null);
|
||||
`,
|
||||
{ themeId: id },
|
||||
).then((result) => result.data?.theme ?? null);
|
||||
};
|
||||
|
||||
@@ -45,11 +45,11 @@ const StyledCoverLink = styled(Link)`
|
||||
const StyledCoverOverlay = styled.div`
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
`;
|
||||
|
||||
@@ -61,52 +61,54 @@ interface VideoSummaryCardProps {
|
||||
isPlaying?: boolean;
|
||||
}
|
||||
|
||||
export const VideoSummaryCard = forwardRef(
|
||||
function VideoSummaryCard({ video, menu, append, onPlay, isPlaying, ...props }: VideoSummaryCardProps, ref: ForwardedRef<HTMLDivElement>) {
|
||||
const entry = video.entries[0];
|
||||
const theme = entry.theme;
|
||||
const anime = theme?.anime;
|
||||
export const VideoSummaryCard = forwardRef(function VideoSummaryCard(
|
||||
{ video, menu, append, onPlay, isPlaying, ...props }: VideoSummaryCardProps,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const entry = video.entries[0];
|
||||
const theme = entry.theme;
|
||||
const anime = theme?.anime;
|
||||
|
||||
if (!entry || !theme || !anime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { smallCover } = extractImages(anime);
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
const href = `/anime/${anime.slug}/${videoSlug}`;
|
||||
|
||||
return (
|
||||
<StyledWrapper ref={ref}>
|
||||
<SummaryCard {...props}>
|
||||
<StyledCoverLink href={href} onClick={onPlay}>
|
||||
<SummaryCard.Cover src={smallCover} />
|
||||
{isPlaying ? (
|
||||
<StyledCoverOverlay>
|
||||
<Icon icon={faPlay} />
|
||||
</StyledCoverOverlay>
|
||||
) : null}
|
||||
</StyledCoverLink>
|
||||
<SummaryCard.Body>
|
||||
<SummaryCard.Title>
|
||||
<SongTitle song={theme.song} as={Link} href={href} onClick={onPlay} />
|
||||
<Performances song={theme.song} />
|
||||
</SummaryCard.Title>
|
||||
<SummaryCard.Description>
|
||||
<span>{videoSlug}{theme.group && ` (${theme.group.name})`}</span>
|
||||
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
|
||||
</SummaryCard.Description>
|
||||
</SummaryCard.Body>
|
||||
{menu ? (
|
||||
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>
|
||||
{menu}
|
||||
</StyledOverlayButtons>
|
||||
) : null}
|
||||
{append}
|
||||
</SummaryCard>
|
||||
</StyledWrapper>
|
||||
);
|
||||
if (!entry || !theme || !anime) {
|
||||
return null;
|
||||
}
|
||||
);
|
||||
|
||||
const { smallCover } = extractImages(anime);
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
const href = `/anime/${anime.slug}/${videoSlug}`;
|
||||
|
||||
return (
|
||||
<StyledWrapper ref={ref}>
|
||||
<SummaryCard {...props}>
|
||||
<StyledCoverLink href={href} onClick={onPlay}>
|
||||
<SummaryCard.Cover src={smallCover} />
|
||||
{isPlaying ? (
|
||||
<StyledCoverOverlay>
|
||||
<Icon icon={faPlay} />
|
||||
</StyledCoverOverlay>
|
||||
) : null}
|
||||
</StyledCoverLink>
|
||||
<SummaryCard.Body>
|
||||
<SummaryCard.Title>
|
||||
<SongTitle song={theme.song} as={Link} href={href} onClick={onPlay} />
|
||||
<Performances song={theme.song} />
|
||||
</SummaryCard.Title>
|
||||
<SummaryCard.Description>
|
||||
<span>
|
||||
{videoSlug}
|
||||
{theme.group && ` (${theme.group.name})`}
|
||||
</span>
|
||||
<TextLink href={`/anime/${anime.slug}`}>{anime.name}</TextLink>
|
||||
</SummaryCard.Description>
|
||||
</SummaryCard.Body>
|
||||
{menu ? (
|
||||
<StyledOverlayButtons onClick={(event) => event.stopPropagation()}>{menu}</StyledOverlayButtons>
|
||||
) : null}
|
||||
{append}
|
||||
</SummaryCard>
|
||||
</StyledWrapper>
|
||||
);
|
||||
});
|
||||
|
||||
export const VideoSummaryCardFragmentVideo = gql`
|
||||
${SongTitleWithArtists.fragments.song}
|
||||
|
||||
@@ -10,7 +10,7 @@ export const SidebarContainer = styled.div`
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
// This will prevent columns from overflowing
|
||||
& > * {
|
||||
min-width: 0;
|
||||
|
||||
@@ -22,27 +22,25 @@ const StyledValue = styled.dd`
|
||||
`;
|
||||
|
||||
interface DescriptionListProps extends ComponentPropsWithoutRef<typeof StyledDescriptionList> {
|
||||
children: ReactNode
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function DescriptionList({ children, ...props }: DescriptionListProps) {
|
||||
return (
|
||||
<StyledDescriptionList {...props}>
|
||||
{children}
|
||||
</StyledDescriptionList>
|
||||
);
|
||||
return <StyledDescriptionList {...props}>{children}</StyledDescriptionList>;
|
||||
}
|
||||
|
||||
interface DescriptionListItemProps {
|
||||
title: string
|
||||
children: ReactNode
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
DescriptionList.Item = function DescriptionListItem({ title, children }: DescriptionListItemProps) {
|
||||
return (
|
||||
<>
|
||||
<StyledKey>
|
||||
<Text as="span" variant="h2">{title}</Text>
|
||||
<Text as="span" variant="h2">
|
||||
{title}
|
||||
</Text>
|
||||
</StyledKey>
|
||||
<StyledValue>{children}</StyledValue>
|
||||
</>
|
||||
|
||||
@@ -38,11 +38,11 @@ const StyledDialogCard = styled(Card)`
|
||||
width: 100%;
|
||||
max-width: 450px;
|
||||
animation: ${contentAnimation} 250ms;
|
||||
|
||||
|
||||
margin: auto;
|
||||
padding: 24px;
|
||||
box-shadow: 0 0 0 2px ${theme.colors["text-disabled"]};
|
||||
|
||||
|
||||
&:before {
|
||||
display: none;
|
||||
}
|
||||
@@ -57,26 +57,27 @@ interface DialogContentProps extends RadixDialog.DialogContentProps {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(
|
||||
function DialogContent({ title, children, ...props }, ref) {
|
||||
return (
|
||||
<RadixDialog.Portal>
|
||||
<StyledOverlay>
|
||||
<RadixDialog.Content asChild {...props} ref={ref}>
|
||||
<StyledDialogCard>
|
||||
{title ? (
|
||||
<StyledHeader>
|
||||
<Text variant="h2">{title}</Text>
|
||||
</StyledHeader>
|
||||
) : null}
|
||||
{children}
|
||||
</StyledDialogCard>
|
||||
</RadixDialog.Content>
|
||||
</StyledOverlay>
|
||||
</RadixDialog.Portal>
|
||||
);
|
||||
}
|
||||
);
|
||||
export const DialogContent = forwardRef<HTMLDivElement, DialogContentProps>(function DialogContent(
|
||||
{ title, children, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<RadixDialog.Portal>
|
||||
<StyledOverlay>
|
||||
<RadixDialog.Content asChild {...props} ref={ref}>
|
||||
<StyledDialogCard>
|
||||
{title ? (
|
||||
<StyledHeader>
|
||||
<Text variant="h2">{title}</Text>
|
||||
</StyledHeader>
|
||||
) : null}
|
||||
{children}
|
||||
</StyledDialogCard>
|
||||
</RadixDialog.Content>
|
||||
</StyledOverlay>
|
||||
</RadixDialog.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
export const Dialog = RadixDialog.Root;
|
||||
|
||||
|
||||
@@ -27,16 +27,13 @@ export function ForgotPasswordDialog() {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Text variant="small" link color="text-muted">Forgot Password?</Text>
|
||||
<Text variant="small" link color="text-muted">
|
||||
Forgot Password?
|
||||
</Text>
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Request a Password Reset">
|
||||
{/* Only render the form when dialog is open, so it will reset after closing. */}
|
||||
{open ? (
|
||||
<ForgotPasswordForm
|
||||
onSuccess={() => setOpen(false)}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{open ? <ForgotPasswordForm onSuccess={() => setOpen(false)} onCancel={() => setOpen(false)} /> : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -72,10 +69,7 @@ function ForgotPasswordForm({ onSuccess, onCancel }: ForgotPasswordProps) {
|
||||
email,
|
||||
});
|
||||
|
||||
dispatchToast(
|
||||
"forgot-password-sent",
|
||||
<Toast>We sent you an e-mail with a password reset link.</Toast>
|
||||
);
|
||||
dispatchToast("forgot-password-sent", <Toast>We sent you an e-mail with a password reset link.</Toast>);
|
||||
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
@@ -103,12 +97,12 @@ function ForgotPasswordForm({ onSuccess, onCancel }: ForgotPasswordProps) {
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
{errors.email ? (
|
||||
<Text color="text-warning">{errors.email}</Text>
|
||||
) : null}
|
||||
{errors.email ? <Text color="text-warning">{errors.email}</Text> : null}
|
||||
</SearchFilter>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
|
||||
<Busy isBusy={isBusy}>Submit</Busy>
|
||||
</Button>
|
||||
|
||||
@@ -11,7 +11,7 @@ import Switch from "@/components/form/Switch";
|
||||
import { SearchFilter } from "@/components/search-filter/SearchFilter";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import useAuth, { type LoginErrors } from "@/hooks/useAuth";
|
||||
|
||||
const StyledForm = styled.form`
|
||||
display: flex;
|
||||
@@ -29,9 +29,7 @@ export function LoginDialog() {
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Login">
|
||||
{/* Only render the form when dialog is open, so it will reset after closing. */}
|
||||
{open ? (
|
||||
<LoginForm onCancel={() => setOpen(false)} />
|
||||
) : null}
|
||||
{open ? <LoginForm onCancel={() => setOpen(false)} /> : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -51,9 +49,7 @@ function LoginForm({ onCancel }: LoginFormProps) {
|
||||
const isValid = email && password;
|
||||
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
const [errors, setErrors] = useState<{
|
||||
email?: string;
|
||||
}>({});
|
||||
const [errors, setErrors] = useState<LoginErrors>({});
|
||||
|
||||
function performLogin(event: SyntheticEvent) {
|
||||
event.preventDefault();
|
||||
@@ -65,8 +61,7 @@ function LoginForm({ onCancel }: LoginFormProps) {
|
||||
email,
|
||||
password,
|
||||
remember: isRemember,
|
||||
})
|
||||
.finally(() => setBusy(false));
|
||||
}).finally(() => setBusy(false));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -83,9 +78,13 @@ function LoginForm({ onCancel }: LoginFormProps) {
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
{errors.email ? (
|
||||
<Text color="text-warning">{errors.email}</Text>
|
||||
) : null}
|
||||
{errors.email
|
||||
? errors.email.map((error) => (
|
||||
<Text key={error} color="text-warning">
|
||||
{error}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>Password</Text>
|
||||
@@ -100,15 +99,15 @@ function LoginForm({ onCancel }: LoginFormProps) {
|
||||
<ForgotPasswordDialog />
|
||||
</SearchFilter>
|
||||
<Row style={{ "--gap": "12px", "--align-items": "center" }}>
|
||||
<Switch
|
||||
id="input-remember"
|
||||
isChecked={isRemember}
|
||||
onCheckedChange={setRemember}
|
||||
/>
|
||||
<Text as="label" htmlFor="input-remember">Remember my login on this device.</Text>
|
||||
<Switch id="input-remember" isChecked={isRemember} onCheckedChange={setRemember} />
|
||||
<Text as="label" htmlFor="input-remember">
|
||||
Remember my login on this device.
|
||||
</Text>
|
||||
</Row>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
|
||||
<Busy isBusy={isBusy}>Login</Busy>
|
||||
</Button>
|
||||
|
||||
@@ -26,12 +26,7 @@ export function PasswordChangeDialog() {
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Change Password">
|
||||
{/* Only render the form when dialog is open, so it will reset after closing. */}
|
||||
{open ? (
|
||||
<PasswordChangeForm
|
||||
onSuccess={() => setOpen(false)}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{open ? <PasswordChangeForm onSuccess={() => setOpen(false)} onCancel={() => setOpen(false)} /> : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -70,19 +65,13 @@ function PasswordChangeForm({ onSuccess, onCancel }: PasswordChangeFormProps) {
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
await axios.put(
|
||||
`${AUTH_PATH}/user/password`,
|
||||
{
|
||||
current_password: currentPassword,
|
||||
password: newPassword,
|
||||
password_confirmation: newPasswordConfirmation,
|
||||
}
|
||||
);
|
||||
await axios.put(`${AUTH_PATH}/user/password`, {
|
||||
current_password: currentPassword,
|
||||
password: newPassword,
|
||||
password_confirmation: newPasswordConfirmation,
|
||||
});
|
||||
|
||||
dispatchToast(
|
||||
"password-change",
|
||||
<Toast>Password changed successfully.</Toast>
|
||||
);
|
||||
dispatchToast("password-change", <Toast>Password changed successfully.</Toast>);
|
||||
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
@@ -110,9 +99,13 @@ function PasswordChangeForm({ onSuccess, onCancel }: PasswordChangeFormProps) {
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
{errors.current_password ? errors.current_password.map((error) => (
|
||||
<Text key={error} color="text-warning">{error}</Text>
|
||||
)) : null}
|
||||
{errors.current_password
|
||||
? errors.current_password.map((error) => (
|
||||
<Text key={error} color="text-warning">
|
||||
{error}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>New Password</Text>
|
||||
@@ -124,9 +117,13 @@ function PasswordChangeForm({ onSuccess, onCancel }: PasswordChangeFormProps) {
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
{errors.password ? errors.password.map((error) => (
|
||||
<Text key={error} color="text-warning">{error}</Text>
|
||||
)) : null}
|
||||
{errors.password
|
||||
? errors.password.map((error) => (
|
||||
<Text key={error} color="text-warning">
|
||||
{error}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>Confirm New Password</Text>
|
||||
@@ -140,7 +137,9 @@ function PasswordChangeForm({ onSuccess, onCancel }: PasswordChangeFormProps) {
|
||||
/>
|
||||
</SearchFilter>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
|
||||
<Busy isBusy={isBusy}>Change Password</Busy>
|
||||
</Button>
|
||||
|
||||
@@ -26,10 +26,7 @@ export function PasswordResetDialog() {
|
||||
return (
|
||||
<Dialog open={open}>
|
||||
<DialogContent title="Reset Password">
|
||||
<PasswordResetForm
|
||||
onSuccess={() => router.push("/profile")}
|
||||
onCancel={() => router.push("/")}
|
||||
/>
|
||||
<PasswordResetForm onSuccess={() => router.push("/profile")} onCancel={() => router.push("/")} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -75,13 +72,10 @@ function PasswordResetForm({ onSuccess, onCancel }: PasswordResetFormProps) {
|
||||
email,
|
||||
password: newPassword,
|
||||
password_confirmation: newPasswordConfirmation,
|
||||
token,
|
||||
token: (Array.isArray(token) ? token[0] : token) ?? "",
|
||||
});
|
||||
|
||||
dispatchToast(
|
||||
"password-reset",
|
||||
<Toast>Password reset successfully.</Toast>
|
||||
);
|
||||
dispatchToast("password-reset", <Toast>Password reset successfully.</Toast>);
|
||||
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
@@ -109,9 +103,13 @@ function PasswordResetForm({ onSuccess, onCancel }: PasswordResetFormProps) {
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
{errors.email ? errors.email.map((error) => (
|
||||
<Text key={error} color="text-warning">{error}</Text>
|
||||
)) : null}
|
||||
{errors.email
|
||||
? errors.email.map((error) => (
|
||||
<Text key={error} color="text-warning">
|
||||
{error}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>New Password</Text>
|
||||
@@ -123,9 +121,13 @@ function PasswordResetForm({ onSuccess, onCancel }: PasswordResetFormProps) {
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
{errors.password ? errors.password.map((error) => (
|
||||
<Text key={error} color="text-warning">{error}</Text>
|
||||
)) : null}
|
||||
{errors.password
|
||||
? errors.password.map((error) => (
|
||||
<Text key={error} color="text-warning">
|
||||
{error}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>Confirm New Password</Text>
|
||||
@@ -139,7 +141,9 @@ function PasswordResetForm({ onSuccess, onCancel }: PasswordResetFormProps) {
|
||||
/>
|
||||
</SearchFilter>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
|
||||
<Busy isBusy={isBusy}>Reset Password</Busy>
|
||||
</Button>
|
||||
|
||||
@@ -29,17 +29,16 @@ export function PlaylistAddDialog({ trigger }: PlaylistAddDialogProps) {
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<IconTextButton icon={faPlus} collapsible>New</IconTextButton>
|
||||
<IconTextButton icon={faPlus} collapsible>
|
||||
New
|
||||
</IconTextButton>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Create a new playlist">
|
||||
{/* Only render the form when dialog is open, so it will reset after closing. */}
|
||||
{open ? (
|
||||
<LoginGate>
|
||||
<PlaylistAddForm
|
||||
onSuccess={() => setOpen(false)}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
<PlaylistAddForm onSuccess={() => setOpen(false)} onCancel={() => setOpen(false)} />
|
||||
</LoginGate>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
@@ -95,10 +94,7 @@ function PlaylistAddForm({ onSuccess, onCancel }: PlaylistAddFormProps) {
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<SearchFilter>
|
||||
<Text>Title</Text>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={setTitle}
|
||||
/>
|
||||
<Input value={title} onChange={setTitle} />
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>Visibility</Text>
|
||||
@@ -109,13 +105,18 @@ function PlaylistAddForm({ onSuccess, onCancel }: PlaylistAddFormProps) {
|
||||
</Listbox>
|
||||
</SearchFilter>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
|
||||
<Busy isBusy={isBusy}>Create Playlist</Busy>
|
||||
</Button>
|
||||
</Row>
|
||||
{error ? (
|
||||
<Text color="text-warning"><strong>The playlist could not be created: </strong>{error}</Text>
|
||||
<Text color="text-warning">
|
||||
<strong>The playlist could not be created: </strong>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</Column>
|
||||
</StyledForm>
|
||||
|
||||
@@ -32,7 +32,9 @@ export function PlaylistEditDialog({ playlist, trigger }: PlaylistEditDialogProp
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<IconTextButton icon={faPen} variant="solid">Edit Playlist</IconTextButton>
|
||||
<IconTextButton icon={faPen} variant="solid">
|
||||
Edit Playlist
|
||||
</IconTextButton>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Edit playlist details">
|
||||
@@ -91,12 +93,9 @@ function PlaylistEditForm({ playlist, onSuccess, onCancel }: PlaylistEditFormPro
|
||||
name: title,
|
||||
visibility,
|
||||
});
|
||||
await mutate((key) => (
|
||||
[key].flat().some((key) =>
|
||||
key === `/api/playlist/${playlist.id}` ||
|
||||
key === "/api/me/playlist"
|
||||
)
|
||||
));
|
||||
await mutate((key) =>
|
||||
[key].flat().some((key) => key === `/api/playlist/${playlist.id}` || key === "/api/me/playlist"),
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (isAxiosError(error) && error.response) {
|
||||
setError(error.response.data.message ?? "An unknown error occured!");
|
||||
@@ -115,10 +114,7 @@ function PlaylistEditForm({ playlist, onSuccess, onCancel }: PlaylistEditFormPro
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<SearchFilter>
|
||||
<Text>Title</Text>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={setTitle}
|
||||
/>
|
||||
<Input value={title} onChange={setTitle} />
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>Visibility</Text>
|
||||
@@ -129,13 +125,18 @@ function PlaylistEditForm({ playlist, onSuccess, onCancel }: PlaylistEditFormPro
|
||||
</Listbox>
|
||||
</SearchFilter>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
|
||||
<Busy isBusy={isBusy}>Update Playlist</Busy>
|
||||
</Button>
|
||||
</Row>
|
||||
{error ? (
|
||||
<Text color="text-warning"><strong>The playlist could not be updated: </strong>{error}</Text>
|
||||
<Text color="text-warning">
|
||||
<strong>The playlist could not be updated: </strong>
|
||||
{error}
|
||||
</Text>
|
||||
) : null}
|
||||
</Column>
|
||||
</StyledForm>
|
||||
|
||||
@@ -30,7 +30,9 @@ export function PlaylistRemoveDialog({ playlist, trigger }: PlaylistRemoveDialog
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<IconTextButton icon={faMinus} variant="solid" collapsible>Delete playlist</IconTextButton>
|
||||
<IconTextButton icon={faMinus} variant="solid" collapsible>
|
||||
Delete playlist
|
||||
</IconTextButton>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Delete playlist">
|
||||
@@ -84,20 +86,25 @@ function PlaylistRemoveForm({ playlist, onSuccess, onCancel }: PlaylistRemoveFor
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
dispatchToast(
|
||||
`playlist-remove-${playlist.id}`,
|
||||
<PlaylistRemoveToast playlist={playlist} />
|
||||
);
|
||||
dispatchToast(`playlist-remove-${playlist.id}`, <PlaylistRemoveToast playlist={playlist} />);
|
||||
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
return (
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<Text>Do you really want to delete <Text color="text-primary" link noWrap>{playlist.name}</Text>?</Text>
|
||||
<Text>
|
||||
Do you really want to delete{" "}
|
||||
<Text color="text-primary" link noWrap>
|
||||
{playlist.name}
|
||||
</Text>
|
||||
?
|
||||
</Text>
|
||||
<PlaylistSummaryCard playlist={playlist} />
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button variant="silent" onClick={onCancel}>Close</Button>
|
||||
<Button variant="silent" onClick={onCancel}>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="warning" disabled={isBusy} onClick={removePlaylist}>
|
||||
<Busy isBusy={isBusy}>Delete playlist</Busy>
|
||||
</Button>
|
||||
|
||||
@@ -23,7 +23,7 @@ import { useToasts } from "@/context/toastContext";
|
||||
import type {
|
||||
PlaylistTrackAddDialogVideoFragment,
|
||||
PlaylistTrackAddFormPlaylistQuery,
|
||||
PlaylistTrackAddFormPlaylistQueryVariables
|
||||
PlaylistTrackAddFormPlaylistQueryVariables,
|
||||
} from "@/generated/graphql";
|
||||
import { fetchDataClient } from "@/lib/client";
|
||||
import axios from "@/lib/client/axios";
|
||||
@@ -40,17 +40,16 @@ export function PlaylistTrackAddDialog({ video, trigger }: PlaylistTrackAddDialo
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<IconTextButton icon={faPlus} variant="solid" collapsible>Add to Playlist</IconTextButton>
|
||||
<IconTextButton icon={faPlus} variant="solid" collapsible>
|
||||
Add to Playlist
|
||||
</IconTextButton>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Add to playlist">
|
||||
{/* Only render the form when dialog is open, so it will reset after closing. */}
|
||||
{open ? (
|
||||
<LoginGate>
|
||||
<PlaylistTrackAddForm
|
||||
video={video}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
<PlaylistTrackAddForm video={video} onCancel={() => setOpen(false)} />
|
||||
</LoginGate>
|
||||
) : null}
|
||||
</DialogContent>
|
||||
@@ -62,7 +61,7 @@ PlaylistTrackAddDialog.fragments = {
|
||||
video: gql`
|
||||
${VideoSummaryCardFragmentVideo}
|
||||
${PlaylistTrackRemoveToast.fragments.video}
|
||||
|
||||
|
||||
fragment PlaylistTrackAddDialogVideo on Video {
|
||||
...VideoSummaryCardVideo
|
||||
...PlaylistTrackRemoveToastVideo
|
||||
@@ -77,12 +76,14 @@ interface PlaylistTrackAddFormProps {
|
||||
}
|
||||
|
||||
function PlaylistTrackAddForm({ video, onCancel }: PlaylistTrackAddFormProps) {
|
||||
const { data: playlists } = useSWR(
|
||||
["PlaylistTrackAddFormPlaylist", "/api/me/playlist", video.id],
|
||||
async () => {
|
||||
const { data } = await fetchDataClient<PlaylistTrackAddFormPlaylistQuery, PlaylistTrackAddFormPlaylistQueryVariables>(gql`
|
||||
const { data: playlists } = useSWR(["PlaylistTrackAddFormPlaylist", "/api/me/playlist", video.id], async () => {
|
||||
const { data } = await fetchDataClient<
|
||||
PlaylistTrackAddFormPlaylistQuery,
|
||||
PlaylistTrackAddFormPlaylistQueryVariables
|
||||
>(
|
||||
gql`
|
||||
${PlaylistSummaryCard.fragments.playlist}
|
||||
|
||||
|
||||
query PlaylistTrackAddFormPlaylist($filterVideoId: Int!) {
|
||||
me {
|
||||
playlistAll {
|
||||
@@ -98,18 +99,21 @@ function PlaylistTrackAddForm({ video, onCancel }: PlaylistTrackAddFormProps) {
|
||||
}
|
||||
}
|
||||
}
|
||||
`, { filterVideoId: video.id });
|
||||
`,
|
||||
{ filterVideoId: video.id },
|
||||
);
|
||||
|
||||
const { playlistAll, playlistAllFiltered } = data.me;
|
||||
const { playlistAll, playlistAllFiltered } = data.me;
|
||||
|
||||
return playlistAll?.map((playlist) => {
|
||||
return (
|
||||
playlistAll?.map((playlist) => {
|
||||
return {
|
||||
...playlist,
|
||||
...playlistAllFiltered?.find((p) => p.id === playlist.id)
|
||||
...playlistAllFiltered?.find((p) => p.id === playlist.id),
|
||||
};
|
||||
}) ?? [];
|
||||
},
|
||||
);
|
||||
}) ?? []
|
||||
);
|
||||
});
|
||||
|
||||
if (!playlists) {
|
||||
return (
|
||||
@@ -129,32 +133,33 @@ function PlaylistTrackAddForm({ video, onCancel }: PlaylistTrackAddFormProps) {
|
||||
<Icon icon={faArrowDown} color="text-disabled" />
|
||||
</Row>
|
||||
<Column style={{ "--gap": "16px" }}>
|
||||
{playlists?.length ? playlists.map((playlist) => (
|
||||
<PlaylistTrackAddCard
|
||||
key={playlist.id}
|
||||
playlist={playlist}
|
||||
video={video}
|
||||
/>
|
||||
)) : (
|
||||
{playlists?.length ? (
|
||||
playlists.map((playlist) => (
|
||||
<PlaylistTrackAddCard key={playlist.id} playlist={playlist} video={video} />
|
||||
))
|
||||
) : (
|
||||
<Text>You have not created a playlist, yet.</Text>
|
||||
)}
|
||||
<PlaylistAddDialog trigger={
|
||||
<Button style={{ "--gap": "8px" }}>
|
||||
<Icon icon={faPlus} />
|
||||
<Text>Create new Playlist</Text>
|
||||
</Button>
|
||||
} />
|
||||
<PlaylistAddDialog
|
||||
trigger={
|
||||
<Button style={{ "--gap": "8px" }}>
|
||||
<Icon icon={faPlus} />
|
||||
<Text>Create new Playlist</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Column>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button variant="silent" onClick={onCancel}>Close</Button>
|
||||
<Button variant="silent" onClick={onCancel}>
|
||||
Close
|
||||
</Button>
|
||||
</Row>
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
interface PlaylistTrackAddCardProps {
|
||||
playlist:
|
||||
NonNullable<PlaylistTrackAddFormPlaylistQuery["me"]["playlistAll"]>[number] &
|
||||
playlist: NonNullable<PlaylistTrackAddFormPlaylistQuery["me"]["playlistAll"]>[number] &
|
||||
Partial<NonNullable<PlaylistTrackAddFormPlaylistQuery["me"]["playlistAllFiltered"]>[number]>;
|
||||
video: PlaylistTrackAddDialogVideoFragment;
|
||||
}
|
||||
@@ -168,20 +173,17 @@ function PlaylistTrackAddCard({ playlist, video }: PlaylistTrackAddCardProps) {
|
||||
setBusy(true);
|
||||
|
||||
try {
|
||||
await axios.post(`/playlist/${playlist.id}/track`, { video_id: video.id, });
|
||||
await mutate((key) => (
|
||||
[key].flat().some((key) =>
|
||||
key === `/api/playlist/${playlist.id}` ||
|
||||
key === "/api/me/playlist"
|
||||
)
|
||||
));
|
||||
await axios.post(`/playlist/${playlist.id}/track`, { video_id: video.id });
|
||||
await mutate((key) =>
|
||||
[key].flat().some((key) => key === `/api/playlist/${playlist.id}` || key === "/api/me/playlist"),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
dispatchToast(
|
||||
`playlist-add-track-${playlist.id}-${video.id}`,
|
||||
<PlaylistTrackAddToast playlist={playlist} video={video} />
|
||||
<PlaylistTrackAddToast playlist={playlist} video={video} />,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -196,38 +198,27 @@ function PlaylistTrackAddCard({ playlist, video }: PlaylistTrackAddCardProps) {
|
||||
|
||||
try {
|
||||
await axios.delete(`/playlist/${playlist.id}/track/${track.id}`);
|
||||
await mutate((key) => (
|
||||
[key].flat().some((key) =>
|
||||
key === `/api/playlist/${playlist.id}` ||
|
||||
key === "/api/me/playlist"
|
||||
)
|
||||
));
|
||||
await mutate((key) =>
|
||||
[key].flat().some((key) => key === `/api/playlist/${playlist.id}` || key === "/api/me/playlist"),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
dispatchToast(
|
||||
`playlist-remove-track-${playlist.id}-${track.id}`,
|
||||
<PlaylistTrackRemoveToast playlist={playlist} video={video} />
|
||||
<PlaylistTrackRemoveToast playlist={playlist} video={video} />,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PlaylistSummaryCard key={playlist.id} playlist={playlist}>
|
||||
{!playlist.tracks?.length ? (
|
||||
<IconTextButton
|
||||
icon={faPlus}
|
||||
disabled={isBusy}
|
||||
onClick={addTrackToPlaylist}
|
||||
>
|
||||
<IconTextButton icon={faPlus} disabled={isBusy} onClick={addTrackToPlaylist}>
|
||||
<Busy isBusy={isBusy}>Add</Busy>
|
||||
</IconTextButton>
|
||||
) : (
|
||||
<IconTextButton
|
||||
icon={faMinus}
|
||||
disabled={isBusy}
|
||||
onClick={removeTrackFromPlaylist}
|
||||
>
|
||||
<IconTextButton icon={faMinus} disabled={isBusy} onClick={removeTrackFromPlaylist}>
|
||||
<Busy isBusy={isBusy}>Remove</Busy>
|
||||
</IconTextButton>
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { Busy } from "@/components/utils/Busy";
|
||||
import { useToasts } from "@/context/toastContext";
|
||||
import type {
|
||||
PlaylistTrackRemoveDialogPlaylistFragment,
|
||||
PlaylistTrackRemoveDialogVideoFragment
|
||||
PlaylistTrackRemoveDialogVideoFragment,
|
||||
} from "@/generated/graphql";
|
||||
import axios from "@/lib/client/axios";
|
||||
|
||||
@@ -35,7 +35,9 @@ export function PlaylistTrackRemoveDialog({ playlist, trackId, video, trigger }:
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<IconTextButton icon={faMinus} variant="solid" collapsible>Remove from Playlist</IconTextButton>
|
||||
<IconTextButton icon={faMinus} variant="solid" collapsible>
|
||||
Remove from Playlist
|
||||
</IconTextButton>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Remove from playlist">
|
||||
@@ -69,7 +71,7 @@ PlaylistTrackRemoveDialog.fragments = {
|
||||
video: gql`
|
||||
${VideoSummaryCardFragmentVideo}
|
||||
${PlaylistTrackRemoveToast.fragments.video}
|
||||
|
||||
|
||||
fragment PlaylistTrackRemoveDialogVideo on Video {
|
||||
...VideoSummaryCardVideo
|
||||
...PlaylistTrackRemoveToastVideo
|
||||
@@ -95,30 +97,35 @@ function PlaylistTrackRemoveForm({ playlist, trackId, video, onSuccess, onCancel
|
||||
|
||||
try {
|
||||
await axios.delete(`/playlist/${playlist.id}/track/${trackId}`);
|
||||
await mutate((key) => (
|
||||
[key].flat().some((key) =>
|
||||
key === `/api/playlist/${playlist.id}` ||
|
||||
key === "/api/me/playlist"
|
||||
)
|
||||
));
|
||||
await mutate((key) =>
|
||||
[key].flat().some((key) => key === `/api/playlist/${playlist.id}` || key === "/api/me/playlist"),
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
dispatchToast(
|
||||
`playlist-remove-track-${playlist.id}-${trackId}`,
|
||||
<PlaylistTrackRemoveToast playlist={playlist} video={video} />
|
||||
<PlaylistTrackRemoveToast playlist={playlist} video={video} />,
|
||||
);
|
||||
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Column style={{ "--gap": "24px" }}>
|
||||
<Text>Do you really want to remove this video from <Text color="text-primary" link noWrap>{playlist.name}</Text>?</Text>
|
||||
<Text>
|
||||
Do you really want to remove this video from{" "}
|
||||
<Text color="text-primary" link noWrap>
|
||||
{playlist.name}
|
||||
</Text>
|
||||
?
|
||||
</Text>
|
||||
<VideoSummaryCard video={video} />
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button variant="silent" onClick={onCancel}>Close</Button>
|
||||
<Button variant="silent" onClick={onCancel}>
|
||||
Close
|
||||
</Button>
|
||||
<Button variant="warning" disabled={isBusy} onClick={removeTrackFromPlaylist}>
|
||||
<Busy isBusy={isBusy}>Remove from playlist</Busy>
|
||||
</Button>
|
||||
|
||||
@@ -11,7 +11,7 @@ import Switch from "@/components/form/Switch";
|
||||
import { SearchFilter } from "@/components/search-filter/SearchFilter";
|
||||
import { Text } from "@/components/text/Text";
|
||||
import { Busy } from "@/components/utils/Busy";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import useAuth, { type RegisterErrors } from "@/hooks/useAuth";
|
||||
|
||||
export function RegisterDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -23,9 +23,7 @@ export function RegisterDialog() {
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Create a new account">
|
||||
{/* Only render the form when dialog is open, so it will reset after closing. */}
|
||||
{open ? (
|
||||
<RegisterForm onCancel={() => setOpen(false)} />
|
||||
) : null}
|
||||
{open ? <RegisterForm onCancel={() => setOpen(false)} /> : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -53,11 +51,7 @@ function RegisterForm({ onCancel }: RegisterFormProps) {
|
||||
const isValid = username && email && password && passwordConfirmation && isTermsAccepted;
|
||||
|
||||
const [isBusy, setBusy] = useState(false);
|
||||
const [errors, setErrors] = useState<{
|
||||
name?: string[];
|
||||
email?: string[];
|
||||
password?: string[]
|
||||
}>({});
|
||||
const [errors, setErrors] = useState<RegisterErrors>({});
|
||||
|
||||
function performRegister(event: SyntheticEvent) {
|
||||
event.preventDefault();
|
||||
@@ -71,8 +65,7 @@ function RegisterForm({ onCancel }: RegisterFormProps) {
|
||||
password,
|
||||
password_confirmation: passwordConfirmation,
|
||||
terms: isTermsAccepted,
|
||||
})
|
||||
.finally(() => setBusy(false));
|
||||
}).finally(() => setBusy(false));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -88,9 +81,13 @@ function RegisterForm({ onCancel }: RegisterFormProps) {
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
{errors.name ? errors.name.map((error) => (
|
||||
<Text key={error} color="text-warning">{error}</Text>
|
||||
)) : null}
|
||||
{errors.name
|
||||
? errors.name.map((error) => (
|
||||
<Text key={error} color="text-warning">
|
||||
{error}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>E-Mail</Text>
|
||||
@@ -102,9 +99,13 @@ function RegisterForm({ onCancel }: RegisterFormProps) {
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
{errors.email ? errors.email.map((error) => (
|
||||
<Text key={error} color="text-warning">{error}</Text>
|
||||
)) : null}
|
||||
{errors.email
|
||||
? errors.email.map((error) => (
|
||||
<Text key={error} color="text-warning">
|
||||
{error}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>Password</Text>
|
||||
@@ -116,9 +117,13 @@ function RegisterForm({ onCancel }: RegisterFormProps) {
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
{errors.password ? errors.password.map((error) => (
|
||||
<Text key={error} color="text-warning">{error}</Text>
|
||||
)) : null}
|
||||
{errors.password
|
||||
? errors.password.map((error) => (
|
||||
<Text key={error} color="text-warning">
|
||||
{error}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>Confirm Password</Text>
|
||||
@@ -132,18 +137,23 @@ function RegisterForm({ onCancel }: RegisterFormProps) {
|
||||
/>
|
||||
</SearchFilter>
|
||||
<Row style={{ "--gap": "12px", "--align-items": "center" }}>
|
||||
<Switch
|
||||
id="input-terms"
|
||||
isChecked={isTermsAccepted}
|
||||
onCheckedChange={setTermsAccepted}
|
||||
/>
|
||||
<Text as="label" htmlFor="input-terms">I accept the{" "}
|
||||
<Text as={Link} href="/about/terms-of-service" link>Terms of Service</Text> and{" "}
|
||||
<Text as={Link} href="/about/privacy-policy" link>Privacy Policy</Text>.
|
||||
<Switch id="input-terms" isChecked={isTermsAccepted} onCheckedChange={setTermsAccepted} />
|
||||
<Text as="label" htmlFor="input-terms">
|
||||
I accept the{" "}
|
||||
<Text as={Link} href="/about/terms-of-service" link>
|
||||
Terms of Service
|
||||
</Text>{" "}
|
||||
and{" "}
|
||||
<Text as={Link} href="/about/privacy-policy" link>
|
||||
Privacy Policy
|
||||
</Text>
|
||||
.
|
||||
</Text>
|
||||
</Row>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
|
||||
<Busy isBusy={isBusy}>Create Account</Busy>
|
||||
</Button>
|
||||
@@ -152,4 +162,4 @@ function RegisterForm({ onCancel }: RegisterFormProps) {
|
||||
</StyledForm>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,17 +21,10 @@ export function ShuffleDialog({ trigger }: ShuffleDialogProps) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
{trigger}
|
||||
</DialogTrigger>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent title="Shuffle">
|
||||
{/* Only render the form when dialog is open, so it will reset after closing. */}
|
||||
{open ? (
|
||||
<ShuffleForm
|
||||
onCancel={() => setOpen(false)}
|
||||
onSuccess={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{open ? <ShuffleForm onCancel={() => setOpen(false)} onSuccess={() => setOpen(false)} /> : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -39,7 +32,7 @@ export function ShuffleDialog({ trigger }: ShuffleDialogProps) {
|
||||
|
||||
const StyledForm = styled.form`
|
||||
position: relative;
|
||||
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
@@ -89,15 +82,25 @@ function ShuffleForm({ onSuccess, onCancel }: ShuffleFormProps) {
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text variant="h2">Premiered After</Text>
|
||||
<Input value={filterAnimeYearMin} onChange={setFilterAnimeYearMin} inputProps={{ type: "number", placeholder: "1900" }} />
|
||||
<Input
|
||||
value={filterAnimeYearMin}
|
||||
onChange={setFilterAnimeYearMin}
|
||||
inputProps={{ type: "number", placeholder: "1900" }}
|
||||
/>
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text variant="h2">Premiered Before</Text>
|
||||
<Input value={filterAnimeYearMax} onChange={setFilterAnimeYearMax} inputProps={{ type: "number", placeholder: "2100" }} />
|
||||
<Input
|
||||
value={filterAnimeYearMax}
|
||||
onChange={setFilterAnimeYearMax}
|
||||
inputProps={{ type: "number", placeholder: "2100" }}
|
||||
/>
|
||||
</SearchFilter>
|
||||
</SearchFilterGroup>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={isBusy}>
|
||||
<Busy isBusy={isBusy}>Start Shuffle</Busy>
|
||||
</Button>
|
||||
|
||||
@@ -28,12 +28,7 @@ export function UserInformationDialog() {
|
||||
</DialogTrigger>
|
||||
<DialogContent title="Change User Information">
|
||||
{/* Only render the form when dialog is open, so it will reset after closing. */}
|
||||
{open ? (
|
||||
<UserInformationForm
|
||||
onSuccess={() => setOpen(false)}
|
||||
onCancel={() => setOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
{open ? <UserInformationForm onSuccess={() => setOpen(false)} onCancel={() => setOpen(false)} /> : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -72,21 +67,13 @@ function UserInformationForm({ onSuccess, onCancel }: UserInformationFormProps)
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
await axios.put(
|
||||
`${AUTH_PATH}/user/profile-information`,
|
||||
{
|
||||
name: username,
|
||||
email: email,
|
||||
}
|
||||
);
|
||||
await mutate((key) => (
|
||||
[key].flat().some((key) => key === "/api/me")
|
||||
));
|
||||
await axios.put(`${AUTH_PATH}/user/profile-information`, {
|
||||
name: username,
|
||||
email: email,
|
||||
});
|
||||
await mutate((key) => [key].flat().some((key) => key === "/api/me"));
|
||||
|
||||
dispatchToast(
|
||||
"email-change",
|
||||
<Toast>User information changed successfully.</Toast>
|
||||
);
|
||||
dispatchToast("email-change", <Toast>User information changed successfully.</Toast>);
|
||||
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
@@ -113,9 +100,13 @@ function UserInformationForm({ onSuccess, onCancel }: UserInformationFormProps)
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
{errors.name ? errors.name.map((error) => (
|
||||
<Text key={error} color="text-warning">{error}</Text>
|
||||
)) : null}
|
||||
{errors.name
|
||||
? errors.name.map((error) => (
|
||||
<Text key={error} color="text-warning">
|
||||
{error}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
</SearchFilter>
|
||||
<SearchFilter>
|
||||
<Text>E-Mail Address</Text>
|
||||
@@ -127,12 +118,18 @@ function UserInformationForm({ onSuccess, onCancel }: UserInformationFormProps)
|
||||
required: true,
|
||||
}}
|
||||
/>
|
||||
{errors.email ? errors.email.map((error) => (
|
||||
<Text key={error} color="text-warning">{error}</Text>
|
||||
)) : null}
|
||||
{errors.email
|
||||
? errors.email.map((error) => (
|
||||
<Text key={error} color="text-warning">
|
||||
{error}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
</SearchFilter>
|
||||
<Row $wrap style={{ "--gap": "8px", "--justify-content": "flex-end" }}>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>Cancel</Button>
|
||||
<Button type="button" variant="silent" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" disabled={!isValid || isBusy}>
|
||||
<Busy isBusy={isBusy}>Update</Busy>
|
||||
</Button>
|
||||
|
||||
@@ -10,7 +10,7 @@ import theme from "@/theme";
|
||||
|
||||
const EventButtonContainer = styled.div`
|
||||
position: relative;
|
||||
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
@@ -26,7 +26,7 @@ const EventButton = styled(Button)`
|
||||
|
||||
const EventIcon = styled(Icon)`
|
||||
margin: 0 0 -1rem -2rem;
|
||||
|
||||
|
||||
font-size: 56px;
|
||||
color: ${theme.colors["text-disabled"]};
|
||||
`;
|
||||
@@ -39,15 +39,15 @@ const GradientBorder = styled.div`
|
||||
width: calc(100% + 4px);
|
||||
height: calc(100% + 4px);
|
||||
transform: translate(-2px, -2px);
|
||||
|
||||
|
||||
@property --gradient-angle {
|
||||
syntax: '<angle>';
|
||||
syntax: "<angle>";
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
--gradient-angle: 360deg;
|
||||
|
||||
|
||||
background: linear-gradient(var(--gradient-angle), ${theme.colors["text-primary"]}, #fff);
|
||||
animation: ${keyframes`
|
||||
from {
|
||||
@@ -57,10 +57,10 @@ const GradientBorder = styled.div`
|
||||
--gradient-angle: 360deg;
|
||||
}
|
||||
`} 5s linear infinite;
|
||||
|
||||
|
||||
transition: opacity 250ms;
|
||||
opacity: 0;
|
||||
|
||||
|
||||
${EventButtonContainer}:hover & {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -77,10 +77,12 @@ export function AnimeAwardsNowAvailable({ year }: AnimeAwardsNowAvailableProps)
|
||||
<Link href="/event/anime-awards" passHref legacyBehavior>
|
||||
<EventButton forwardedAs="a">
|
||||
<EventIcon icon={faTrophy} />
|
||||
<Text><Text color="text-primary">/r/anime Awards {year}</Text>: Results are now available!</Text>
|
||||
<Text>
|
||||
<Text color="text-primary">/r/anime Awards {year}</Text>: Results are now available!
|
||||
</Text>
|
||||
<Icon icon={faArrowRight} color="text-primary" />
|
||||
</EventButton>
|
||||
</Link>
|
||||
</EventButtonContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import { Text } from "@/components/text/Text";
|
||||
|
||||
interface ExternalLinkProps extends ComponentPropsWithoutRef<typeof Text> {
|
||||
href?: string | null
|
||||
children: ReactNode
|
||||
href?: string | null;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ExternalLink({ href, children, ...props }: ExternalLinkProps) {
|
||||
@@ -16,7 +16,7 @@ export function ExternalLink({ href, children, ...props }: ExternalLinkProps) {
|
||||
<Text>{children}</Text>
|
||||
<Text noWrap>
|
||||
|
||||
<Icon icon={faChevronCircleRight}/>
|
||||
<Icon icon={faChevronCircleRight} />
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -38,11 +38,11 @@ const StyledWrapper = styled.div`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
|
||||
height: 200px;
|
||||
|
||||
|
||||
position: relative;
|
||||
|
||||
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
margin-inline-start: -16px;
|
||||
margin-inline-end: -16px;
|
||||
@@ -63,7 +63,7 @@ const StyledOverflowHidden = styled(Link)`
|
||||
|
||||
const StyledCenter = styled.div`
|
||||
position: absolute;
|
||||
|
||||
|
||||
width: 400px;
|
||||
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
@@ -91,12 +91,12 @@ const StyledCover = styled.img`
|
||||
|
||||
const StyledGrillContainer = styled.div`
|
||||
position: absolute;
|
||||
|
||||
|
||||
height: 130%;
|
||||
bottom: 0;
|
||||
right: 32px;
|
||||
overflow: hidden;
|
||||
|
||||
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
right: 0;
|
||||
}
|
||||
@@ -108,10 +108,10 @@ const StyledGrill = styled.img`
|
||||
object-fit: contain;
|
||||
object-position: bottom;
|
||||
animation: ${slideIn} 2s 2s backwards cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
|
||||
|
||||
transition: transform 1s;
|
||||
transform: translateY(10%);
|
||||
|
||||
|
||||
&:hover {
|
||||
transform: none;
|
||||
}
|
||||
@@ -127,8 +127,8 @@ interface FeaturedThemeProps {
|
||||
}
|
||||
|
||||
export function FeaturedTheme({ theme, hasGrill = true, card, onPlay }: FeaturedThemeProps) {
|
||||
const [ grill, setGrill ] = useState<string | null>(null);
|
||||
const [ featuredThemePreview ] = useSetting(FeaturedThemePreview);
|
||||
const [grill, setGrill] = useState<string | null>(null);
|
||||
const [featuredThemePreview] = useSetting(FeaturedThemePreview);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasGrill) {
|
||||
@@ -136,26 +136,21 @@ export function FeaturedTheme({ theme, hasGrill = true, card, onPlay }: Featured
|
||||
}
|
||||
}, [hasGrill]);
|
||||
|
||||
const FeaturedThemeWrapper = featuredThemePreview !== FeaturedThemePreview.DISABLED
|
||||
? StyledWrapper
|
||||
: Box;
|
||||
const FeaturedThemeWrapper = featuredThemePreview !== FeaturedThemePreview.DISABLED ? StyledWrapper : Box;
|
||||
|
||||
const featuredThemeSummaryCard = featuredThemePreview !== FeaturedThemePreview.DISABLED
|
||||
? (
|
||||
<StyledCenter>
|
||||
{card ?? <ThemeSummaryCard theme={theme}/>}
|
||||
</StyledCenter>
|
||||
)
|
||||
: (
|
||||
card ?? <ThemeSummaryCard theme={theme}/>
|
||||
const featuredThemeSummaryCard =
|
||||
featuredThemePreview !== FeaturedThemePreview.DISABLED ? (
|
||||
<StyledCenter>{card ?? <ThemeSummaryCard theme={theme} />}</StyledCenter>
|
||||
) : (
|
||||
card ?? <ThemeSummaryCard theme={theme} />
|
||||
);
|
||||
|
||||
return (
|
||||
<FeaturedThemeWrapper>
|
||||
<FeaturedThemeBackground theme={theme} onPlay={onPlay}/>
|
||||
<FeaturedThemeBackground theme={theme} onPlay={onPlay} />
|
||||
{featuredThemePreview !== FeaturedThemePreview.DISABLED && grill && (
|
||||
<StyledGrillContainer>
|
||||
<StyledGrill src={grill}/>
|
||||
<StyledGrill src={grill} />
|
||||
</StyledGrillContainer>
|
||||
)}
|
||||
{featuredThemeSummaryCard}
|
||||
@@ -164,9 +159,9 @@ export function FeaturedTheme({ theme, hasGrill = true, card, onPlay }: Featured
|
||||
}
|
||||
|
||||
function FeaturedThemeBackground({ theme, onPlay }: FeaturedThemeProps) {
|
||||
const [ featuredThemePreview ] = useSetting(FeaturedThemePreview);
|
||||
const [featuredThemePreview] = useSetting(FeaturedThemePreview);
|
||||
const { canPlayVideo } = useCompatability();
|
||||
const [ fallbackToCover, setFallbackToCover ] = useState(false);
|
||||
const [fallbackToCover, setFallbackToCover] = useState(false);
|
||||
const { smallCover: featuredCover } = extractImages(theme.anime);
|
||||
|
||||
if (!theme.anime || !theme.entries.length) {
|
||||
@@ -187,24 +182,15 @@ function FeaturedThemeBackground({ theme, onPlay }: FeaturedThemeProps) {
|
||||
if (featuredThemePreview === FeaturedThemePreview.VIDEO && canPlayVideo && !fallbackToCover) {
|
||||
return (
|
||||
<StyledOverflowHidden href={href} onClick={onPlay}>
|
||||
<StyledVideo
|
||||
key={video.basename}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
onError={() => setFallbackToCover(true)}
|
||||
>
|
||||
<source
|
||||
src={`${VIDEO_URL}/${video.basename}`}
|
||||
type={`video/webm; codecs="vp8, vp9, opus`}
|
||||
/>
|
||||
<StyledVideo key={video.basename} autoPlay muted loop onError={() => setFallbackToCover(true)}>
|
||||
<source src={`${VIDEO_URL}/${video.basename}`} type={`video/webm; codecs="vp8, vp9, opus`} />
|
||||
</StyledVideo>
|
||||
</StyledOverflowHidden>
|
||||
);
|
||||
} else if (featuredThemePreview !== FeaturedThemePreview.DISABLED) {
|
||||
return (
|
||||
<StyledOverflowHidden href={href} onClick={onPlay}>
|
||||
<StyledCover src={featuredCover}/>
|
||||
<StyledCover src={featuredCover} />
|
||||
</StyledOverflowHidden>
|
||||
);
|
||||
}
|
||||
@@ -216,7 +202,7 @@ FeaturedTheme.fragments = {
|
||||
theme: gql`
|
||||
${ThemeSummaryCard.fragments.theme}
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
|
||||
|
||||
fragment FeaturedThemeTheme on Theme {
|
||||
...ThemeSummaryCardTheme
|
||||
anime {
|
||||
@@ -228,5 +214,5 @@ FeaturedTheme.fragments = {
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -11,27 +11,26 @@ import type { AnimeThemeFilterThemeFragment } from "@/generated/graphql";
|
||||
import { either, themeGroupComparator, themeIndexComparator, themeTypeComparator } from "@/utils/comparators";
|
||||
|
||||
interface AnimeThemeFilterProps {
|
||||
themes: Array<AnimeThemeFilterThemeFragment>
|
||||
themes: Array<AnimeThemeFilterThemeFragment>;
|
||||
}
|
||||
|
||||
function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
|
||||
const hasMultipleTypes = (
|
||||
themes.some((theme) => theme.type === "OP") &&
|
||||
themes.some((theme) => theme.type === "ED")
|
||||
);
|
||||
const [ filterType, setFilterType ] = useState<string | null>(null);
|
||||
const hasMultipleTypes = themes.some((theme) => theme.type === "OP") && themes.some((theme) => theme.type === "ED");
|
||||
const [filterType, setFilterType] = useState<string | null>(null);
|
||||
|
||||
const filteredThemes = themes
|
||||
.filter((theme) => !filterType || theme.type === filterType)
|
||||
.sort(either(themeGroupComparator).or(themeTypeComparator).or(themeIndexComparator).chain());
|
||||
|
||||
const groups = useMemo(
|
||||
() => filteredThemes.reduce<{
|
||||
name: string,
|
||||
slug: string,
|
||||
themes: typeof themes
|
||||
}[]>(
|
||||
(groups, theme) => {
|
||||
() =>
|
||||
filteredThemes.reduce<
|
||||
{
|
||||
name: string;
|
||||
slug: string;
|
||||
themes: typeof themes;
|
||||
}[]
|
||||
>((groups, theme) => {
|
||||
const groupName = theme.group?.name || "Original";
|
||||
const groupSlug = theme.group?.slug || "original";
|
||||
const group = groups.find((group) => group.name === groupName);
|
||||
@@ -45,13 +44,11 @@ function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
|
||||
group.themes.push(theme);
|
||||
}
|
||||
return groups;
|
||||
},
|
||||
[]
|
||||
),
|
||||
[ filteredThemes ]
|
||||
}, []),
|
||||
[filteredThemes],
|
||||
);
|
||||
|
||||
const [ activeGroup, setActiveGroup ] = useState<string | null>(null);
|
||||
const [activeGroup, setActiveGroup] = useState<string | null>(null);
|
||||
const activeGroupThemes = groups.find((group) => group.slug === activeGroup)?.themes;
|
||||
|
||||
return (
|
||||
@@ -60,16 +57,36 @@ function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
|
||||
<HorizontalScroll fixShadows>
|
||||
<Row style={{ "--gap": "16px" }}>
|
||||
{groups.length > 1 && (
|
||||
<Listbox value={activeGroup} onValueChange={setActiveGroup} defaultValue={null} resettable nullable highlightNonDefault>
|
||||
<ListboxOption value={null} hidden>All Groups</ListboxOption>
|
||||
<Listbox
|
||||
value={activeGroup}
|
||||
onValueChange={setActiveGroup}
|
||||
defaultValue={null}
|
||||
resettable
|
||||
nullable
|
||||
highlightNonDefault
|
||||
>
|
||||
<ListboxOption value={null} hidden>
|
||||
All Groups
|
||||
</ListboxOption>
|
||||
{groups.map((group) => (
|
||||
<ListboxOption key={group.slug} value={group.slug}>{group.name}</ListboxOption>
|
||||
<ListboxOption key={group.slug} value={group.slug}>
|
||||
{group.name}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</Listbox>
|
||||
)}
|
||||
{hasMultipleTypes && (
|
||||
<Listbox value={filterType} onValueChange={setFilterType} defaultValue={null} resettable nullable highlightNonDefault>
|
||||
<ListboxOption value={null} hidden>OP & ED</ListboxOption>
|
||||
<Listbox
|
||||
value={filterType}
|
||||
onValueChange={setFilterType}
|
||||
defaultValue={null}
|
||||
resettable
|
||||
nullable
|
||||
highlightNonDefault
|
||||
>
|
||||
<ListboxOption value={null} hidden>
|
||||
OP & ED
|
||||
</ListboxOption>
|
||||
<ListboxOption value="OP">OP</ListboxOption>
|
||||
<ListboxOption value="ED">ED</ListboxOption>
|
||||
</Listbox>
|
||||
@@ -84,16 +101,16 @@ function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
|
||||
<ThemeDetailCard key={theme.id} theme={theme} />
|
||||
))}
|
||||
</Column>
|
||||
) : groups.map((group) => (
|
||||
<Column key={group.slug} style={{ "--gap": "16px" }}>
|
||||
{groups.length > 1 && (
|
||||
<Text variant="h3">{group.name}</Text>
|
||||
)}
|
||||
{group.themes.map((theme) => (
|
||||
<ThemeDetailCard key={theme.id} theme={theme} />
|
||||
))}
|
||||
</Column>
|
||||
))}
|
||||
) : (
|
||||
groups.map((group) => (
|
||||
<Column key={group.slug} style={{ "--gap": "16px" }}>
|
||||
{groups.length > 1 && <Text variant="h3">{group.name}</Text>}
|
||||
{group.themes.map((theme) => (
|
||||
<ThemeDetailCard key={theme.id} theme={theme} />
|
||||
))}
|
||||
</Column>
|
||||
))
|
||||
)}
|
||||
</Column>
|
||||
</Column>
|
||||
);
|
||||
@@ -102,7 +119,7 @@ function AnimeThemeFilterInternal({ themes }: AnimeThemeFilterProps) {
|
||||
AnimeThemeFilterInternal.fragments = {
|
||||
theme: gql`
|
||||
${ThemeDetailCard.fragments.theme}
|
||||
|
||||
|
||||
fragment AnimeThemeFilterTheme on Theme {
|
||||
...ThemeDetailCardTheme
|
||||
type
|
||||
@@ -111,7 +128,7 @@ AnimeThemeFilterInternal.fragments = {
|
||||
slug
|
||||
}
|
||||
}
|
||||
`
|
||||
`,
|
||||
};
|
||||
|
||||
export const AnimeThemeFilter = memo(AnimeThemeFilterInternal);
|
||||
|
||||
@@ -32,17 +32,16 @@ const StyledSocialList = styled(Row)`
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
|
||||
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
flex-basis: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
// To avoid overlap with scroll back to top button as window width gets smaller
|
||||
@media (max-width: ${theme.breakpoints.socialListMax}) and (min-width:${theme.breakpoints.mobileMax}) {
|
||||
@media (max-width: ${theme.breakpoints.socialListMax}) and (min-width: ${theme.breakpoints.mobileMax}) {
|
||||
margin-right: 64px;
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
const StyledSocialButton = styled(Button).attrs({ variant: "silent", isCircle: true })`
|
||||
@@ -54,23 +53,13 @@ export function Footer() {
|
||||
<StyledFooter>
|
||||
<StyledContainer>
|
||||
<StyledLinkList>
|
||||
<FooterTextLink href="/about/transparency">
|
||||
Transparency
|
||||
</FooterTextLink>
|
||||
<FooterTextLink href="/about/donate">
|
||||
Donate
|
||||
</FooterTextLink>
|
||||
<FooterTextLink href="/about/faq">
|
||||
FAQ
|
||||
</FooterTextLink>
|
||||
<FooterTextLink href="/about/transparency">Transparency</FooterTextLink>
|
||||
<FooterTextLink href="/about/donate">Donate</FooterTextLink>
|
||||
<FooterTextLink href="/about/faq">FAQ</FooterTextLink>
|
||||
</StyledLinkList>
|
||||
<StyledLinkList>
|
||||
<FooterTextLink href="/about/terms-of-service">
|
||||
Terms of Service
|
||||
</FooterTextLink>
|
||||
<FooterTextLink href="/about/privacy-policy">
|
||||
Privacy Policy
|
||||
</FooterTextLink>
|
||||
<FooterTextLink href="/about/terms-of-service">Terms of Service</FooterTextLink>
|
||||
<FooterTextLink href="/about/privacy-policy">Privacy Policy</FooterTextLink>
|
||||
<FooterTextLink as="a" href="mailto:admin@animethemes.moe">
|
||||
Contact
|
||||
</FooterTextLink>
|
||||
@@ -78,22 +67,22 @@ export function Footer() {
|
||||
<StyledSocialList>
|
||||
<FooterLink href="https://reddit.com/r/AnimeThemes">
|
||||
<StyledSocialButton title="Reddit">
|
||||
<Icon icon={faReddit}/>
|
||||
<Icon icon={faReddit} />
|
||||
</StyledSocialButton>
|
||||
</FooterLink>
|
||||
<FooterLink href="https://discordapp.com/invite/m9zbVyQ">
|
||||
<StyledSocialButton title="Discord">
|
||||
<Icon icon={faDiscord}/>
|
||||
<Icon icon={faDiscord} />
|
||||
</StyledSocialButton>
|
||||
</FooterLink>
|
||||
<FooterLink href="https://twitter.com/AnimeThemesMoe">
|
||||
<StyledSocialButton title="Twitter">
|
||||
<Icon icon={faTwitter}/>
|
||||
<Icon icon={faTwitter} />
|
||||
</StyledSocialButton>
|
||||
</FooterLink>
|
||||
<FooterLink href="https://github.com/AnimeThemes">
|
||||
<StyledSocialButton title="GitHub">
|
||||
<Icon icon={faGithub}/>
|
||||
<Icon icon={faGithub} />
|
||||
</StyledSocialButton>
|
||||
</FooterLink>
|
||||
</StyledSocialList>
|
||||
@@ -103,13 +92,9 @@ export function Footer() {
|
||||
}
|
||||
|
||||
function FooterLink(props: ComponentPropsWithoutRef<typeof Text>) {
|
||||
return (
|
||||
<Text as={Link} {...props}/>
|
||||
);
|
||||
return <Text as={Link} {...props} />;
|
||||
}
|
||||
|
||||
function FooterTextLink(props: ComponentPropsWithoutRef<typeof FooterLink>) {
|
||||
return (
|
||||
<FooterLink link block color="text-muted" noWrap {...props}/>
|
||||
);
|
||||
return <FooterLink link block color="text-muted" noWrap {...props} />;
|
||||
}
|
||||
|
||||
@@ -17,10 +17,10 @@ const StyledSearchInput = styled.div`
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 2rem;
|
||||
gap: 8px;
|
||||
|
||||
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
color: ${theme.colors["text-muted"]};
|
||||
|
||||
|
||||
&:focus-within {
|
||||
box-shadow: ${theme.shadows.low};
|
||||
|
||||
@@ -34,7 +34,7 @@ const StyledInput = styled.input`
|
||||
`;
|
||||
const StyledResetButton = styled(Button).attrs({ variant: "silent", isCircle: true })`
|
||||
margin: -8px;
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
@@ -42,26 +42,17 @@ const StyledResetButton = styled(Button).attrs({ variant: "silent", isCircle: tr
|
||||
`;
|
||||
|
||||
interface InputProps extends ComponentPropsWithoutRef<typeof StyledSearchInput> {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
resettable?: boolean
|
||||
icon?: IconDefinition
|
||||
inputProps?: ComponentPropsWithoutRef<typeof StyledInput>
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
resettable?: boolean;
|
||||
icon?: IconDefinition;
|
||||
inputProps?: ComponentPropsWithoutRef<typeof StyledInput>;
|
||||
}
|
||||
|
||||
export function Input({
|
||||
value,
|
||||
onChange,
|
||||
resettable = false,
|
||||
icon,
|
||||
inputProps = {},
|
||||
...props
|
||||
}: InputProps) {
|
||||
export function Input({ value, onChange, resettable = false, icon, inputProps = {}, ...props }: InputProps) {
|
||||
return (
|
||||
<StyledSearchInput {...props}>
|
||||
{icon && (
|
||||
<Icon icon={icon} color="text-disabled"/>
|
||||
)}
|
||||
{icon && <Icon icon={icon} color="text-disabled" />}
|
||||
<StyledInput
|
||||
type="text"
|
||||
value={value}
|
||||
@@ -70,7 +61,7 @@ export function Input({
|
||||
/>
|
||||
{resettable && !!value && (
|
||||
<StyledResetButton>
|
||||
<Icon icon={faTimes} onClick={() => onChange && onChange("")}/>
|
||||
<Icon icon={faTimes} onClick={() => onChange && onChange("")} />
|
||||
</StyledResetButton>
|
||||
)}
|
||||
</StyledSearchInput>
|
||||
|
||||
@@ -29,10 +29,12 @@ const StyledSwitchThumb = styled(RadixSwitch.Thumb)`
|
||||
height: 25px;
|
||||
background-color: ${theme.colors["text-disabled"]};
|
||||
border-radius: 9999px;
|
||||
transition: transform 250ms, background-color 250ms;
|
||||
transition:
|
||||
transform 250ms,
|
||||
background-color 250ms;
|
||||
will-change: transform;
|
||||
|
||||
&[data-state='checked'] {
|
||||
|
||||
&[data-state="checked"] {
|
||||
transform: translateX(25px);
|
||||
background-color: ${theme.colors["text-primary"]};
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ export const TextArea = styled.textarea`
|
||||
background-color: ${theme.colors["solid-on-card"]};
|
||||
color: ${theme.colors["text-muted"]};
|
||||
scrollbar-color: ${theme.colors["gray-800"]} transparent;
|
||||
|
||||
|
||||
&:focus-within {
|
||||
box-shadow: ${theme.shadows.low};
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { SVGProps } from "react";
|
||||
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
|
||||
|
||||
export type FontAwesomeIconProps = SVGProps<SVGSVGElement> & {
|
||||
icon: IconDefinition
|
||||
title?: string
|
||||
}
|
||||
icon: IconDefinition;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
const xmlns = "http://www.w3.org/2000/svg";
|
||||
|
||||
@@ -27,17 +27,15 @@ export function FontAwesomeIcon(props: FontAwesomeIconProps) {
|
||||
className={`${className} svg-inline--fa fa-fw`}
|
||||
{...rest}
|
||||
>
|
||||
{title ? (
|
||||
<title>{title}</title>
|
||||
) : null}
|
||||
{title ? <title>{title}</title> : null}
|
||||
{children}
|
||||
{Array.isArray(svgPathData) ? (
|
||||
<g>
|
||||
<path d={svgPathData[0]}/>
|
||||
<path d={svgPathData[1]}/>
|
||||
<path d={svgPathData[0]} />
|
||||
<path d={svgPathData[1]} />
|
||||
</g>
|
||||
) : (
|
||||
<path fill="currentColor" d={svgPathData}/>
|
||||
<path fill="currentColor" d={svgPathData} />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -6,8 +6,8 @@ import { FontAwesomeIcon } from "@/components/icon/FontAwesomeIcon";
|
||||
import theme from "@/theme";
|
||||
import type { Colors } from "@/theme/colors";
|
||||
|
||||
export const Icon = styled(FontAwesomeIcon)<{ color?: keyof Colors, transition?: Property.Transition }>`
|
||||
export const Icon = styled(FontAwesomeIcon)<{ color?: keyof Colors; transition?: Property.Transition }>`
|
||||
color: ${(props) => props.color && theme.colors[props.color]};
|
||||
|
||||
transition: ${(props) => props.transition}
|
||||
transition: ${(props) => props.transition};
|
||||
`;
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { CoverImageResourceWithImagesFragment } from "@/generated/graphql";
|
||||
import extractImages from "@/utils/extractImages";
|
||||
|
||||
interface CoverImageProps extends ComponentPropsWithoutRef<typeof FullWidthImage> {
|
||||
resourceWithImages: CoverImageResourceWithImagesFragment
|
||||
resourceWithImages: CoverImageResourceWithImagesFragment;
|
||||
}
|
||||
|
||||
export function CoverImage({ resourceWithImages, ...props }: CoverImageProps) {
|
||||
@@ -16,7 +16,12 @@ export function CoverImage({ resourceWithImages, ...props }: CoverImageProps) {
|
||||
|
||||
return (
|
||||
<AspectRatio ratio={2 / 3}>
|
||||
<FullWidthImage key={largeCover} src={largeCover} style={{ backgroundImage: `url(${smallCover})` }} {...props}/>
|
||||
<FullWidthImage
|
||||
key={largeCover}
|
||||
src={largeCover}
|
||||
style={{ backgroundImage: `url(${smallCover})` }}
|
||||
{...props}
|
||||
/>
|
||||
</AspectRatio>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +29,7 @@ export function CoverImage({ resourceWithImages, ...props }: CoverImageProps) {
|
||||
CoverImage.fragments = {
|
||||
resourceWithImages: gql`
|
||||
${extractImages.fragments.resourceWithImages}
|
||||
|
||||
|
||||
fragment CoverImageResourceWithImages on ResourceWithImages {
|
||||
...extractImagesResourceWithImages
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export const FullWidthImage = styled.img`
|
||||
object-fit: cover;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: ${theme.shadows.medium};
|
||||
|
||||
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
`;
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { SVGAttributes } from "react";
|
||||
export function Logo(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg fill="currentColor" viewBox="0 0 160 86.6" {...props}>
|
||||
<polygon points="56.25 32.48 56.25 75.78 75 86.6 75 0 0 43.3 18.75 54.13 56.25 32.48"/>
|
||||
<polygon points="103.75 32.48 141.25 54.13 160 43.3 85 0 85 86.6 103.75 75.78 103.75 32.48"/>
|
||||
<polygon points="56.25 32.48 56.25 75.78 75 86.6 75 0 0 43.3 18.75 54.13 56.25 32.48" />
|
||||
<polygon points="103.75 32.48 141.25 54.13 160 43.3 85 0 85 86.6 103.75 75.78 103.75 32.48" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,10 @@ const StyledPlaceholder = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 32px;
|
||||
|
||||
|
||||
background: ${theme.colors["solid"]};
|
||||
color: ${theme.colors["text-disabled"]};
|
||||
|
||||
|
||||
& svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -22,7 +22,7 @@ const StyledPlaceholder = styled.div`
|
||||
export function LogoPlaceholder(props: ComponentPropsWithoutRef<typeof StyledPlaceholder>) {
|
||||
return (
|
||||
<StyledPlaceholder {...props}>
|
||||
<Logo/>
|
||||
<Logo />
|
||||
</StyledPlaceholder>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,17 +12,24 @@ function getTranslationX(item: number, itemCount: number) {
|
||||
switch (itemCount) {
|
||||
case 4:
|
||||
switch (item) {
|
||||
case 1: return -33;
|
||||
case 2: return -16.5;
|
||||
case 3: return 16.5;
|
||||
case 4: return 33;
|
||||
case 1:
|
||||
return -33;
|
||||
case 2:
|
||||
return -16.5;
|
||||
case 3:
|
||||
return 16.5;
|
||||
case 4:
|
||||
return 33;
|
||||
}
|
||||
break;
|
||||
case 3:
|
||||
switch (item) {
|
||||
case 1: return -25;
|
||||
case 2: return 0;
|
||||
case 3: return 25;
|
||||
case 1:
|
||||
return -25;
|
||||
case 2:
|
||||
return 0;
|
||||
case 3:
|
||||
return 25;
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -43,58 +50,52 @@ const StyledCoverItemContainer = styled.div<{ $itemCount: number }>`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
${(props) => props.$itemCount > 1 && css`
|
||||
&:nth-child(1) {
|
||||
--translate-x: ${getTranslationX(1, props.$itemCount)}%;
|
||||
clip-path: polygon(
|
||||
0 0,
|
||||
calc(100% / (${props.$itemCount} - 1)) 0,
|
||||
0 100%,
|
||||
0 100%
|
||||
);
|
||||
}
|
||||
${(props) =>
|
||||
props.$itemCount > 1 &&
|
||||
css`
|
||||
&:nth-child(1) {
|
||||
--translate-x: ${getTranslationX(1, props.$itemCount)}%;
|
||||
clip-path: polygon(0 0, calc(100% / (${props.$itemCount} - 1)) 0, 0 100%, 0 100%);
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
--translate-x: ${getTranslationX(2, props.$itemCount)}%;
|
||||
clip-path: polygon(
|
||||
calc(100% / (${props.$itemCount} - 1)) 0,
|
||||
calc(100% / (${props.$itemCount} - 1) * 2) 0,
|
||||
calc(100% / (${props.$itemCount} - 1)) 100%,
|
||||
0 100%
|
||||
);
|
||||
}
|
||||
&:nth-child(2) {
|
||||
--translate-x: ${getTranslationX(2, props.$itemCount)}%;
|
||||
clip-path: polygon(
|
||||
calc(100% / (${props.$itemCount} - 1)) 0,
|
||||
calc(100% / (${props.$itemCount} - 1) * 2) 0,
|
||||
calc(100% / (${props.$itemCount} - 1)) 100%,
|
||||
0 100%
|
||||
);
|
||||
}
|
||||
|
||||
&:nth-child(3) {
|
||||
--translate-x: ${getTranslationX(3, props.$itemCount)}%;
|
||||
clip-path: polygon(
|
||||
calc(100% / (${props.$itemCount} - 1) * 2) 0,
|
||||
100% 0,
|
||||
calc(100% / (${props.$itemCount} - 1) * 2) 100%,
|
||||
calc(100% / (${props.$itemCount} - 1)) 100%
|
||||
);
|
||||
}
|
||||
&:nth-child(3) {
|
||||
--translate-x: ${getTranslationX(3, props.$itemCount)}%;
|
||||
clip-path: polygon(
|
||||
calc(100% / (${props.$itemCount} - 1) * 2) 0,
|
||||
100% 0,
|
||||
calc(100% / (${props.$itemCount} - 1) * 2) 100%,
|
||||
calc(100% / (${props.$itemCount} - 1)) 100%
|
||||
);
|
||||
}
|
||||
|
||||
&:nth-child(4) {
|
||||
--translate-x: ${getTranslationX(4, props.$itemCount)}%;
|
||||
clip-path: polygon(
|
||||
100% 0,
|
||||
100% 0,
|
||||
100% 100%,
|
||||
calc(100% / (${props.$itemCount} - 1) * 2) 100%
|
||||
);
|
||||
}
|
||||
&:nth-child(4) {
|
||||
--translate-x: ${getTranslationX(4, props.$itemCount)}%;
|
||||
clip-path: polygon(100% 0, 100% 0, 100% 100%, calc(100% / (${props.$itemCount} - 1) * 2) 100%);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
|
||||
transition: clip-path 250ms;
|
||||
z-index: 2;
|
||||
}
|
||||
&:hover {
|
||||
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
|
||||
transition: clip-path 250ms;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&:not(:hover) {
|
||||
transition: clip-path 500ms, z-index 1000ms;
|
||||
z-index: 0;
|
||||
}
|
||||
`}
|
||||
&:not(:hover) {
|
||||
transition:
|
||||
clip-path 500ms,
|
||||
z-index 1000ms;
|
||||
z-index: 0;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
const StyledCover = styled.img`
|
||||
width: 100%;
|
||||
@@ -105,7 +106,7 @@ const StyledCover = styled.img`
|
||||
background-position: center;
|
||||
transition: transform 500ms;
|
||||
transform: translateX(var(--translate-x));
|
||||
|
||||
|
||||
&:hover {
|
||||
transition: transform 250ms;
|
||||
transform: scale(1.1);
|
||||
@@ -113,14 +114,12 @@ const StyledCover = styled.img`
|
||||
`;
|
||||
|
||||
interface MultiCoverImageProps extends ComponentPropsWithoutRef<typeof StyledCover> {
|
||||
resourcesWithImages: Array<MultiCoverImageResourceWithImagesFragment>
|
||||
resourcesWithImages: Array<MultiCoverImageResourceWithImagesFragment>;
|
||||
}
|
||||
|
||||
export function MultiCoverImage({ resourcesWithImages, ...props }: MultiCoverImageProps) {
|
||||
const images = resourcesWithImages
|
||||
.filter((resource, index, list) =>
|
||||
list.findIndex((r) => r.name === resource.name) === index
|
||||
)
|
||||
.filter((resource, index, list) => list.findIndex((r) => r.name === resource.name) === index)
|
||||
.map((resource) => {
|
||||
const { largeCover, smallCover } = extractImages(resource);
|
||||
|
||||
@@ -136,19 +135,21 @@ export function MultiCoverImage({ resourcesWithImages, ...props }: MultiCoverIma
|
||||
return (
|
||||
<AspectRatio ratio={2 / 3}>
|
||||
<StyledCoverContainer>
|
||||
{images.length ? images.map(({ largeCover, smallCover, resource }) => (
|
||||
<StyledCoverItemContainer key={largeCover} $itemCount={images.length}>
|
||||
<StyledCover
|
||||
loading="lazy"
|
||||
src={largeCover}
|
||||
alt={`Cover image of ${resource.name}`}
|
||||
title={resource.name}
|
||||
style={{ backgroundImage: `url(${smallCover})` }}
|
||||
{...props}
|
||||
/>
|
||||
</StyledCoverItemContainer>
|
||||
)) : (
|
||||
<LogoPlaceholder {...props}/>
|
||||
{images.length ? (
|
||||
images.map(({ largeCover, smallCover, resource }) => (
|
||||
<StyledCoverItemContainer key={largeCover} $itemCount={images.length}>
|
||||
<StyledCover
|
||||
loading="lazy"
|
||||
src={largeCover}
|
||||
alt={`Cover image of ${resource.name}`}
|
||||
title={resource.name}
|
||||
style={{ backgroundImage: `url(${smallCover})` }}
|
||||
{...props}
|
||||
/>
|
||||
</StyledCoverItemContainer>
|
||||
))
|
||||
) : (
|
||||
<LogoPlaceholder {...props} />
|
||||
)}
|
||||
</StyledCoverContainer>
|
||||
</AspectRatio>
|
||||
|
||||
@@ -16,13 +16,13 @@ const StyledImage = styled(FullWidthImage)`
|
||||
`;
|
||||
|
||||
interface StudioCoverImageProps extends ComponentPropsWithoutRef<typeof FullWidthImage> {
|
||||
studio: StudioCoverImageStudioFragment
|
||||
studio: StudioCoverImageStudioFragment;
|
||||
}
|
||||
|
||||
export function StudioCoverImage({ studio, ...props }: StudioCoverImageProps) {
|
||||
const { largeCover } = extractImages(studio);
|
||||
|
||||
const [ imageNotFound, setImageNotFound ] = useState(!largeCover);
|
||||
const [imageNotFound, setImageNotFound] = useState(!largeCover);
|
||||
|
||||
return (
|
||||
<AspectRatio ratio={2 / 3}>
|
||||
@@ -35,7 +35,7 @@ export function StudioCoverImage({ studio, ...props }: StudioCoverImageProps) {
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<MultiCoverImage resourcesWithImages={studio.anime} {...props}/>
|
||||
<MultiCoverImage resourcesWithImages={studio.anime} {...props} />
|
||||
)}
|
||||
</AspectRatio>
|
||||
);
|
||||
@@ -56,5 +56,5 @@ StudioCoverImage.fragments = {
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -25,8 +25,8 @@ const StyledItemGrid = styled.div`
|
||||
type AlphabeticalIndexItem = { name: string };
|
||||
|
||||
type AlphabeticalIndexProps<T extends AlphabeticalIndexItem> = {
|
||||
items: Array<T>
|
||||
children: (item: T) => ReactNode
|
||||
items: Array<T>;
|
||||
children: (item: T) => ReactNode;
|
||||
};
|
||||
|
||||
export function AlphabeticalIndex<T extends AlphabeticalIndexItem>({ items, children }: AlphabeticalIndexProps<T>) {
|
||||
@@ -39,30 +39,29 @@ export function AlphabeticalIndex<T extends AlphabeticalIndexItem>({ items, chil
|
||||
return firstLetter;
|
||||
}
|
||||
return "0-9";
|
||||
}
|
||||
)
|
||||
)
|
||||
.sort(([ a ], [ b ]) => a.localeCompare(b));
|
||||
},
|
||||
),
|
||||
).sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
return <>
|
||||
<StyledLetterList>
|
||||
{itemsByFirstLetter.map(([ firstLetter ]) => (
|
||||
<Link
|
||||
key={firstLetter}
|
||||
href={`#${firstLetter}`}
|
||||
passHref
|
||||
legacyBehavior>
|
||||
<Text as="a" link>{firstLetter.toUpperCase()} </Text>
|
||||
</Link>
|
||||
return (
|
||||
<>
|
||||
<StyledLetterList>
|
||||
{itemsByFirstLetter.map(([firstLetter]) => (
|
||||
<Link key={firstLetter} href={`#${firstLetter}`} passHref legacyBehavior>
|
||||
<Text as="a" link>
|
||||
{firstLetter.toUpperCase()}{" "}
|
||||
</Text>
|
||||
</Link>
|
||||
))}
|
||||
</StyledLetterList>
|
||||
{itemsByFirstLetter.map(([firstLetter, itemsWithFirstLetter]) => (
|
||||
<React.Fragment key={firstLetter}>
|
||||
<Text id={firstLetter} variant="h2">
|
||||
{firstLetter}
|
||||
</Text>
|
||||
<StyledItemGrid>{itemsWithFirstLetter.map((item) => children(item))}</StyledItemGrid>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</StyledLetterList>
|
||||
{itemsByFirstLetter.map(([ firstLetter, itemsWithFirstLetter ]) => (
|
||||
<React.Fragment key={firstLetter}>
|
||||
<Text id={firstLetter} variant="h2">{firstLetter}</Text>
|
||||
<StyledItemGrid>
|
||||
{itemsWithFirstLetter.map((item) => children(item))}
|
||||
</StyledItemGrid>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</>;
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,9 @@ const StyledListboxPopover = styled(RadixSelect.Portal)`
|
||||
overflow: auto;
|
||||
|
||||
background-color: ${theme.colors["solid"]};
|
||||
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]}, ${theme.shadows.high};
|
||||
box-shadow:
|
||||
0 0 0 2px ${theme.colors["text-primary"]},
|
||||
${theme.shadows.high};
|
||||
|
||||
transform-origin: top;
|
||||
animation: ${flipDown} 200ms ease-out;
|
||||
@@ -57,7 +59,7 @@ const StyledListboxList = styled(RadixSelect.Content)`
|
||||
padding: 8px 0;
|
||||
`;
|
||||
const StyledListboxOption = styled(RadixSelect.Item)`
|
||||
display: ${(props) => props.hidden ? "none" : "flex"};
|
||||
display: ${(props) => (props.hidden ? "none" : "flex")};
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -72,16 +74,14 @@ const StyledListboxOption = styled(RadixSelect.Item)`
|
||||
color: ${theme.colors["text"]};
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
||||
&[data-state="checked"] {
|
||||
color: ${theme.colors["text-primary"]};
|
||||
}
|
||||
`;
|
||||
|
||||
type ListboxProps =
|
||||
Omit<RadixSelect.SelectProps, "value" | "defaultValue" | "onValueChange">
|
||||
& (PropsNullable | PropsNotNullable)
|
||||
& {
|
||||
type ListboxProps = Omit<RadixSelect.SelectProps, "value" | "defaultValue" | "onValueChange"> &
|
||||
(PropsNullable | PropsNotNullable) & {
|
||||
resettable?: boolean;
|
||||
highlightNonDefault?: boolean;
|
||||
};
|
||||
@@ -100,71 +100,73 @@ interface PropsNotNullable {
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export const Listbox = forwardRef<HTMLButtonElement, ListboxProps>(
|
||||
function Listbox({
|
||||
value,
|
||||
onValueChange,
|
||||
defaultValue,
|
||||
nullable,
|
||||
resettable,
|
||||
highlightNonDefault,
|
||||
children,
|
||||
...props
|
||||
}, ref) {
|
||||
const radixValue = value === null ? NULL_VALUE : value;
|
||||
const radixOnValueChange = (newValue: string) => {
|
||||
if (nullable) {
|
||||
onValueChange(newValue === NULL_VALUE ? null : newValue);
|
||||
} else {
|
||||
onValueChange(newValue);
|
||||
}
|
||||
};
|
||||
const radixDefaultValue = defaultValue === null ? NULL_VALUE : defaultValue;
|
||||
export const Listbox = forwardRef<HTMLButtonElement, ListboxProps>(function Listbox(
|
||||
{ value, onValueChange, defaultValue, nullable, resettable, highlightNonDefault, children, ...props },
|
||||
ref,
|
||||
) {
|
||||
const radixValue = value === null ? NULL_VALUE : value;
|
||||
const radixOnValueChange = (newValue: string) => {
|
||||
if (nullable) {
|
||||
onValueChange(newValue === NULL_VALUE ? null : newValue);
|
||||
} else {
|
||||
onValueChange(newValue);
|
||||
}
|
||||
};
|
||||
const radixDefaultValue = defaultValue === null ? NULL_VALUE : defaultValue;
|
||||
|
||||
return (
|
||||
<RadixSelect.Root value={radixValue} onValueChange={radixOnValueChange} defaultValue={radixDefaultValue} {...props}>
|
||||
<RadixSelect.Trigger asChild ref={ref}>
|
||||
<StyledListboxButton variant={highlightNonDefault && value !== defaultValue ? "primary" : undefined}>
|
||||
<RadixSelect.Value />
|
||||
<RadixSelect.Icon>
|
||||
{(resettable && radixDefaultValue && value !== defaultValue) ? (
|
||||
<StyledListboxReset
|
||||
onClick={() => radixOnValueChange(radixDefaultValue)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Icon icon={faTimes}/>
|
||||
</StyledListboxReset>
|
||||
) : (
|
||||
<Icon icon={faSort} />
|
||||
)}
|
||||
</RadixSelect.Icon>
|
||||
</StyledListboxButton>
|
||||
</RadixSelect.Trigger>
|
||||
<StyledListboxPopover>
|
||||
<StyledListboxList position="popper" sideOffset={8} collisionBoundary={typeof document !== "undefined" ? document.body : []}>
|
||||
<RadixSelect.Viewport>{children}</RadixSelect.Viewport>
|
||||
</StyledListboxList>
|
||||
</StyledListboxPopover>
|
||||
</RadixSelect.Root>
|
||||
);
|
||||
}
|
||||
);
|
||||
return (
|
||||
<RadixSelect.Root
|
||||
value={radixValue}
|
||||
onValueChange={radixOnValueChange}
|
||||
defaultValue={radixDefaultValue}
|
||||
{...props}
|
||||
>
|
||||
<RadixSelect.Trigger asChild ref={ref}>
|
||||
<StyledListboxButton variant={highlightNonDefault && value !== defaultValue ? "primary" : undefined}>
|
||||
<RadixSelect.Value />
|
||||
<RadixSelect.Icon>
|
||||
{resettable && radixDefaultValue && value !== defaultValue ? (
|
||||
<StyledListboxReset
|
||||
onClick={() => radixOnValueChange(radixDefaultValue)}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Icon icon={faTimes} />
|
||||
</StyledListboxReset>
|
||||
) : (
|
||||
<Icon icon={faSort} />
|
||||
)}
|
||||
</RadixSelect.Icon>
|
||||
</StyledListboxButton>
|
||||
</RadixSelect.Trigger>
|
||||
<StyledListboxPopover>
|
||||
<StyledListboxList
|
||||
position="popper"
|
||||
sideOffset={8}
|
||||
collisionBoundary={typeof document !== "undefined" ? document.body : []}
|
||||
>
|
||||
<RadixSelect.Viewport>{children}</RadixSelect.Viewport>
|
||||
</StyledListboxList>
|
||||
</StyledListboxPopover>
|
||||
</RadixSelect.Root>
|
||||
);
|
||||
});
|
||||
|
||||
export interface ListboxOptionProps extends Omit<RadixSelect.SelectItemProps, "value"> {
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
export const ListboxOption = forwardRef<HTMLDivElement, ListboxOptionProps>(
|
||||
function ListboxOption({ value, children, ...props }, ref) {
|
||||
const radixValue = value === null ? NULL_VALUE : value;
|
||||
export const ListboxOption = forwardRef<HTMLDivElement, ListboxOptionProps>(function ListboxOption(
|
||||
{ value, children, ...props },
|
||||
ref,
|
||||
) {
|
||||
const radixValue = value === null ? NULL_VALUE : value;
|
||||
|
||||
return (
|
||||
<StyledListboxOption value={radixValue} {...props} ref={ref}>
|
||||
<RadixSelect.ItemText>{children}</RadixSelect.ItemText>
|
||||
<RadixSelect.ItemIndicator>
|
||||
<Icon icon={faCheck} />
|
||||
</RadixSelect.ItemIndicator>
|
||||
</StyledListboxOption>
|
||||
);
|
||||
}
|
||||
);
|
||||
return (
|
||||
<StyledListboxOption value={radixValue} {...props} ref={ref}>
|
||||
<RadixSelect.ItemText>{children}</RadixSelect.ItemText>
|
||||
<RadixSelect.ItemIndicator>
|
||||
<Icon icon={faCheck} />
|
||||
</RadixSelect.ItemIndicator>
|
||||
</StyledListboxOption>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ import theme from "@/theme";
|
||||
const StyledMarkdown = styled.div`
|
||||
line-height: 1.75;
|
||||
word-break: break-word;
|
||||
|
||||
|
||||
& h1 {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
@@ -26,21 +26,34 @@ const StyledMarkdown = styled.div`
|
||||
& h3 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
& p + h2, & ul + h2, & ol + h2, & ${Card} + h2, & pre + h2, & table + h2 {
|
||||
|
||||
& p + h2,
|
||||
& ul + h2,
|
||||
& ol + h2,
|
||||
& ${Card} + h2,
|
||||
& pre + h2,
|
||||
& table + h2 {
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
& p + h3, & ul + h3, & ol + h3, & ${Card} + h3, & pre + h3, & table + h3 {
|
||||
& p + h3,
|
||||
& ul + h3,
|
||||
& ol + h3,
|
||||
& ${Card} + h3,
|
||||
& pre + h3,
|
||||
& table + h3 {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
& p, & ul, & ol {
|
||||
& p,
|
||||
& ul,
|
||||
& ol {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
& ul ul, & ol ol {
|
||||
|
||||
& ul ul,
|
||||
& ol ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@@ -91,23 +104,23 @@ const StyledMarkdown = styled.div`
|
||||
padding-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
& pre {
|
||||
margin-bottom: 16px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
|
||||
& pre > code {
|
||||
display: block;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
|
||||
& ${Card} {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
|
||||
& img {
|
||||
border-radius: ${theme.scalars.borderRadiusCard};
|
||||
}
|
||||
@@ -128,19 +141,19 @@ export function Markdown({ source, components = {} }: MarkdownProps) {
|
||||
const { href } = props;
|
||||
|
||||
if (href?.startsWith("/")) {
|
||||
return <TextLink href={href} {...props}/>;
|
||||
return <TextLink href={href} {...props} />;
|
||||
}
|
||||
|
||||
return <Text as="a" link href={href} {...props}/>;
|
||||
return <Text as="a" link href={href} {...props} />;
|
||||
},
|
||||
h1: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h1" {...props}/>,
|
||||
h2: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" {...props}/>,
|
||||
h3: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" as="h3" {...props}/>,
|
||||
code: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="code" {...props}/>,
|
||||
h1: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h1" {...props} />,
|
||||
h2: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" {...props} />,
|
||||
h3: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="h2" as="h3" {...props} />,
|
||||
code: (props: ComponentPropsWithoutRef<typeof Text>) => <Text variant="code" {...props} />,
|
||||
Card,
|
||||
...components,
|
||||
}}
|
||||
/>
|
||||
</StyledMarkdown>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const StyledTableOfContents = styled.ul`
|
||||
// TODO: Magic value neccessary?
|
||||
top: 92px;
|
||||
align-self: flex-start;
|
||||
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
@@ -20,13 +20,13 @@ const StyledTableOfContents = styled.ul`
|
||||
max-height: calc(100vh - 92px);
|
||||
padding-left: 16px;
|
||||
padding-bottom: 16px;
|
||||
|
||||
|
||||
list-style: none;
|
||||
overflow-y: auto;
|
||||
|
||||
|
||||
& > li {
|
||||
position: relative;
|
||||
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -75,10 +75,10 @@ export function TableOfContents({ headings }: { headings: Array<Heading> }) {
|
||||
<StyledTableOfContents>
|
||||
{headings.map(({ text, slug, depth }) => (
|
||||
<StyledTableOfContentsHeading key={slug} $depth={depth}>
|
||||
{slug === currentSlug && (
|
||||
<StyledDot layoutId="dot"/>
|
||||
)}
|
||||
<Text as="a" link color={slug === currentSlug ? "text-muted" : "text-disabled"} href={`#${slug}`}>{text}</Text>
|
||||
{slug === currentSlug && <StyledDot layoutId="dot" />}
|
||||
<Text as="a" link color={slug === currentSlug ? "text-muted" : "text-disabled"} href={`#${slug}`}>
|
||||
{text}
|
||||
</Text>
|
||||
</StyledTableOfContentsHeading>
|
||||
))}
|
||||
</StyledTableOfContents>
|
||||
|
||||
@@ -15,7 +15,9 @@ const StyledMenuContent = styled(RadixMenu.Content)`
|
||||
overflow: auto;
|
||||
|
||||
background-color: ${theme.colors["solid"]};
|
||||
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]}, ${theme.shadows.high};
|
||||
box-shadow:
|
||||
0 0 0 2px ${theme.colors["text-primary"]},
|
||||
${theme.shadows.high};
|
||||
|
||||
transform-origin: top;
|
||||
animation: ${flipDown} 200ms ease-out;
|
||||
@@ -24,24 +26,25 @@ const StyledMenuContent = styled(RadixMenu.Content)`
|
||||
export const Menu = RadixMenu.Root;
|
||||
export const MenuTrigger = RadixMenu.Trigger;
|
||||
|
||||
export const MenuContent = forwardRef<HTMLDivElement, DropdownMenuContentProps>(
|
||||
function MenuContent({ children, ...props }, forwardedRef) {
|
||||
return (
|
||||
<RadixMenu.Portal>
|
||||
<StyledMenuContent
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
collisionPadding={8}
|
||||
collisionBoundary={typeof document !== "undefined" ? document.body : []}
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
>
|
||||
{children}
|
||||
</StyledMenuContent>
|
||||
</RadixMenu.Portal>
|
||||
);
|
||||
}
|
||||
);
|
||||
export const MenuContent = forwardRef<HTMLDivElement, DropdownMenuContentProps>(function MenuContent(
|
||||
{ children, ...props },
|
||||
forwardedRef,
|
||||
) {
|
||||
return (
|
||||
<RadixMenu.Portal>
|
||||
<StyledMenuContent
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
collisionPadding={8}
|
||||
collisionBoundary={typeof document !== "undefined" ? document.body : []}
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
>
|
||||
{children}
|
||||
</StyledMenuContent>
|
||||
</RadixMenu.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
export const MenuItem = styled(RadixMenu.Item)`
|
||||
display: flex;
|
||||
@@ -58,7 +61,7 @@ export const MenuItem = styled(RadixMenu.Item)`
|
||||
color: ${theme.colors["text"]};
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
||||
&[data-disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: revert;
|
||||
@@ -69,7 +72,7 @@ export const MenuLabel = styled(RadixMenu.Label)`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
|
||||
width: 100%;
|
||||
padding: 8px 16px;
|
||||
color: ${theme.colors["text-muted"]};
|
||||
|
||||
@@ -22,17 +22,22 @@ export function ShareMenu({ pagePath, videoUrl, audioUrl, trigger }: ShareMenuPr
|
||||
const [audioMode] = useSetting(AudioMode, { storageSync: false });
|
||||
|
||||
function saveToClipboard(url: string) {
|
||||
navigator.clipboard.writeText(url)
|
||||
.then(() => dispatchToast("clipboard", <Toast>Copied to clipboard!</Toast>));
|
||||
navigator.clipboard.writeText(url).then(() => dispatchToast("clipboard", <Toast>Copied to clipboard!</Toast>));
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu modal={false}>
|
||||
<MenuTrigger asChild>
|
||||
{trigger ?? <IconTextButton icon={faShare} variant="solid" collapsible="socialListMax">Share</IconTextButton>}
|
||||
{trigger ?? (
|
||||
<IconTextButton icon={faShare} variant="solid" collapsible="socialListMax">
|
||||
Share
|
||||
</IconTextButton>
|
||||
)}
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
<MenuItem onSelect={() => saveToClipboard(location.origin + BASE_PATH + pagePath)}>Copy URL to this Page</MenuItem>
|
||||
<MenuItem onSelect={() => saveToClipboard(location.origin + BASE_PATH + pagePath)}>
|
||||
Copy URL to this Page
|
||||
</MenuItem>
|
||||
{audioMode === AudioMode.ENABLED ? (
|
||||
<>
|
||||
<MenuItem onSelect={() => saveToClipboard(audioUrl)}>Copy URL to Embeddable Audio</MenuItem>
|
||||
@@ -51,4 +56,4 @@ export function ShareMenu({ pagePath, videoUrl, audioUrl, trigger }: ShareMenuPr
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +31,12 @@ export function ThemeMenu({ theme }: ThemeMenuProps) {
|
||||
// Flip the structure on it's head, because we need video as the root object here.
|
||||
const videoFlipped = {
|
||||
...video,
|
||||
entries: [{
|
||||
...entry,
|
||||
theme,
|
||||
}],
|
||||
entries: [
|
||||
{
|
||||
...entry,
|
||||
theme,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -79,7 +81,7 @@ ThemeMenu.fragments = {
|
||||
${createVideoSlug.fragments.theme}
|
||||
${createVideoSlug.fragments.entry}
|
||||
${createVideoSlug.fragments.video}
|
||||
|
||||
|
||||
fragment ThemeMenuTheme on Theme {
|
||||
...createVideoSlugTheme
|
||||
id
|
||||
|
||||
@@ -14,13 +14,15 @@ export const StyledNavigation = styled.nav<{ $floating: boolean }>`
|
||||
|
||||
transition: 100ms ease;
|
||||
transition-property: background-color, box-shadow;
|
||||
|
||||
${(props) => props.$floating && css`
|
||||
transition: 500ms ease;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
`}
|
||||
|
||||
|
||||
${(props) =>
|
||||
props.$floating &&
|
||||
css`
|
||||
transition: 500ms ease;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
`}
|
||||
|
||||
[data-fullscreen] & {
|
||||
display: none;
|
||||
}
|
||||
@@ -32,7 +34,7 @@ export const StyledNavigationContainer = styled(Container)`
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
|
||||
|
||||
padding: 8px 16px;
|
||||
`;
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
StyledNavigationContainer,
|
||||
StyledNavigationLinks,
|
||||
StyledProfileImage,
|
||||
StyledProfileImageIcon
|
||||
StyledProfileImageIcon,
|
||||
} from "@/components/navigation/Navigation.style";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import useCurrentSeason from "@/hooks/useCurrentSeason";
|
||||
@@ -24,9 +24,9 @@ export function Navigation() {
|
||||
const { currentYear, currentSeason } = useCurrentSeason();
|
||||
|
||||
const router = useRouter();
|
||||
const [ prevPathname, setPrevPathname ] = useState(router.pathname);
|
||||
const [prevPathname, setPrevPathname] = useState(router.pathname);
|
||||
|
||||
const [ isFloating, setFloating ] = useState(true);
|
||||
const [isFloating, setFloating] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
function onScroll() {
|
||||
@@ -45,50 +45,71 @@ export function Navigation() {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>
|
||||
<StyledNavigation $floating={isFloating}>
|
||||
<StyledNavigationContainer onClick={(event) => event.stopPropagation()}>
|
||||
<Link href="/" passHref legacyBehavior>
|
||||
<StyledLogoContainer>
|
||||
<StyledLogo width="277" height="150"/>
|
||||
</StyledLogoContainer>
|
||||
</Link>
|
||||
<StyledNavigationLinks>
|
||||
<Link href="/search" passHref legacyBehavior>
|
||||
<IconTextButton forwardedAs="a" icon={faSearch} variant="silent" collapsible style={{ "--gap": "8px" }}>
|
||||
Search
|
||||
</IconTextButton>
|
||||
return (
|
||||
<>
|
||||
<StyledNavigation $floating={isFloating}>
|
||||
<StyledNavigationContainer onClick={(event) => event.stopPropagation()}>
|
||||
<Link href="/" passHref legacyBehavior>
|
||||
<StyledLogoContainer>
|
||||
<StyledLogo width="277" height="150" />
|
||||
</StyledLogoContainer>
|
||||
</Link>
|
||||
<ShuffleDialog trigger={
|
||||
<IconTextButton variant="silent" icon={faRandom} collapsible style={{ "--gap": "8px" }}>
|
||||
Shuffle
|
||||
</IconTextButton>
|
||||
} />
|
||||
<Link
|
||||
href={(currentYear && currentSeason) ? `/year/${currentYear}/${currentSeason}` : "/"}
|
||||
passHref
|
||||
legacyBehavior>
|
||||
<IconTextButton forwardedAs="a" variant="silent" icon={faTv} collapsible style={{ "--gap": "8px" }}>
|
||||
Current Season
|
||||
</IconTextButton>
|
||||
</Link>
|
||||
<Link href="/profile" passHref legacyBehavior>
|
||||
<IconTextButton
|
||||
forwardedAs="a"
|
||||
variant="silent"
|
||||
icon={me.user ? (
|
||||
<StyledProfileImageIcon>
|
||||
<StyledProfileImage user={me.user} />
|
||||
</StyledProfileImageIcon>
|
||||
) : faUser}
|
||||
title="My Profile"
|
||||
collapsible
|
||||
<StyledNavigationLinks>
|
||||
<Link href="/search" passHref legacyBehavior>
|
||||
<IconTextButton
|
||||
forwardedAs="a"
|
||||
icon={faSearch}
|
||||
variant="silent"
|
||||
collapsible
|
||||
style={{ "--gap": "8px" }}
|
||||
>
|
||||
Search
|
||||
</IconTextButton>
|
||||
</Link>
|
||||
<ShuffleDialog
|
||||
trigger={
|
||||
<IconTextButton variant="silent" icon={faRandom} collapsible style={{ "--gap": "8px" }}>
|
||||
Shuffle
|
||||
</IconTextButton>
|
||||
}
|
||||
/>
|
||||
<Link
|
||||
href={currentYear && currentSeason ? `/year/${currentYear}/${currentSeason}` : "/"}
|
||||
passHref
|
||||
legacyBehavior
|
||||
>
|
||||
My Profile
|
||||
</IconTextButton>
|
||||
</Link>
|
||||
</StyledNavigationLinks>
|
||||
</StyledNavigationContainer>
|
||||
</StyledNavigation>
|
||||
</>;
|
||||
<IconTextButton
|
||||
forwardedAs="a"
|
||||
variant="silent"
|
||||
icon={faTv}
|
||||
collapsible
|
||||
style={{ "--gap": "8px" }}
|
||||
>
|
||||
Current Season
|
||||
</IconTextButton>
|
||||
</Link>
|
||||
<Link href="/profile" passHref legacyBehavior>
|
||||
<IconTextButton
|
||||
forwardedAs="a"
|
||||
variant="silent"
|
||||
icon={
|
||||
me.user ? (
|
||||
<StyledProfileImageIcon>
|
||||
<StyledProfileImage user={me.user} />
|
||||
</StyledProfileImageIcon>
|
||||
) : (
|
||||
faUser
|
||||
)
|
||||
}
|
||||
title="My Profile"
|
||||
collapsible
|
||||
>
|
||||
My Profile
|
||||
</IconTextButton>
|
||||
</Link>
|
||||
</StyledNavigationLinks>
|
||||
</StyledNavigationContainer>
|
||||
</StyledNavigation>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ const StyledSearchOptions = styled.div`
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
grid-gap: 1rem;
|
||||
|
||||
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: stretch;
|
||||
@@ -28,19 +28,23 @@ const updateSearchQuery = debounce((router, newSearchQuery) => {
|
||||
// Update URL to maintain the searchQuery on page navigation.
|
||||
const newUrlParams = {
|
||||
...router.query,
|
||||
q: newSearchQuery
|
||||
q: newSearchQuery,
|
||||
};
|
||||
|
||||
if (!newUrlParams.q) {
|
||||
delete newUrlParams.q;
|
||||
}
|
||||
|
||||
router.replace({
|
||||
pathname: router.pathname,
|
||||
query: newUrlParams
|
||||
}, null, {
|
||||
shallow: true
|
||||
});
|
||||
router.replace(
|
||||
{
|
||||
pathname: router.pathname,
|
||||
query: newUrlParams,
|
||||
},
|
||||
null,
|
||||
{
|
||||
shallow: true,
|
||||
},
|
||||
);
|
||||
}, 500);
|
||||
|
||||
export function SearchNavigation() {
|
||||
@@ -62,7 +66,7 @@ export function SearchNavigation() {
|
||||
// Only focus the input on desktop devices
|
||||
if (window.matchMedia("(hover: hover) and (pointer: fine)").matches) {
|
||||
input?.focus({
|
||||
preventScroll: true
|
||||
preventScroll: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -71,10 +75,13 @@ export function SearchNavigation() {
|
||||
|
||||
useEffect(() => {
|
||||
const hotkeyListener = (event: KeyboardEvent) => {
|
||||
if (inputRef.current !== document.activeElement && ((event.key === "s" && event.ctrlKey) || (event.key === "/"))) {
|
||||
if (
|
||||
inputRef.current !== document.activeElement &&
|
||||
((event.key === "s" && event.ctrlKey) || event.key === "/")
|
||||
) {
|
||||
event.preventDefault();
|
||||
inputRef.current?.focus({
|
||||
preventScroll: true
|
||||
preventScroll: true,
|
||||
});
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
@@ -91,31 +98,75 @@ export function SearchNavigation() {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>
|
||||
<Text variant="h1">Search</Text>
|
||||
<StyledSearchOptions>
|
||||
<Input
|
||||
value={inputSearchQuery}
|
||||
onChange={updateInputSearchQuery}
|
||||
inputProps={{
|
||||
ref: onMountInput,
|
||||
spellCheck: false,
|
||||
placeholder: "Search"
|
||||
}}
|
||||
resettable
|
||||
icon={faSearch}
|
||||
/>
|
||||
<HorizontalScroll fixShadows>
|
||||
<Switcher selectedItem={entity as string || null}>
|
||||
<SwitcherReset as={Link} prefetch={false} href={{ pathname: "/search", query }}/>
|
||||
<SwitcherOption as={Link} prefetch={false} href={{ pathname: "/search/anime", query }} value="anime">Anime</SwitcherOption>
|
||||
<SwitcherOption as={Link} prefetch={false} href={{ pathname: "/search/theme", query }} value="theme">Theme</SwitcherOption>
|
||||
<SwitcherOption as={Link} prefetch={false} href={{ pathname: "/search/artist", query }} value="artist">Artist</SwitcherOption>
|
||||
<SwitcherOption as={Link} prefetch={false} href={{ pathname: "/search/series", query }} value="series">Series</SwitcherOption>
|
||||
<SwitcherOption as={Link} prefetch={false} href={{ pathname: "/search/studio", query }} value="studio">Studio</SwitcherOption>
|
||||
<SwitcherOption as={Link} prefetch={false} href={{ pathname: "/search/playlist", query }} value="playlist">Playlist</SwitcherOption>
|
||||
</Switcher>
|
||||
</HorizontalScroll>
|
||||
</StyledSearchOptions>
|
||||
</>;
|
||||
return (
|
||||
<>
|
||||
<Text variant="h1">Search</Text>
|
||||
<StyledSearchOptions>
|
||||
<Input
|
||||
value={inputSearchQuery}
|
||||
onChange={updateInputSearchQuery}
|
||||
inputProps={{
|
||||
ref: onMountInput,
|
||||
spellCheck: false,
|
||||
placeholder: "Search",
|
||||
}}
|
||||
resettable
|
||||
icon={faSearch}
|
||||
/>
|
||||
<HorizontalScroll fixShadows>
|
||||
<Switcher selectedItem={(entity as string) || null}>
|
||||
<SwitcherReset as={Link} prefetch={false} href={{ pathname: "/search", query }} />
|
||||
<SwitcherOption
|
||||
as={Link}
|
||||
prefetch={false}
|
||||
href={{ pathname: "/search/anime", query }}
|
||||
value="anime"
|
||||
>
|
||||
Anime
|
||||
</SwitcherOption>
|
||||
<SwitcherOption
|
||||
as={Link}
|
||||
prefetch={false}
|
||||
href={{ pathname: "/search/theme", query }}
|
||||
value="theme"
|
||||
>
|
||||
Theme
|
||||
</SwitcherOption>
|
||||
<SwitcherOption
|
||||
as={Link}
|
||||
prefetch={false}
|
||||
href={{ pathname: "/search/artist", query }}
|
||||
value="artist"
|
||||
>
|
||||
Artist
|
||||
</SwitcherOption>
|
||||
<SwitcherOption
|
||||
as={Link}
|
||||
prefetch={false}
|
||||
href={{ pathname: "/search/series", query }}
|
||||
value="series"
|
||||
>
|
||||
Series
|
||||
</SwitcherOption>
|
||||
<SwitcherOption
|
||||
as={Link}
|
||||
prefetch={false}
|
||||
href={{ pathname: "/search/studio", query }}
|
||||
value="studio"
|
||||
>
|
||||
Studio
|
||||
</SwitcherOption>
|
||||
<SwitcherOption
|
||||
as={Link}
|
||||
prefetch={false}
|
||||
href={{ pathname: "/search/playlist", query }}
|
||||
value="playlist"
|
||||
>
|
||||
Playlist
|
||||
</SwitcherOption>
|
||||
</Switcher>
|
||||
</HorizontalScroll>
|
||||
</StyledSearchOptions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,9 @@ export function YearNavigation({ year, yearAll }: YearDetailPageProps) {
|
||||
<StyledYearPrevious>
|
||||
{previousYear && (
|
||||
<Link href={`/year/${previousYear}`} passHref legacyBehavior>
|
||||
<Button as="a" variant="silent">{previousYear}</Button>
|
||||
<Button as="a" variant="silent">
|
||||
{previousYear}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</StyledYearPrevious>
|
||||
@@ -43,7 +45,9 @@ export function YearNavigation({ year, yearAll }: YearDetailPageProps) {
|
||||
<StyledYearNext>
|
||||
{nextYear && (
|
||||
<Link href={`/year/${nextYear}`} passHref legacyBehavior>
|
||||
<Button as="a" variant="silent">{nextYear}</Button>
|
||||
<Button as="a" variant="silent">
|
||||
{nextYear}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</StyledYearNext>
|
||||
|
||||
@@ -5,8 +5,8 @@ import { Text } from "@/components/text/Text";
|
||||
const letters = createLetters();
|
||||
|
||||
interface SearchFilterFirstLetterProps {
|
||||
value: string | null
|
||||
setValue: (newValue: string | null) => void
|
||||
value: string | null;
|
||||
setValue: (newValue: string | null) => void;
|
||||
}
|
||||
|
||||
export function SearchFilterFirstLetter({ value, setValue }: SearchFilterFirstLetterProps) {
|
||||
@@ -14,9 +14,13 @@ export function SearchFilterFirstLetter({ value, setValue }: SearchFilterFirstLe
|
||||
<SearchFilter>
|
||||
<Text variant="h2">First Letter</Text>
|
||||
<Listbox value={value} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
|
||||
<ListboxOption value={null} hidden>Any</ListboxOption>
|
||||
<ListboxOption value={null} hidden>
|
||||
Any
|
||||
</ListboxOption>
|
||||
{letters.map((letter) => (
|
||||
<ListboxOption key={letter} value={letter}>{letter}</ListboxOption>
|
||||
<ListboxOption key={letter} value={letter}>
|
||||
{letter}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</Listbox>
|
||||
</SearchFilter>
|
||||
|
||||
@@ -3,8 +3,8 @@ import { SearchFilter } from "@/components/search-filter/SearchFilter";
|
||||
import { Text } from "@/components/text/Text";
|
||||
|
||||
interface SearchFilterMediaFormatProps {
|
||||
value: string | null
|
||||
setValue: (newValue: string | null) => void
|
||||
value: string | null;
|
||||
setValue: (newValue: string | null) => void;
|
||||
}
|
||||
|
||||
export function SearchFilterMediaFormat({ value, setValue }: SearchFilterMediaFormatProps) {
|
||||
@@ -12,7 +12,9 @@ export function SearchFilterMediaFormat({ value, setValue }: SearchFilterMediaFo
|
||||
<SearchFilter>
|
||||
<Text variant="h2">Format</Text>
|
||||
<Listbox value={value} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
|
||||
<ListboxOption value={null} hidden>Any</ListboxOption>
|
||||
<ListboxOption value={null} hidden>
|
||||
Any
|
||||
</ListboxOption>
|
||||
<ListboxOption value="TV">TV</ListboxOption>
|
||||
<ListboxOption value="TV Short">TV Short</ListboxOption>
|
||||
<ListboxOption value="Movie">Movie</ListboxOption>
|
||||
|
||||
@@ -3,8 +3,8 @@ import { SearchFilter } from "@/components/search-filter/SearchFilter";
|
||||
import { Text } from "@/components/text/Text";
|
||||
|
||||
interface SearchFilterSeasonProps {
|
||||
value: string | null
|
||||
setValue: (newValue: string | null) => void
|
||||
value: string | null;
|
||||
setValue: (newValue: string | null) => void;
|
||||
}
|
||||
|
||||
export function SearchFilterSeason({ value, setValue }: SearchFilterSeasonProps) {
|
||||
@@ -12,7 +12,9 @@ export function SearchFilterSeason({ value, setValue }: SearchFilterSeasonProps)
|
||||
<SearchFilter>
|
||||
<Text variant="h2">Season</Text>
|
||||
<Listbox value={value} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
|
||||
<ListboxOption value={null} hidden>Any</ListboxOption>
|
||||
<ListboxOption value={null} hidden>
|
||||
Any
|
||||
</ListboxOption>
|
||||
<ListboxOption value="Winter">Winter</ListboxOption>
|
||||
<ListboxOption value="Spring">Spring</ListboxOption>
|
||||
<ListboxOption value="Summer">Summer</ListboxOption>
|
||||
|
||||
@@ -6,9 +6,9 @@ import { SearchFilter } from "@/components/search-filter/SearchFilter";
|
||||
import { Text } from "@/components/text/Text";
|
||||
|
||||
interface SearchFilterSortByProps<T extends string | null> {
|
||||
children: ReactNode
|
||||
value: T
|
||||
setValue: (newValue: T) => void
|
||||
children: ReactNode;
|
||||
value: T;
|
||||
setValue: (newValue: T) => void;
|
||||
}
|
||||
|
||||
export function SearchFilterSortBy<T extends string | null>({ children, value, setValue }: SearchFilterSortByProps<T>) {
|
||||
@@ -23,5 +23,5 @@ export function SearchFilterSortBy<T extends string | null>({ children, value, s
|
||||
}
|
||||
|
||||
SearchFilterSortBy.Option = function SearchFilterSortByOption(props: ListboxOptionProps) {
|
||||
return <ListboxOption {...props}/>;
|
||||
return <ListboxOption {...props} />;
|
||||
};
|
||||
|
||||
@@ -3,8 +3,8 @@ import { SearchFilter } from "@/components/search-filter/SearchFilter";
|
||||
import { Text } from "@/components/text/Text";
|
||||
|
||||
interface SearchFilterThemeTypeProps {
|
||||
value: string | null
|
||||
setValue: (newValue: string | null) => void
|
||||
value: string | null;
|
||||
setValue: (newValue: string | null) => void;
|
||||
}
|
||||
|
||||
export function SearchFilterThemeType({ value, setValue }: SearchFilterThemeTypeProps) {
|
||||
@@ -12,7 +12,9 @@ export function SearchFilterThemeType({ value, setValue }: SearchFilterThemeType
|
||||
<SearchFilter>
|
||||
<Text variant="h2">Type</Text>
|
||||
<Listbox value={value} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
|
||||
<ListboxOption value={null} hidden>Any</ListboxOption>
|
||||
<ListboxOption value={null} hidden>
|
||||
Any
|
||||
</ListboxOption>
|
||||
<ListboxOption value="OP">OP</ListboxOption>
|
||||
<ListboxOption value="ED">ED</ListboxOption>
|
||||
</Listbox>
|
||||
|
||||
@@ -4,8 +4,8 @@ import { Text } from "@/components/text/Text";
|
||||
import useYearList from "@/hooks/useYearList";
|
||||
|
||||
interface SearchFilterYearProps {
|
||||
value: string | null
|
||||
setValue: (newValue: string | null) => void
|
||||
value: string | null;
|
||||
setValue: (newValue: string | null) => void;
|
||||
}
|
||||
|
||||
export function SearchFilterYear({ value, setValue }: SearchFilterYearProps) {
|
||||
@@ -14,10 +14,21 @@ export function SearchFilterYear({ value, setValue }: SearchFilterYearProps) {
|
||||
return (
|
||||
<SearchFilter>
|
||||
<Text variant="h2">Year</Text>
|
||||
<Listbox value={value ? String(value) : null} onValueChange={setValue} defaultValue={null} resettable nullable highlightNonDefault>
|
||||
<ListboxOption value={null} hidden>Any</ListboxOption>
|
||||
<Listbox
|
||||
value={value ? String(value) : null}
|
||||
onValueChange={setValue}
|
||||
defaultValue={null}
|
||||
resettable
|
||||
nullable
|
||||
highlightNonDefault
|
||||
>
|
||||
<ListboxOption value={null} hidden>
|
||||
Any
|
||||
</ListboxOption>
|
||||
{yearList.map((year) => (
|
||||
<ListboxOption key={year} value={year ? String(year) : null}>{String(year)}</ListboxOption>
|
||||
<ListboxOption key={year} value={year ? String(year) : null}>
|
||||
{String(year)}
|
||||
</ListboxOption>
|
||||
))}
|
||||
</Listbox>
|
||||
</SearchFilter>
|
||||
|
||||
@@ -22,7 +22,7 @@ const initialFilter = {
|
||||
};
|
||||
|
||||
interface SearchAnimeProps {
|
||||
searchQuery?: string
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export function SearchAnime({ searchQuery }: SearchAnimeProps) {
|
||||
@@ -30,7 +30,7 @@ export function SearchAnime({ searchQuery }: SearchAnimeProps) {
|
||||
...initialFilter,
|
||||
sortBy: searchQuery ? null : initialFilter.sortBy,
|
||||
});
|
||||
const [ prevSearchQuery, setPrevSearchQuery ] = useState(searchQuery);
|
||||
const [prevSearchQuery, setPrevSearchQuery] = useState(searchQuery);
|
||||
|
||||
if (!searchQuery && filter.sortBy === null) {
|
||||
updateFilter("sortBy", initialFilter.sortBy);
|
||||
@@ -47,8 +47,7 @@ export function SearchAnime({ searchQuery }: SearchAnimeProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchEntity
|
||||
<SearchAnimeQuery["searchAnime"]["data"][number]>
|
||||
<SearchEntity<SearchAnimeQuery["searchAnime"]["data"][number]>
|
||||
entity="anime"
|
||||
searchArgs={{
|
||||
query: searchQuery,
|
||||
@@ -61,32 +60,33 @@ export function SearchAnime({ searchQuery }: SearchAnimeProps) {
|
||||
sortBy: filter.sortBy,
|
||||
}}
|
||||
fetchResults={async (searchArgs) => {
|
||||
const { data } = await fetchDataClient<SearchAnimeQuery, SearchAnimeQueryVariables>(gql`
|
||||
${AnimeSummaryCard.fragments.anime}
|
||||
${AnimeSummaryCard.fragments.expandable}
|
||||
|
||||
query SearchAnime($args: SearchArgs!) {
|
||||
searchAnime(args: $args) {
|
||||
data {
|
||||
...AnimeSummaryCardAnime
|
||||
...AnimeSummaryCardAnimeExpandable
|
||||
const { data } = await fetchDataClient<SearchAnimeQuery, SearchAnimeQueryVariables>(
|
||||
gql`
|
||||
${AnimeSummaryCard.fragments.anime}
|
||||
${AnimeSummaryCard.fragments.expandable}
|
||||
|
||||
query SearchAnime($args: SearchArgs!) {
|
||||
searchAnime(args: $args) {
|
||||
data {
|
||||
...AnimeSummaryCardAnime
|
||||
...AnimeSummaryCardAnimeExpandable
|
||||
}
|
||||
nextPage
|
||||
}
|
||||
nextPage
|
||||
}
|
||||
}
|
||||
`, { args: searchArgs });
|
||||
`,
|
||||
{ args: searchArgs },
|
||||
);
|
||||
|
||||
return data.searchAnime;
|
||||
}}
|
||||
renderResult={(anime) => (
|
||||
<AnimeSummaryCard key={anime.slug} anime={anime} expandable/>
|
||||
)}
|
||||
renderResult={(anime) => <AnimeSummaryCard key={anime.slug} anime={anime} expandable />}
|
||||
filters={
|
||||
<>
|
||||
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
|
||||
<SearchFilterSeason value={filter.season} setValue={bindUpdateFilter("season")}/>
|
||||
<SearchFilterYear value={filter.year} setValue={bindUpdateFilter("year")}/>
|
||||
<SearchFilterMediaFormat value={filter.mediaFormat} setValue={bindUpdateFilter("mediaFormat")}/>
|
||||
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")} />
|
||||
<SearchFilterSeason value={filter.season} setValue={bindUpdateFilter("season")} />
|
||||
<SearchFilterYear value={filter.year} setValue={bindUpdateFilter("year")} />
|
||||
<SearchFilterMediaFormat value={filter.mediaFormat} setValue={bindUpdateFilter("mediaFormat")} />
|
||||
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
|
||||
{searchQuery ? (
|
||||
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
|
||||
|
||||
@@ -16,7 +16,7 @@ const initialFilter = {
|
||||
};
|
||||
|
||||
interface SearchArtistProps {
|
||||
searchQuery?: string
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export function SearchArtist({ searchQuery }: SearchArtistProps) {
|
||||
@@ -24,7 +24,7 @@ export function SearchArtist({ searchQuery }: SearchArtistProps) {
|
||||
...initialFilter,
|
||||
sortBy: searchQuery ? null : initialFilter.sortBy,
|
||||
});
|
||||
const [ prevSearchQuery, setPrevSearchQuery ] = useState(searchQuery);
|
||||
const [prevSearchQuery, setPrevSearchQuery] = useState(searchQuery);
|
||||
|
||||
if (!searchQuery && filter.sortBy === null) {
|
||||
updateFilter("sortBy", initialFilter.sortBy);
|
||||
@@ -41,38 +41,38 @@ export function SearchArtist({ searchQuery }: SearchArtistProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchEntity
|
||||
<SearchArtistQuery["searchArtist"]["data"][number]>
|
||||
<SearchEntity<SearchArtistQuery["searchArtist"]["data"][number]>
|
||||
entity="artist"
|
||||
searchArgs={{
|
||||
query: searchQuery,
|
||||
filters: {
|
||||
"name-like": filter.firstLetter ? `${filter.firstLetter}%` : null,
|
||||
},
|
||||
sortBy: filter.sortBy
|
||||
sortBy: filter.sortBy,
|
||||
}}
|
||||
fetchResults={async (searchArgs) => {
|
||||
const { data } = await fetchDataClient<SearchArtistQuery, SearchArtistQueryVariables>(gql`
|
||||
${ArtistSummaryCard.fragments.artist}
|
||||
|
||||
query SearchArtist($args: SearchArgs!) {
|
||||
searchArtist(args: $args) {
|
||||
data {
|
||||
...ArtistSummaryCardArtist
|
||||
const { data } = await fetchDataClient<SearchArtistQuery, SearchArtistQueryVariables>(
|
||||
gql`
|
||||
${ArtistSummaryCard.fragments.artist}
|
||||
|
||||
query SearchArtist($args: SearchArgs!) {
|
||||
searchArtist(args: $args) {
|
||||
data {
|
||||
...ArtistSummaryCardArtist
|
||||
}
|
||||
nextPage
|
||||
}
|
||||
nextPage
|
||||
}
|
||||
}
|
||||
`, { args: searchArgs });
|
||||
`,
|
||||
{ args: searchArgs },
|
||||
);
|
||||
|
||||
return data.searchArtist;
|
||||
}}
|
||||
renderResult={(artist) => (
|
||||
<ArtistSummaryCard key={artist.slug} artist={artist}/>
|
||||
)}
|
||||
renderResult={(artist) => <ArtistSummaryCard key={artist.slug} artist={artist} />}
|
||||
filters={
|
||||
<>
|
||||
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
|
||||
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")} />
|
||||
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
|
||||
{searchQuery ? (
|
||||
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
|
||||
|
||||
@@ -13,50 +13,30 @@ import useEntitySearch from "@/hooks/useEntitySearch";
|
||||
import type { SimpleSearchArgs } from "@/lib/client/search";
|
||||
|
||||
interface SearchEntityProps<T> {
|
||||
entity: string
|
||||
entity: string;
|
||||
fetchResults: (searchArgs: SearchArgs) => Promise<{
|
||||
data: Array<T>
|
||||
nextPage: number | null
|
||||
}>
|
||||
searchArgs: SimpleSearchArgs
|
||||
filters: ReactNode
|
||||
renderResult: (result: T) => ReactNode
|
||||
data: Array<T>;
|
||||
nextPage: number | null;
|
||||
}>;
|
||||
searchArgs: SimpleSearchArgs;
|
||||
filters: ReactNode;
|
||||
renderResult: (result: T) => ReactNode;
|
||||
}
|
||||
|
||||
export function SearchEntity<T>({ entity, fetchResults, searchArgs, filters, renderResult }: SearchEntityProps<T>) {
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
isError,
|
||||
isFetchingNextPage,
|
||||
isLoading,
|
||||
isPlaceholderData,
|
||||
} = useEntitySearch<T>(
|
||||
entity,
|
||||
fetchResults,
|
||||
searchArgs
|
||||
);
|
||||
const { data, error, fetchNextPage, hasNextPage, isError, isFetchingNextPage, isLoading, isPlaceholderData } =
|
||||
useEntitySearch<T>(entity, fetchResults, searchArgs);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!filters && (
|
||||
<SearchFilterGroup>
|
||||
{filters}
|
||||
</SearchFilterGroup>
|
||||
)}
|
||||
{!!filters && <SearchFilterGroup>{filters}</SearchFilterGroup>}
|
||||
{(() => {
|
||||
if (isError) {
|
||||
return (
|
||||
<ErrorCard error={error}/>
|
||||
);
|
||||
return <ErrorCard error={error} />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Text block>Searching...</Text>
|
||||
);
|
||||
return <Text block>Searching...</Text>;
|
||||
}
|
||||
|
||||
const results = data?.pages.flatMap((page) => page.data) ?? [];
|
||||
@@ -64,12 +44,12 @@ export function SearchEntity<T>({ entity, fetchResults, searchArgs, filters, ren
|
||||
if (!results.length) {
|
||||
if (searchArgs.query) {
|
||||
return (
|
||||
<Text block>No results found for query "{searchArgs.query}". Did you spell it correctly?</Text>
|
||||
<Text block>
|
||||
No results found for query "{searchArgs.query}". Did you spell it correctly?
|
||||
</Text>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Text block>No results found for your current filter settings.</Text>
|
||||
);
|
||||
return <Text block>No results found for your current filter settings.</Text>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,13 +57,19 @@ export function SearchEntity<T>({ entity, fetchResults, searchArgs, filters, ren
|
||||
|
||||
return (
|
||||
<>
|
||||
<Column style={{ "--gap": "16px" }}>
|
||||
{results.map(renderResult)}
|
||||
</Column>
|
||||
<Column style={{ "--gap": "16px" }}>{results.map(renderResult)}</Column>
|
||||
{(hasNextPage || isPlaceholderData) && (
|
||||
<Row style={{ "--justify-content": "center" }}>
|
||||
<Button variant="silent" isCircle onClick={() => !isLoadingMore && fetchNextPage()} title="Load more">
|
||||
<Icon icon={isLoadingMore ? faSpinner : faChevronDown} className={isLoadingMore ? "fa-spin" : undefined}/>
|
||||
<Button
|
||||
variant="silent"
|
||||
isCircle
|
||||
onClick={() => !isLoadingMore && fetchNextPage()}
|
||||
title="Load more"
|
||||
>
|
||||
<Icon
|
||||
icon={isLoadingMore ? faSpinner : faChevronDown}
|
||||
className={isLoadingMore ? "fa-spin" : undefined}
|
||||
/>
|
||||
</Button>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
@@ -20,71 +20,62 @@ import type { SearchGlobalQuery, SearchGlobalQueryVariables } from "@/generated/
|
||||
import { fetchDataClient } from "@/lib/client";
|
||||
|
||||
interface SearchGlobalProps {
|
||||
searchQuery?: string
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export function SearchGlobal({ searchQuery }: SearchGlobalProps) {
|
||||
const fetchSearchResults = () => fetchDataClient<SearchGlobalQuery, SearchGlobalQueryVariables>(gql`
|
||||
${AnimeSummaryCard.fragments.anime}
|
||||
${AnimeSummaryCard.fragments.expandable}
|
||||
${ThemeSummaryCard.fragments.theme}
|
||||
${ThemeSummaryCard.fragments.expandable}
|
||||
${ArtistSummaryCard.fragments.artist}
|
||||
${PlaylistSummaryCard.fragments.playlist}
|
||||
${PlaylistSummaryCard.fragments.showOwner}
|
||||
|
||||
query SearchGlobal($args: SearchArgs!) {
|
||||
search(args: $args) {
|
||||
anime {
|
||||
...AnimeSummaryCardAnime
|
||||
...AnimeSummaryCardAnimeExpandable
|
||||
}
|
||||
themes {
|
||||
...ThemeSummaryCardTheme
|
||||
...ThemeSummaryCardThemeExpandable
|
||||
}
|
||||
artists {
|
||||
...ArtistSummaryCardArtist
|
||||
}
|
||||
series {
|
||||
slug
|
||||
name
|
||||
}
|
||||
studios {
|
||||
slug
|
||||
name
|
||||
}
|
||||
playlists {
|
||||
...PlaylistSummaryCardPlaylist
|
||||
...PlaylistSummaryCardShowOwner
|
||||
}
|
||||
}
|
||||
}
|
||||
`, { args: { query: searchQuery ?? null } });
|
||||
const fetchSearchResults = () =>
|
||||
fetchDataClient<SearchGlobalQuery, SearchGlobalQueryVariables>(
|
||||
gql`
|
||||
${AnimeSummaryCard.fragments.anime}
|
||||
${AnimeSummaryCard.fragments.expandable}
|
||||
${ThemeSummaryCard.fragments.theme}
|
||||
${ThemeSummaryCard.fragments.expandable}
|
||||
${ArtistSummaryCard.fragments.artist}
|
||||
${PlaylistSummaryCard.fragments.playlist}
|
||||
${PlaylistSummaryCard.fragments.showOwner}
|
||||
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
isLoading,
|
||||
isError
|
||||
} = useQuery(
|
||||
["searchGlobal", searchQuery],
|
||||
fetchSearchResults,
|
||||
{
|
||||
keepPreviousData: true
|
||||
}
|
||||
);
|
||||
query SearchGlobal($args: SearchArgs!) {
|
||||
search(args: $args) {
|
||||
anime {
|
||||
...AnimeSummaryCardAnime
|
||||
...AnimeSummaryCardAnimeExpandable
|
||||
}
|
||||
themes {
|
||||
...ThemeSummaryCardTheme
|
||||
...ThemeSummaryCardThemeExpandable
|
||||
}
|
||||
artists {
|
||||
...ArtistSummaryCardArtist
|
||||
}
|
||||
series {
|
||||
slug
|
||||
name
|
||||
}
|
||||
studios {
|
||||
slug
|
||||
name
|
||||
}
|
||||
playlists {
|
||||
...PlaylistSummaryCardPlaylist
|
||||
...PlaylistSummaryCardShowOwner
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ args: { query: searchQuery ?? null } },
|
||||
);
|
||||
|
||||
const { data, error, isLoading, isError } = useQuery(["searchGlobal", searchQuery], fetchSearchResults, {
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<ErrorCard error={error}/>
|
||||
);
|
||||
return <ErrorCard error={error} />;
|
||||
}
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Text block>Searching...</Text>
|
||||
);
|
||||
return <Text block>Searching...</Text>;
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -105,9 +96,7 @@ export function SearchGlobal({ searchQuery }: SearchGlobalProps) {
|
||||
playlistResults.length;
|
||||
|
||||
if (!totalResults) {
|
||||
return (
|
||||
<Text block>No results found for query "{searchQuery}". Did you spell it correctly?</Text>
|
||||
);
|
||||
return <Text block>No results found for query "{searchQuery}". Did you spell it correctly?</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -116,27 +105,33 @@ export function SearchGlobal({ searchQuery }: SearchGlobalProps) {
|
||||
entity="anime"
|
||||
title="Anime"
|
||||
results={animeResults}
|
||||
renderSummaryCard={(anime) => <AnimeSummaryCard key={anime.slug} anime={anime} expandable/>}
|
||||
renderSummaryCard={(anime) => <AnimeSummaryCard key={anime.slug} anime={anime} expandable />}
|
||||
/>
|
||||
<GlobalSearchSection
|
||||
entity="theme"
|
||||
title="Themes"
|
||||
results={themeResults}
|
||||
renderSummaryCard={(theme) => <ThemeSummaryCard key={`${theme.anime?.slug}-${theme.id}`} theme={theme} expandable/>}
|
||||
renderSummaryCard={(theme) => (
|
||||
<ThemeSummaryCard key={`${theme.anime?.slug}-${theme.id}`} theme={theme} expandable />
|
||||
)}
|
||||
/>
|
||||
<GlobalSearchSection
|
||||
entity="artist"
|
||||
title="Artist"
|
||||
results={artistResults}
|
||||
renderSummaryCard={(artist) => <ArtistSummaryCard key={artist.slug} artist={artist}/>}
|
||||
renderSummaryCard={(artist) => <ArtistSummaryCard key={artist.slug} artist={artist} />}
|
||||
/>
|
||||
<GlobalSearchSection
|
||||
entity="series"
|
||||
title="Series"
|
||||
results={seriesResults}
|
||||
renderSummaryCard={(series) => (
|
||||
<SummaryCard key={series.slug} title={series.name} description="Series"
|
||||
to={`/series/${series.slug}`}/>
|
||||
<SummaryCard
|
||||
key={series.slug}
|
||||
title={series.name}
|
||||
description="Series"
|
||||
to={`/series/${series.slug}`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<GlobalSearchSection
|
||||
@@ -144,25 +139,31 @@ export function SearchGlobal({ searchQuery }: SearchGlobalProps) {
|
||||
title="Studios"
|
||||
results={studioResults}
|
||||
renderSummaryCard={(studio) => (
|
||||
<SummaryCard key={studio.slug} title={studio.name} description="Studio"
|
||||
to={`/studio/${studio.slug}`}/>
|
||||
<SummaryCard
|
||||
key={studio.slug}
|
||||
title={studio.name}
|
||||
description="Studio"
|
||||
to={`/studio/${studio.slug}`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<GlobalSearchSection
|
||||
entity="playlist"
|
||||
title="Playlists"
|
||||
results={playlistResults}
|
||||
renderSummaryCard={(playlist) => <PlaylistSummaryCard key={playlist.id} playlist={playlist} showOwner />}
|
||||
renderSummaryCard={(playlist) => (
|
||||
<PlaylistSummaryCard key={playlist.id} playlist={playlist} showOwner />
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface GlobalSearchSectionProps<T> {
|
||||
entity: string
|
||||
title: string
|
||||
results: Array<T>
|
||||
renderSummaryCard: (result: T) => ReactNode
|
||||
entity: string;
|
||||
title: string;
|
||||
results: Array<T>;
|
||||
renderSummaryCard: (result: T) => ReactNode;
|
||||
}
|
||||
|
||||
function GlobalSearchSection<T>({ entity, title, results, renderSummaryCard }: GlobalSearchSectionProps<T>) {
|
||||
@@ -176,22 +177,19 @@ function GlobalSearchSection<T>({ entity, title, results, renderSummaryCard }: G
|
||||
const resultsPreview = results.slice(0, 3);
|
||||
const hasMoreResults = results.length > 3;
|
||||
|
||||
return <>
|
||||
<Text variant="h2">{title}</Text>
|
||||
<Column style={{ "--gap": "16px" }}>
|
||||
{resultsPreview.map(renderSummaryCard)}
|
||||
</Column>
|
||||
{hasMoreResults && (
|
||||
<Row style={{ "--justify-content": "center" }}>
|
||||
<Link
|
||||
href={{ pathname: `/search/${entity}`, query: urlParams }}
|
||||
passHref
|
||||
legacyBehavior>
|
||||
<Button as="a" variant="silent" isCircle title="See all results">
|
||||
<Icon icon={faChevronDown}/>
|
||||
</Button>
|
||||
</Link>
|
||||
</Row>
|
||||
)}
|
||||
</>;
|
||||
return (
|
||||
<>
|
||||
<Text variant="h2">{title}</Text>
|
||||
<Column style={{ "--gap": "16px" }}>{resultsPreview.map(renderSummaryCard)}</Column>
|
||||
{hasMoreResults && (
|
||||
<Row style={{ "--justify-content": "center" }}>
|
||||
<Link href={{ pathname: `/search/${entity}`, query: urlParams }} passHref legacyBehavior>
|
||||
<Button as="a" variant="silent" isCircle title="See all results">
|
||||
<Icon icon={faChevronDown} />
|
||||
</Button>
|
||||
</Link>
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ const initialFilter = {
|
||||
};
|
||||
|
||||
interface SearchPlaylistProps {
|
||||
searchQuery?: string
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export function SearchPlaylist({ searchQuery }: SearchPlaylistProps) {
|
||||
@@ -22,7 +22,7 @@ export function SearchPlaylist({ searchQuery }: SearchPlaylistProps) {
|
||||
...initialFilter,
|
||||
sortBy: searchQuery ? null : initialFilter.sortBy,
|
||||
});
|
||||
const [ prevSearchQuery, setPrevSearchQuery ] = useState(searchQuery);
|
||||
const [prevSearchQuery, setPrevSearchQuery] = useState(searchQuery);
|
||||
|
||||
if (!searchQuery && filter.sortBy === null) {
|
||||
updateFilter("sortBy", initialFilter.sortBy);
|
||||
@@ -39,34 +39,34 @@ export function SearchPlaylist({ searchQuery }: SearchPlaylistProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchEntity
|
||||
<SearchPlaylistQuery["searchPlaylist"]["data"][number]>
|
||||
<SearchEntity<SearchPlaylistQuery["searchPlaylist"]["data"][number]>
|
||||
entity="playlist"
|
||||
searchArgs={{
|
||||
query: searchQuery,
|
||||
sortBy: filter.sortBy,
|
||||
}}
|
||||
fetchResults={async (searchArgs) => {
|
||||
const { data } = await fetchDataClient<SearchPlaylistQuery, SearchPlaylistQueryVariables>(gql`
|
||||
${PlaylistSummaryCard.fragments.playlist}
|
||||
${PlaylistSummaryCard.fragments.showOwner}
|
||||
|
||||
query SearchPlaylist($args: SearchArgs!) {
|
||||
searchPlaylist(args: $args) {
|
||||
data {
|
||||
...PlaylistSummaryCardPlaylist
|
||||
...PlaylistSummaryCardShowOwner
|
||||
const { data } = await fetchDataClient<SearchPlaylistQuery, SearchPlaylistQueryVariables>(
|
||||
gql`
|
||||
${PlaylistSummaryCard.fragments.playlist}
|
||||
${PlaylistSummaryCard.fragments.showOwner}
|
||||
|
||||
query SearchPlaylist($args: SearchArgs!) {
|
||||
searchPlaylist(args: $args) {
|
||||
data {
|
||||
...PlaylistSummaryCardPlaylist
|
||||
...PlaylistSummaryCardShowOwner
|
||||
}
|
||||
nextPage
|
||||
}
|
||||
nextPage
|
||||
}
|
||||
}
|
||||
`, { args: searchArgs });
|
||||
`,
|
||||
{ args: searchArgs },
|
||||
);
|
||||
|
||||
return data.searchPlaylist;
|
||||
}}
|
||||
renderResult={(playlist) => (
|
||||
<PlaylistSummaryCard key={playlist.id} playlist={playlist} showOwner />
|
||||
)}
|
||||
renderResult={(playlist) => <PlaylistSummaryCard key={playlist.id} playlist={playlist} showOwner />}
|
||||
filters={
|
||||
<>
|
||||
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
|
||||
|
||||
@@ -16,7 +16,7 @@ const initialFilter = {
|
||||
};
|
||||
|
||||
interface SearchSeriesProps {
|
||||
searchQuery?: string
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export function SearchSeries({ searchQuery }: SearchSeriesProps) {
|
||||
@@ -24,7 +24,7 @@ export function SearchSeries({ searchQuery }: SearchSeriesProps) {
|
||||
...initialFilter,
|
||||
sortBy: searchQuery ? null : initialFilter.sortBy,
|
||||
});
|
||||
const [ prevSearchQuery, setPrevSearchQuery ] = useState(searchQuery);
|
||||
const [prevSearchQuery, setPrevSearchQuery] = useState(searchQuery);
|
||||
|
||||
if (!searchQuery && filter.sortBy === null) {
|
||||
updateFilter("sortBy", initialFilter.sortBy);
|
||||
@@ -41,8 +41,7 @@ export function SearchSeries({ searchQuery }: SearchSeriesProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchEntity
|
||||
<SearchSeriesQuery["searchSeries"]["data"][number]>
|
||||
<SearchEntity<SearchSeriesQuery["searchSeries"]["data"][number]>
|
||||
entity="series"
|
||||
searchArgs={{
|
||||
query: searchQuery,
|
||||
@@ -52,17 +51,20 @@ export function SearchSeries({ searchQuery }: SearchSeriesProps) {
|
||||
sortBy: filter.sortBy,
|
||||
}}
|
||||
fetchResults={async (searchArgs) => {
|
||||
const { data } = await fetchDataClient<SearchSeriesQuery, SearchSeriesQueryVariables>(gql`
|
||||
query SearchSeries($args: SearchArgs!) {
|
||||
searchSeries(args: $args) {
|
||||
data {
|
||||
slug
|
||||
name
|
||||
const { data } = await fetchDataClient<SearchSeriesQuery, SearchSeriesQueryVariables>(
|
||||
gql`
|
||||
query SearchSeries($args: SearchArgs!) {
|
||||
searchSeries(args: $args) {
|
||||
data {
|
||||
slug
|
||||
name
|
||||
}
|
||||
nextPage
|
||||
}
|
||||
nextPage
|
||||
}
|
||||
}
|
||||
`, { args: searchArgs });
|
||||
`,
|
||||
{ args: searchArgs },
|
||||
);
|
||||
|
||||
return data.searchSeries;
|
||||
}}
|
||||
@@ -71,7 +73,7 @@ export function SearchSeries({ searchQuery }: SearchSeriesProps) {
|
||||
)}
|
||||
filters={
|
||||
<>
|
||||
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
|
||||
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")} />
|
||||
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
|
||||
{searchQuery ? (
|
||||
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
|
||||
|
||||
@@ -17,7 +17,7 @@ const initialFilter = {
|
||||
};
|
||||
|
||||
interface SearchStudioProps {
|
||||
searchQuery?: string
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export function SearchStudio({ searchQuery }: SearchStudioProps) {
|
||||
@@ -25,7 +25,7 @@ export function SearchStudio({ searchQuery }: SearchStudioProps) {
|
||||
...initialFilter,
|
||||
sortBy: searchQuery ? null : initialFilter.sortBy,
|
||||
});
|
||||
const [ prevSearchQuery, setPrevSearchQuery ] = useState(searchQuery);
|
||||
const [prevSearchQuery, setPrevSearchQuery] = useState(searchQuery);
|
||||
|
||||
if (!searchQuery && filter.sortBy === null) {
|
||||
updateFilter("sortBy", initialFilter.sortBy);
|
||||
@@ -42,8 +42,7 @@ export function SearchStudio({ searchQuery }: SearchStudioProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchEntity
|
||||
<SearchStudioQuery["searchStudio"]["data"][number]>
|
||||
<SearchEntity<SearchStudioQuery["searchStudio"]["data"][number]>
|
||||
entity="studio"
|
||||
searchArgs={{
|
||||
query: searchQuery,
|
||||
@@ -53,29 +52,30 @@ export function SearchStudio({ searchQuery }: SearchStudioProps) {
|
||||
sortBy: filter.sortBy,
|
||||
}}
|
||||
fetchResults={async (searchArgs) => {
|
||||
const { data } = await fetchDataClient<SearchStudioQuery, SearchStudioQueryVariables>(gql`
|
||||
${StudioCoverImage.fragments.studio}
|
||||
|
||||
query SearchStudio($args: SearchArgs!) {
|
||||
searchStudio(args: $args) {
|
||||
data {
|
||||
...StudioCoverImageStudio
|
||||
slug
|
||||
name
|
||||
const { data } = await fetchDataClient<SearchStudioQuery, SearchStudioQueryVariables>(
|
||||
gql`
|
||||
${StudioCoverImage.fragments.studio}
|
||||
|
||||
query SearchStudio($args: SearchArgs!) {
|
||||
searchStudio(args: $args) {
|
||||
data {
|
||||
...StudioCoverImageStudio
|
||||
slug
|
||||
name
|
||||
}
|
||||
nextPage
|
||||
}
|
||||
nextPage
|
||||
}
|
||||
}
|
||||
`, { args: searchArgs });
|
||||
`,
|
||||
{ args: searchArgs },
|
||||
);
|
||||
|
||||
return data.searchStudio;
|
||||
}}
|
||||
renderResult={(studio) => (
|
||||
<StudioSummaryCard key={studio.slug} studio={studio}/>
|
||||
)}
|
||||
renderResult={(studio) => <StudioSummaryCard key={studio.slug} studio={studio} />}
|
||||
filters={
|
||||
<>
|
||||
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
|
||||
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")} />
|
||||
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
|
||||
{searchQuery ? (
|
||||
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
|
||||
|
||||
@@ -18,7 +18,7 @@ const initialFilter = {
|
||||
};
|
||||
|
||||
interface SearchThemeProps {
|
||||
searchQuery?: string
|
||||
searchQuery?: string;
|
||||
}
|
||||
|
||||
export function SearchTheme({ searchQuery }: SearchThemeProps) {
|
||||
@@ -26,7 +26,7 @@ export function SearchTheme({ searchQuery }: SearchThemeProps) {
|
||||
...initialFilter,
|
||||
sortBy: searchQuery ? null : initialFilter.sortBy,
|
||||
});
|
||||
const [ prevSearchQuery, setPrevSearchQuery ] = useState(searchQuery);
|
||||
const [prevSearchQuery, setPrevSearchQuery] = useState(searchQuery);
|
||||
|
||||
if (!searchQuery && filter.sortBy === null) {
|
||||
updateFilter("sortBy", initialFilter.sortBy);
|
||||
@@ -43,51 +43,57 @@ export function SearchTheme({ searchQuery }: SearchThemeProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<SearchEntity
|
||||
<SearchThemeQuery["searchTheme"]["data"][number]>
|
||||
<SearchEntity<SearchThemeQuery["searchTheme"]["data"][number]>
|
||||
entity="theme"
|
||||
searchArgs={{
|
||||
query: searchQuery,
|
||||
filters: {
|
||||
has: "song",
|
||||
"song][title-like": filter.firstLetter ? `${filter.firstLetter}%` : null,
|
||||
type: filter.type
|
||||
type: filter.type,
|
||||
},
|
||||
sortBy: filter.sortBy,
|
||||
}}
|
||||
fetchResults={async (searchArgs) => {
|
||||
const { data } = await fetchDataClient<SearchThemeQuery, SearchThemeQueryVariables>(gql`
|
||||
${ThemeSummaryCard.fragments.theme}
|
||||
${ThemeSummaryCard.fragments.expandable}
|
||||
|
||||
query SearchTheme($args: SearchArgs!) {
|
||||
searchTheme(args: $args) {
|
||||
data {
|
||||
...ThemeSummaryCardTheme
|
||||
...ThemeSummaryCardThemeExpandable
|
||||
const { data } = await fetchDataClient<SearchThemeQuery, SearchThemeQueryVariables>(
|
||||
gql`
|
||||
${ThemeSummaryCard.fragments.theme}
|
||||
${ThemeSummaryCard.fragments.expandable}
|
||||
|
||||
query SearchTheme($args: SearchArgs!) {
|
||||
searchTheme(args: $args) {
|
||||
data {
|
||||
...ThemeSummaryCardTheme
|
||||
...ThemeSummaryCardThemeExpandable
|
||||
}
|
||||
nextPage
|
||||
}
|
||||
nextPage
|
||||
}
|
||||
}
|
||||
`, { args: searchArgs });
|
||||
`,
|
||||
{ args: searchArgs },
|
||||
);
|
||||
|
||||
return data.searchTheme;
|
||||
}}
|
||||
renderResult={(theme) => (
|
||||
<ThemeSummaryCard key={`${theme.anime?.slug}-${theme.id}`} theme={theme} expandable/>
|
||||
<ThemeSummaryCard key={`${theme.anime?.slug}-${theme.id}`} theme={theme} expandable />
|
||||
)}
|
||||
filters={
|
||||
<>
|
||||
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")}/>
|
||||
<SearchFilterThemeType value={filter.type} setValue={bindUpdateFilter("type")}/>
|
||||
<SearchFilterFirstLetter value={filter.firstLetter} setValue={bindUpdateFilter("firstLetter")} />
|
||||
<SearchFilterThemeType value={filter.type} setValue={bindUpdateFilter("type")} />
|
||||
<SearchFilterSortBy value={filter.sortBy} setValue={bindUpdateFilter("sortBy")}>
|
||||
{searchQuery ? (
|
||||
<SearchFilterSortBy.Option value={null}>Relevance</SearchFilterSortBy.Option>
|
||||
) : null}
|
||||
<SearchFilterSortBy.Option value="song.title">A ➜ Z</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-song.title">Z ➜ A</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="anime.year,anime.season,song.title">Old ➜ New</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-anime.year,-anime.season,song.title">New ➜ Old</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="anime.year,anime.season,song.title">
|
||||
Old ➜ New
|
||||
</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-anime.year,-anime.season,song.title">
|
||||
New ➜ Old
|
||||
</SearchFilterSortBy.Option>
|
||||
<SearchFilterSortBy.Option value="-created_at">Last Added</SearchFilterSortBy.Option>
|
||||
</SearchFilterSortBy>
|
||||
</>
|
||||
|
||||
@@ -4,27 +4,27 @@ import Head from "next/head";
|
||||
import withBasePath from "@/utils/withBasePath";
|
||||
|
||||
interface SEOProps {
|
||||
title?: string
|
||||
description?: string
|
||||
image?: string
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
export function SEO({
|
||||
title,
|
||||
description = "AnimeThemes is a simple and consistent repository of anime opening and ending themes.",
|
||||
image = withBasePath("/img/logo.svg"),
|
||||
children
|
||||
children,
|
||||
}: PropsWithChildren<SEOProps>) {
|
||||
const titleWithSuffix = title ? `${title} · AnimeThemes` : "AnimeThemes";
|
||||
|
||||
return (
|
||||
<Head>
|
||||
<title key="title">{titleWithSuffix}</title>
|
||||
<meta key="description" name="description" content={description}/>
|
||||
<meta key="og:title" name="og:title" content={title}/>
|
||||
<meta key="og:description" name="og:description" content={description}/>
|
||||
<meta key="og:image" name="og:image" content={image}/>
|
||||
<meta key="og:site_name" name="og:site_name" content="AnimeThemes"/>
|
||||
<meta key="description" name="description" content={description} />
|
||||
<meta key="og:title" name="og:title" content={title} />
|
||||
<meta key="og:description" name="og:description" content={description} />
|
||||
<meta key="og:image" name="og:image" content={image} />
|
||||
<meta key="og:site_name" name="og:site_name" content="AnimeThemes" />
|
||||
{children}
|
||||
</Head>
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { fadeIn } from "@/styles/animations";
|
||||
import { loadingAnimation } from "@/styles/mixins";
|
||||
import theme from "@/theme";
|
||||
|
||||
const StyledSkeleton = styled.div`
|
||||
const StyledSkeleton = styled.div`
|
||||
${loadingAnimation}
|
||||
`;
|
||||
|
||||
@@ -21,22 +21,18 @@ const StyledContent = styled.div<{ style: { "--delay": Property.AnimationDelay }
|
||||
`;
|
||||
|
||||
interface SkeletonProps {
|
||||
children?: ReactNode
|
||||
variant?: "summary-card"
|
||||
delay?: number
|
||||
children?: ReactNode;
|
||||
variant?: "summary-card";
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export function Skeleton({ children, variant, delay = 0 }: SkeletonProps) {
|
||||
if (!children) {
|
||||
switch (variant) {
|
||||
case "summary-card":
|
||||
return <StyledSkeletonSummaryCard/>;
|
||||
return <StyledSkeletonSummaryCard />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledContent style={{ "--delay": `${delay}ms` }}>
|
||||
{children}
|
||||
</StyledContent>
|
||||
);
|
||||
return <StyledContent style={{ "--delay": `${delay}ms` }}>{children}</StyledContent>;
|
||||
}
|
||||
|
||||
@@ -35,30 +35,28 @@ const StyledThumb = styled(RadixSlider.Thumb)`
|
||||
box-shadow: ${theme.shadows.low};
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
&:hover {
|
||||
background-color: ${theme.colors["text-muted"]};
|
||||
}
|
||||
|
||||
|
||||
&:focus:focus-within {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px ${theme.colors["text-primary"]};
|
||||
}
|
||||
`;
|
||||
|
||||
export const Slider = forwardRef<HTMLSpanElement, RadixSlider.SliderProps>(
|
||||
function Slider(props, ref) {
|
||||
const value = (props.value || props.defaultValue) ?? [];
|
||||
export const Slider = forwardRef<HTMLSpanElement, RadixSlider.SliderProps>(function Slider(props, ref) {
|
||||
const value = (props.value || props.defaultValue) ?? [];
|
||||
|
||||
return (
|
||||
<StyledSlider {...props} ref={ref}>
|
||||
<StyledTrack>
|
||||
<StyledRange />
|
||||
</StyledTrack>
|
||||
{value.map((_, i) => (
|
||||
<StyledThumb key={i} />
|
||||
))}
|
||||
</StyledSlider>
|
||||
);
|
||||
}
|
||||
);
|
||||
return (
|
||||
<StyledSlider {...props} ref={ref}>
|
||||
<StyledTrack>
|
||||
<StyledRange />
|
||||
</StyledTrack>
|
||||
{value.map((_, i) => (
|
||||
<StyledThumb key={i} />
|
||||
))}
|
||||
</StyledSlider>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,23 +12,25 @@ import { withHover } from "@/styles/mixins";
|
||||
import theme from "@/theme";
|
||||
|
||||
interface SwitcherContextValue {
|
||||
selectedItem: string | null
|
||||
select?: (value: string | null) => void
|
||||
selectedItem: string | null;
|
||||
select?: (value: string | null) => void;
|
||||
}
|
||||
|
||||
const SwitcherContext = createContext<SwitcherContextValue>({
|
||||
selectedItem: null,
|
||||
select: () => { /* Do nothing. */ }
|
||||
select: () => {
|
||||
/* Do nothing. */
|
||||
},
|
||||
});
|
||||
|
||||
const StyledSwitcher = styled.div`
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
|
||||
|
||||
width: fit-content;
|
||||
border-radius: 2rem;
|
||||
white-space: nowrap;
|
||||
|
||||
|
||||
background-color: ${theme.colors["solid"]};
|
||||
box-shadow: ${theme.shadows.low};
|
||||
|
||||
@@ -38,31 +40,31 @@ const StyledSwitcher = styled.div`
|
||||
`;
|
||||
|
||||
interface StyledButtonProps {
|
||||
$isCircle?: boolean
|
||||
$isSelected?: boolean
|
||||
$isCircle?: boolean;
|
||||
$isSelected?: boolean;
|
||||
}
|
||||
|
||||
const StyledButton = styled.button<StyledButtonProps>`
|
||||
const StyledButton = styled.button<StyledButtonProps>`
|
||||
position: relative;
|
||||
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
padding: ${(props) => props.$isCircle ? "8px" : "8px 16px"};
|
||||
padding: ${(props) => (props.$isCircle ? "8px" : "8px 16px")};
|
||||
aspect-ratio: ${(props) => props.$isCircle && "1 / 1"};
|
||||
|
||||
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1rem;
|
||||
cursor: pointer;
|
||||
color: ${(props) => props.$isSelected ? theme.colors["text-on-primary"] : theme.colors["text-muted"]};
|
||||
|
||||
color: ${(props) => (props.$isSelected ? theme.colors["text-on-primary"] : theme.colors["text-muted"])};
|
||||
|
||||
transition: color 500ms;
|
||||
|
||||
${withHover`
|
||||
color: ${(props) => props.$isSelected ? theme.colors["text-on-primary"] : theme.colors["text"]};;
|
||||
color: ${(props) => (props.$isSelected ? theme.colors["text-on-primary"] : theme.colors["text"])};;
|
||||
transition-duration: 250ms;
|
||||
`}
|
||||
`;
|
||||
@@ -82,33 +84,34 @@ const StyledTop = styled.span`
|
||||
`;
|
||||
|
||||
type SwitcherProps<T extends string | null> = ComponentPropsWithoutRef<typeof StyledSwitcher> & {
|
||||
selectedItem: T
|
||||
onChange?: (value: T) => void
|
||||
children: ReactNode
|
||||
selectedItem: T;
|
||||
onChange?: (value: T) => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Switcher<T extends string | null>({ selectedItem, onChange, children, ...props }: SwitcherProps<T>) {
|
||||
const uniqueId = useMemo(createUniqueId, []);
|
||||
|
||||
const context = useMemo(() => ({
|
||||
selectedItem,
|
||||
select: onChange as (value: string | null) => void
|
||||
}), [onChange, selectedItem]);
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
selectedItem,
|
||||
select: onChange as (value: string | null) => void,
|
||||
}),
|
||||
[onChange, selectedItem],
|
||||
);
|
||||
|
||||
return (
|
||||
<StyledSwitcher {...props}>
|
||||
<SwitcherContext.Provider value={context}>
|
||||
<LayoutGroup id={uniqueId}>
|
||||
{children}
|
||||
</LayoutGroup>
|
||||
<LayoutGroup id={uniqueId}>{children}</LayoutGroup>
|
||||
</SwitcherContext.Provider>
|
||||
</StyledSwitcher>
|
||||
);
|
||||
}
|
||||
|
||||
interface SwitcherOptionProps extends ComponentPropsWithRef<typeof StyledButton> {
|
||||
children: ReactNode
|
||||
value: string
|
||||
children: ReactNode;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function SwitcherOption({ children, value, ...props }: SwitcherOptionProps) {
|
||||
@@ -116,18 +119,13 @@ export function SwitcherOption({ children, value, ...props }: SwitcherOptionProp
|
||||
const isSelected = context.selectedItem === value;
|
||||
|
||||
return (
|
||||
<StyledButton
|
||||
type="button"
|
||||
$isSelected={isSelected}
|
||||
onClick={() => context.select?.(value)}
|
||||
{...props}
|
||||
>
|
||||
<StyledButton type="button" $isSelected={isSelected} onClick={() => context.select?.(value)} {...props}>
|
||||
{isSelected && (
|
||||
<StyledButtonBackground
|
||||
layout
|
||||
layoutId="button-bg"
|
||||
layoutDependency={value}
|
||||
transition={{ duration: 0.250 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
/>
|
||||
)}
|
||||
<StyledTop>{children}</StyledTop>
|
||||
@@ -152,7 +150,7 @@ export function SwitcherReset(props: SwitcherResetProps) {
|
||||
onClick={() => context.select?.(null)}
|
||||
{...props}
|
||||
>
|
||||
<Icon icon={faTimes}/>
|
||||
<Icon icon={faTimes} />
|
||||
</StyledButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,11 +21,11 @@ export const TableRow = styled.div`
|
||||
grid-gap: 16px;
|
||||
align-items: baseline;
|
||||
padding: 8px;
|
||||
|
||||
|
||||
&:not(:last-of-type) {
|
||||
border-bottom: 2px solid ${theme.colors["solid-on-card"]};
|
||||
}
|
||||
|
||||
|
||||
${withHover`
|
||||
background-color: ${theme.colors["solid"]};
|
||||
`}
|
||||
|
||||
@@ -15,58 +15,63 @@ import { either, themeIndexComparator, themeTypeComparator } from "@/utils/compa
|
||||
import createVideoSlug from "@/utils/createVideoSlug";
|
||||
|
||||
export interface ThemeTableProps {
|
||||
themes: Array<ThemeTableThemeFragment>
|
||||
onPlay?(initiatingThemeId: number, entryIndex?: number, videoIndex?: number): void
|
||||
themes: Array<ThemeTableThemeFragment>;
|
||||
onPlay?(initiatingThemeId: number, entryIndex?: number, videoIndex?: number): void;
|
||||
}
|
||||
|
||||
export function ThemeTable({ themes, onPlay }: ThemeTableProps) {
|
||||
const rows = themes
|
||||
.filter((theme) => theme.anime && theme.entries.length && theme.entries[0]?.videos.length)
|
||||
.sort(either(themeTypeComparator).or(themeIndexComparator).chain())
|
||||
.map((theme) => theme.entries.map((entry, entryIndex) => entry.videos.map((video, videoIndex) => {
|
||||
const anime = theme.anime as NonNullable<typeof theme["anime"]>;
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
return (
|
||||
<Link
|
||||
key={anime.slug + videoSlug}
|
||||
href={`/anime/${anime.slug}/${videoSlug}`}
|
||||
passHref
|
||||
legacyBehavior
|
||||
>
|
||||
<TableRow as="a" onClick={()=> onPlay?.(theme.id, entryIndex, videoIndex)}>
|
||||
<TableCell style={{ "--span": (entryIndex || videoIndex) ? 2 : undefined }}>
|
||||
{!videoIndex && (
|
||||
(entry.version ?? 1) > 1 ? (
|
||||
<Text variant="small" color="text-muted">{theme.type}{theme.sequence || null} v{entry.version}</Text>
|
||||
) : (
|
||||
<Text variant="small">{theme.type}{theme.sequence || null}</Text>
|
||||
)
|
||||
)}
|
||||
</TableCell>
|
||||
{(!entryIndex && !videoIndex) && (
|
||||
<TableCell>
|
||||
<SongTitle song={theme.song}/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
{!videoIndex && (
|
||||
<EpisodeTag entry={entry}/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{!videoIndex && (
|
||||
<Row $wrap style={{ "--gap": "8px", "--align-items": "baseline" }}>
|
||||
<ContentWarningTags entry={entry}/>
|
||||
</Row>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<VideoTags video={video}/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Link>
|
||||
);
|
||||
})));
|
||||
.map((theme) =>
|
||||
theme.entries.map((entry, entryIndex) =>
|
||||
entry.videos.map((video, videoIndex) => {
|
||||
const anime = theme.anime as NonNullable<(typeof theme)["anime"]>;
|
||||
const videoSlug = createVideoSlug(theme, entry, video);
|
||||
return (
|
||||
<Link
|
||||
key={anime.slug + videoSlug}
|
||||
href={`/anime/${anime.slug}/${videoSlug}`}
|
||||
passHref
|
||||
legacyBehavior
|
||||
>
|
||||
<TableRow as="a" onClick={() => onPlay?.(theme.id, entryIndex, videoIndex)}>
|
||||
<TableCell style={{ "--span": entryIndex || videoIndex ? 2 : undefined }}>
|
||||
{!videoIndex &&
|
||||
((entry.version ?? 1) > 1 ? (
|
||||
<Text variant="small" color="text-muted">
|
||||
{theme.type}
|
||||
{theme.sequence || null} v{entry.version}
|
||||
</Text>
|
||||
) : (
|
||||
<Text variant="small">
|
||||
{theme.type}
|
||||
{theme.sequence || null}
|
||||
</Text>
|
||||
))}
|
||||
</TableCell>
|
||||
{!entryIndex && !videoIndex && (
|
||||
<TableCell>
|
||||
<SongTitle song={theme.song} />
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>{!videoIndex && <EpisodeTag entry={entry} />}</TableCell>
|
||||
<TableCell>
|
||||
{!videoIndex && (
|
||||
<Row $wrap style={{ "--gap": "8px", "--align-items": "baseline" }}>
|
||||
<ContentWarningTags entry={entry} />
|
||||
</Row>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<VideoTags video={video} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Link>
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<Table style={{ "--columns": "42px 3fr 2fr 2fr 2fr" }}>
|
||||
@@ -77,9 +82,7 @@ export function ThemeTable({ themes, onPlay }: ThemeTableProps) {
|
||||
<TableHeadCell>Content Warning</TableHeadCell>
|
||||
<TableHeadCell>Notes</TableHeadCell>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows}
|
||||
</TableBody>
|
||||
<TableBody>{rows}</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -92,7 +95,7 @@ ThemeTable.fragments = {
|
||||
${EpisodeTag.fragments.entry}
|
||||
${ContentWarningTags.fragments.entry}
|
||||
${VideoTags.fragments.video}
|
||||
|
||||
|
||||
fragment ThemeTableTheme on Theme {
|
||||
...createVideoSlugTheme
|
||||
id
|
||||
@@ -114,5 +117,5 @@ ThemeTable.fragments = {
|
||||
title
|
||||
}
|
||||
}
|
||||
`
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -6,19 +6,19 @@ import { Tag } from "@/components/tag/Tag";
|
||||
import type { ContentWarningTagsEntryFragment } from "@/generated/graphql";
|
||||
|
||||
interface ContentWarningTagsProps {
|
||||
entry: ContentWarningTagsEntryFragment
|
||||
entry: ContentWarningTagsEntryFragment;
|
||||
}
|
||||
|
||||
export function ContentWarningTags({ entry }: ContentWarningTagsProps) {
|
||||
return (
|
||||
<>
|
||||
{entry.spoiler && (
|
||||
<Tag icon={<Icon icon={faExclamationCircle} color="text-warning"/>} textColor="text-warning-muted">
|
||||
<Tag icon={<Icon icon={faExclamationCircle} color="text-warning" />} textColor="text-warning-muted">
|
||||
SPOILER
|
||||
</Tag>
|
||||
)}
|
||||
{entry.nsfw && (
|
||||
<Tag icon={<Icon icon={faExclamationCircle} color="text-warning"/>} textColor="text-warning-muted">
|
||||
<Tag icon={<Icon icon={faExclamationCircle} color="text-warning" />} textColor="text-warning-muted">
|
||||
NSFW
|
||||
</Tag>
|
||||
)}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tag } from "@/components/tag/Tag";
|
||||
import type { EpisodeTagEntryFragment } from "@/generated/graphql";
|
||||
|
||||
interface EpisodeTagProps {
|
||||
entry: EpisodeTagEntryFragment
|
||||
entry: EpisodeTagEntryFragment;
|
||||
}
|
||||
|
||||
export function EpisodeTag({ entry }: EpisodeTagProps) {
|
||||
|
||||
+17
-13
@@ -13,7 +13,7 @@ const StyledTag = styled.span`
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
|
||||
|
||||
& ${Icon} {
|
||||
transform: translateY(0.2em);
|
||||
}
|
||||
@@ -21,25 +21,27 @@ const StyledTag = styled.span`
|
||||
|
||||
const StyledText = styled(Text)`
|
||||
letter-spacing: 0.05rem;
|
||||
|
||||
${(props) => props.hideTextOnMobile && css`
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
display: none;
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.hideTextOnMobile &&
|
||||
css`
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
display: none;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
interface TagProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
icon?: ReactComponentElement<typeof Icon> | IconDefinition
|
||||
children?: ReactNode
|
||||
hideTextOnMobile?: boolean
|
||||
textColor?: keyof Colors
|
||||
icon?: ReactComponentElement<typeof Icon> | IconDefinition;
|
||||
children?: ReactNode;
|
||||
hideTextOnMobile?: boolean;
|
||||
textColor?: keyof Colors;
|
||||
}
|
||||
|
||||
export function Tag({ icon, children, hideTextOnMobile = false, textColor, ...props }: TagProps) {
|
||||
return (
|
||||
<StyledTag {...props}>
|
||||
{!!icon && (isIcon(icon) ? icon : <Icon icon={icon} color="text-disabled"/>)}
|
||||
{!!icon && (isIcon(icon) ? icon : <Icon icon={icon} color="text-disabled" />)}
|
||||
{!!children && (
|
||||
<StyledText variant="small" hideTextOnMobile={hideTextOnMobile} color={textColor}>
|
||||
{children}
|
||||
@@ -49,6 +51,8 @@ export function Tag({ icon, children, hideTextOnMobile = false, textColor, ...pr
|
||||
);
|
||||
}
|
||||
|
||||
function isIcon(value: ReactComponentElement<typeof Icon> | IconDefinition): value is ReactComponentElement<typeof Icon> {
|
||||
function isIcon(
|
||||
value: ReactComponentElement<typeof Icon> | IconDefinition,
|
||||
): value is ReactComponentElement<typeof Icon> {
|
||||
return typeof value === "object" && "type" in value && (value as ReactElement).type === Icon;
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@ import { EpisodeTag } from "@/components/tag/EpisodeTag";
|
||||
import type { ThemeEntryTagsEntryFragment } from "@/generated/graphql";
|
||||
|
||||
type ThemeEntryTagsProps = {
|
||||
entry: ThemeEntryTagsEntryFragment
|
||||
entry: ThemeEntryTagsEntryFragment;
|
||||
};
|
||||
|
||||
export function ThemeEntryTags({ entry }: ThemeEntryTagsProps) {
|
||||
return (
|
||||
<Row style={{ "--gap": "8px", "--align-items": "baseline" }}>
|
||||
<EpisodeTag entry={entry}/>
|
||||
<ContentWarningTags entry={entry}/>
|
||||
<EpisodeTag entry={entry} />
|
||||
<ContentWarningTags entry={entry} />
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -22,10 +22,10 @@ ThemeEntryTags.fragments = {
|
||||
entry: gql`
|
||||
${EpisodeTag.fragments.entry}
|
||||
${ContentWarningTags.fragments.entry}
|
||||
|
||||
|
||||
fragment ThemeEntryTagsEntry on Entry {
|
||||
...EpisodeTagEntry
|
||||
...ContentWarningTagsEntry
|
||||
}
|
||||
`
|
||||
`,
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
faCommentLines,
|
||||
faCommentMusic,
|
||||
faCompactDisc,
|
||||
faEyes
|
||||
faEyes,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import gql from "graphql-tag";
|
||||
|
||||
@@ -21,32 +21,22 @@ const StyledVideoTags = styled(Row)`
|
||||
`;
|
||||
|
||||
interface VideoTagsProps {
|
||||
video: VideoTagsVideoFragment
|
||||
hideTextOnMobile?: boolean
|
||||
video: VideoTagsVideoFragment;
|
||||
hideTextOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export function VideoTags({ video, hideTextOnMobile = false }: VideoTagsProps) {
|
||||
return (
|
||||
<StyledVideoTags>
|
||||
<Tag title="Resolution">
|
||||
{video.resolution}p
|
||||
</Tag>
|
||||
<Tag title="Resolution">{video.resolution}p</Tag>
|
||||
|
||||
{video.nc && (
|
||||
<Tag icon={faAlignSlash} title="No Credits"/>
|
||||
)}
|
||||
{video.nc && <Tag icon={faAlignSlash} title="No Credits" />}
|
||||
|
||||
{video.subbed && (
|
||||
<Tag icon={faCommentLines} title="With Subtitles"/>
|
||||
)}
|
||||
{video.subbed && <Tag icon={faCommentLines} title="With Subtitles" />}
|
||||
|
||||
{video.lyrics && (
|
||||
<Tag icon={faCommentMusic} title="With Lyrics"/>
|
||||
)}
|
||||
{video.lyrics && <Tag icon={faCommentMusic} title="With Lyrics" />}
|
||||
|
||||
{video.uncen && (
|
||||
<Tag icon={faEyes} title="Uncensored"/>
|
||||
)}
|
||||
{video.uncen && <Tag icon={faEyes} title="Uncensored" />}
|
||||
|
||||
{!!video.source && (
|
||||
<Tag icon={faCompactDisc} title="Source">
|
||||
@@ -74,5 +64,5 @@ VideoTags.fragments = {
|
||||
source
|
||||
overlap
|
||||
}
|
||||
`
|
||||
`,
|
||||
};
|
||||
|
||||
+92
-74
@@ -8,92 +8,110 @@ import type { Colors } from "@/theme/colors";
|
||||
|
||||
interface TextProps {
|
||||
variant?: "h1" | "h2" | "h3" | "small" | "code";
|
||||
link?: boolean
|
||||
maxLines?: number
|
||||
noWrap?: boolean | "ellipsis"
|
||||
block?: boolean
|
||||
italics?: boolean
|
||||
weight?: Property.FontWeight
|
||||
color?: keyof Colors
|
||||
as?: string | ReactElement
|
||||
wrapAnywhere?: boolean
|
||||
link?: boolean;
|
||||
maxLines?: number;
|
||||
noWrap?: boolean | "ellipsis";
|
||||
block?: boolean;
|
||||
italics?: boolean;
|
||||
weight?: Property.FontWeight;
|
||||
color?: keyof Colors;
|
||||
as?: string | ReactElement;
|
||||
wrapAnywhere?: boolean;
|
||||
}
|
||||
|
||||
export const Text = styled.span.attrs(getAttributes)<TextProps>`
|
||||
export const Text = styled.span.attrs(getAttributes)<TextProps>`
|
||||
// Reset margin for elements like <p>
|
||||
margin: 0;
|
||||
scroll-margin-top: 4rem;
|
||||
|
||||
${(props) => props.variant === "h1" && css`
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: ${theme.colors["text"]};
|
||||
`}
|
||||
|
||||
${(props) => props.variant === "h2" && css`
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: ${theme.colors["text-muted"]};
|
||||
`}
|
||||
|
||||
${(props) => props.variant === "h3" && css`
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: ${theme.colors["text-disabled"]};
|
||||
`}
|
||||
|
||||
${(props) => props.variant === "small" && css`
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
`}
|
||||
|
||||
${(props) => props.variant === "code" && css`
|
||||
font-family: monospace;
|
||||
line-height: 1.5;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: ${theme.colors["solid"]};
|
||||
box-shadow: ${theme.shadows.low};
|
||||
box-decoration-break: clone;
|
||||
`}
|
||||
|
||||
${(props) => props.link && css`
|
||||
cursor: pointer;
|
||||
color: ${theme.colors["text-primary"]};
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) => props.maxLines && css`
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: ${props.maxLines};
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
`}
|
||||
|
||||
${(props) => props.noWrap && css<TextProps>`
|
||||
white-space: nowrap;
|
||||
|
||||
${(props) => props.noWrap === "ellipsis" && css`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
${(props) =>
|
||||
props.variant === "h1" &&
|
||||
css`
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: ${theme.colors["text"]};
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.variant === "h2" &&
|
||||
css`
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: ${theme.colors["text-muted"]};
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.variant === "h3" &&
|
||||
css`
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: ${theme.colors["text-disabled"]};
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.variant === "small" &&
|
||||
css`
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.variant === "code" &&
|
||||
css`
|
||||
font-family: monospace;
|
||||
line-height: 1.5;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: ${theme.colors["solid"]};
|
||||
box-shadow: ${theme.shadows.low};
|
||||
box-decoration-break: clone;
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.link &&
|
||||
css`
|
||||
cursor: pointer;
|
||||
color: ${theme.colors["text-primary"]};
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.maxLines &&
|
||||
css`
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: ${props.maxLines};
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.noWrap &&
|
||||
css<TextProps>`
|
||||
white-space: nowrap;
|
||||
|
||||
${(props) =>
|
||||
props.noWrap === "ellipsis" &&
|
||||
css`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`}
|
||||
`}
|
||||
`}
|
||||
|
||||
// Apply these styles last, so that props can override variant styles.
|
||||
display: ${(props) => props.block && "block"};
|
||||
|
||||
|
||||
font-style: ${(props) => props.italics && "italic"};
|
||||
font-weight: ${(props) => props.weight};
|
||||
color: ${(props) => props.color && theme.colors[props.color]};
|
||||
overflow-wrap: ${(props) => props.wrapAnywhere ? "anywhere" : "break-word"};
|
||||
overflow-wrap: ${(props) => (props.wrapAnywhere ? "anywhere" : "break-word")};
|
||||
`;
|
||||
|
||||
function getAttributes(props: TextProps) {
|
||||
@@ -102,7 +120,7 @@ function getAttributes(props: TextProps) {
|
||||
}
|
||||
|
||||
return {
|
||||
as: getAs(props.variant)
|
||||
as: getAs(props.variant),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ShowAnnouncements } from "@/utils/settings";
|
||||
const StyledBody = styled.div`
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
|
||||
@media (max-width: ${theme.breakpoints.mobileMax}) {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -25,22 +25,23 @@ const StyledAnnouncements = styled.div`
|
||||
|
||||
export function AnnouncementToast() {
|
||||
const { closeToast } = useToasts();
|
||||
const [ announcements, setAnnouncements ] = useState<Array<Announcement>>([]);
|
||||
const [ showAnnouncements ] = useSetting(ShowAnnouncements);
|
||||
const [announcements, setAnnouncements] = useState<Array<Announcement>>([]);
|
||||
const [showAnnouncements] = useSetting(ShowAnnouncements);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
if (showAnnouncements !== ShowAnnouncements.DISABLED) {
|
||||
fetchAnnouncements()
|
||||
.then((announcements) => {
|
||||
if (!cancelled) {
|
||||
setAnnouncements(announcements);
|
||||
}
|
||||
});
|
||||
fetchAnnouncements().then((announcements) => {
|
||||
if (!cancelled) {
|
||||
setAnnouncements(announcements);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [showAnnouncements]);
|
||||
|
||||
if (!announcements.length) {
|
||||
@@ -52,7 +53,7 @@ export function AnnouncementToast() {
|
||||
<StyledBody>
|
||||
<StyledAnnouncements>
|
||||
{announcements.map((announcement) => (
|
||||
<Text key={announcement.id} as="p" dangerouslySetInnerHTML={{ __html: announcement.content }}/>
|
||||
<Text key={announcement.id} as="p" dangerouslySetInnerHTML={{ __html: announcement.content }} />
|
||||
))}
|
||||
</StyledAnnouncements>
|
||||
<Text color="text-disabled">(Click to dismiss.)</Text>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Toast } from "@/components/toast/Toast";
|
||||
import { SongTitle } from "@/components/utils/SongTitle";
|
||||
|
||||
interface PlaylistAddToastProps {
|
||||
theme: Exclude<FetchThemeSummaryCardData, null>
|
||||
theme: Exclude<FetchThemeSummaryCardData, null>;
|
||||
}
|
||||
|
||||
export function PlaylistAddToast({ theme }: PlaylistAddToastProps) {
|
||||
@@ -16,7 +16,7 @@ export function PlaylistAddToast({ theme }: PlaylistAddToastProps) {
|
||||
<Toast as="a" hoverable>
|
||||
<Row $wrap style={{ "--justify-content": "space-between", "--gap": "8px" }}>
|
||||
<span>
|
||||
<SongTitle song={theme.song}/> was added to the playlist!
|
||||
<SongTitle song={theme.song} /> was added to the playlist!
|
||||
</span>
|
||||
<Text color="text-disabled">(Click to view playlist.)</Text>
|
||||
</Row>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user