feat: added reject image and migrated to tailwindcss

feat: made it smartphone friendly
This commit is contained in:
2026-04-14 00:16:59 +09:00
parent 3582fca2d9
commit abf350aa6b
35 changed files with 788 additions and 1107 deletions

View File

@@ -1,2 +1,2 @@
APP_VERSION=1.0.2 APP_VERSION=1.0.3
docker buildx build --platform linux/arm64 -f nextjs/Dockerfile -t cfc-web:$APP_VERSION . docker buildx build --platform linux/arm64 -f nextjs/Dockerfile -t cfc-web:$APP_VERSION .

View File

@@ -7,9 +7,6 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const nextConfig = { const nextConfig = {
output: 'standalone', output: 'standalone',
outputFileTracingRoot: path.join(__dirname, '..'), outputFileTracingRoot: path.join(__dirname, '..'),
sassOptions: {
includePaths: ['src/styles'],
},
}; };
export default nextConfig; export default nextConfig;

View File

@@ -17,17 +17,18 @@
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-i18next": "^14.1.0", "react-i18next": "^14.1.0",
"react-youtube": "^10.1.0", "react-youtube": "^10.1.0"
"semantic-ui-css": "^2.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/node": "^20.14.10", "@types/node": "^20.14.10",
"@types/react": "^18.3.3", "@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"autoprefixer": "^10.4.27",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-config-next": "^15.5.14", "eslint-config-next": "^15.5.14",
"sass": "^1.77.8", "postcss": "^8.4.31",
"tailwindcss": "^3.4.19",
"typescript": "^5.5.4" "typescript": "^5.5.4"
} }
} }

6
nextjs/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,12 +1,12 @@
import style from '@/styles/web.module.scss'; import { guideBody } from '@/lib/siteContentClasses';
export default function AboutPage() { export default function AboutPage() {
return ( return (
<div className={style.guideBody}> <div className={guideBody}>
<h2>About</h2> <h2 className="text-xl sm:text-2xl">About</h2>
<hr /> <hr />
<div className={style.aboutNoticeBlock}> <div className="relative mx-auto my-5 min-h-[400px] w-full max-w-[980px] bg-[url(https://static.catherine-fc.com/media/playerbg.png)] [background-repeat:no-repeat] [background-position:center] [background-size:100%_100%] p-6 font-mplus sm:p-10 md:h-[800px] md:min-h-0 md:bg-[length:980px_800px] md:p-[50px]">
<br /> <br className="hidden sm:block" />
<p> <p>
Covid-19 Covid-19
@@ -22,9 +22,14 @@ export default function AboutPage() {
</p> </p>
<p></p> <p></p>
<div className={style.sheepAbout} /> <div className="relative mt-8 h-[200px] w-[120px] max-w-full bg-[url(https://static.catherine-fc.com/media/title_sheep.png)] bg-contain bg-left [background-repeat:no-repeat] sm:float-left sm:mt-[70px] sm:h-[280px] sm:w-[160px] md:h-[350px] md:w-[207px]" />
<div className={style.aboutContactBox}> <div className="relative clear-both m-5 inline-block h-32 w-32 max-w-full bg-[url(https://static.catherine-fc.com/media/letterbox.png)] bg-[length:128px_128px] [background-repeat:no-repeat] sm:clear-none">
<a href="https://forms.gle/Dn4p7cFEPLK1zcTz5" rel="noopener noreferrer" target="_blank"> <a
className="block h-32 w-32 min-h-[44px] min-w-[44px]"
href="https://forms.gle/Dn4p7cFEPLK1zcTz5"
rel="noopener noreferrer"
target="_blank"
>
&nbsp; &nbsp;
</a> </a>
</div> </div>

View File

@@ -1,27 +1,41 @@
import style from '@/styles/web.module.scss';
export default function HomePage() { export default function HomePage() {
return ( return (
<div className={style.mainBody}> <div className="mx-auto my-5 flex w-full max-w-[980px] flex-col items-center justify-center px-4 sm:px-5 md:px-0">
<div className={style.evojapan2026} /> <div className="mx-auto aspect-[500/320] w-full max-w-[500px] bg-[url(https://static.catherine-fc.com/media/reject_cfc.jpeg)] bg-contain bg-center [background-repeat:no-repeat]" />
<div className={style.padding} /> <div className="mx-auto aspect-[500/471] w-full max-w-[500px] bg-[url(https://static.catherine-fc.com/media/evo_logo_2026_shadow_drop.png)] bg-contain bg-center [background-repeat:no-repeat]" />
<div className={style.evojapan2023kaisaikettei} /> <div className="mx-auto h-12 w-full max-w-[900px] sm:h-[100px]" />
<div className={style.padding} /> <div className="mx-auto aspect-[846/285] w-full max-w-[846px] bg-[url(https://static.catherine-fc.com/media/kaisaikettei3.png)] bg-contain bg-center" />
<div className={style.group}> <div className="mx-auto h-12 w-full max-w-[900px] sm:h-[100px]" />
<div className={style.evojapan2023catherinetonamel}> <div className="mx-auto flex w-full max-w-[900px] flex-col items-stretch gap-4 bg-contain [background-repeat:no-repeat] md:flex-row md:gap-0">
<a href="https://tonamel.com/competition/u2GRP" rel="noopener noreferrer" target="_blank"> <div className="flex w-full justify-center md:w-auto md:flex-1">
&nbsp; <div className="bg-[url(https://static.catherine-fc.com/media/catherine_logo_classic.png)] bg-contain bg-center [background-repeat:no-repeat]">
</a>
</div>
<div className={style.evojapan2023catherinefullbodytonamel}>
<a href="https://tonamel.com/competition/BxuNE" rel="noopener noreferrer" target="_blank">
&nbsp;
</a>
</div>
</div>
<div className={style.padding} />
<div className={style.twitchHome}>
<a <a
className="mx-auto block aspect-[412/389] w-full max-w-[412px] min-h-[200px]"
href="https://tonamel.com/competition/u2GRP"
rel="noopener noreferrer"
target="_blank"
>
&nbsp;
</a>
</div>
</div>
<div className="flex w-full justify-center md:w-auto md:flex-1">
<div className="bg-[url(https://static.catherine-fc.com/media/catherine_fullbody_logo.png)] bg-contain bg-center [background-repeat:no-repeat]">
<a
className="mx-auto block aspect-[517/319] w-full max-w-[517px] min-h-[180px]"
href="https://tonamel.com/competition/BxuNE"
rel="noopener noreferrer"
target="_blank"
>
&nbsp;
</a>
</div>
</div>
</div>
<div className="mx-auto h-12 w-full max-w-[900px] sm:h-[100px]" />
<div className="mx-auto h-[70px] w-[113px] bg-[url(https://static.catherine-fc.com/media/twitch.png)] bg-contain [background-repeat:no-repeat]">
<a
className="block h-[70px] w-[113px]"
href="https://www.twitch.tv/catherine_faito_crab" href="https://www.twitch.tv/catherine_faito_crab"
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"

View File

@@ -1,9 +1,35 @@
import { redirect } from 'next/navigation';
import { PlayersList } from '@/components/PlayersList'; import { PlayersList } from '@/components/PlayersList';
import { getAllPlayers } from '@/lib/data'; import { getAllPlayersPaged, PLAYERS_PAGE_SIZE } from '@/lib/data';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export default async function PlayersPage() { type PageProps = {
const players = await getAllPlayers(); searchParams: Promise<{ page?: string }>;
return <PlayersList players={players} />; };
export default async function PlayersPage({ searchParams }: PageProps) {
const { page: pageRaw } = await searchParams;
const parsed = parseInt(pageRaw ?? '1', 10);
const page = Number.isFinite(parsed) && parsed >= 1 ? Math.floor(parsed) : 1;
const { players, total } = await getAllPlayersPaged(page);
const totalPages = Math.max(1, Math.ceil(total / PLAYERS_PAGE_SIZE));
if (page > totalPages) {
redirect(totalPages <= 1 ? '/players' : `/players?page=${totalPages}`);
}
return (
<PlayersList
players={players}
pagination={{
page,
total,
pageSize: PLAYERS_PAGE_SIZE,
basePath: '/players',
}}
/>
);
} }

View File

@@ -1,19 +1,43 @@
import { notFound } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import { PlayersList } from '@/components/PlayersList'; import { PlayersList } from '@/components/PlayersList';
import { getPlayersForTournament } from '@/lib/data'; import { getPlayersForTournamentPaged, PLAYERS_PAGE_SIZE } from '@/lib/data';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
type PageProps = { type PageProps = {
params: Promise<{ tournament_key: string }>; params: Promise<{ tournament_key: string }>;
searchParams: Promise<{ page?: string }>;
}; };
export default async function TournamentPlayersPage({ params }: PageProps) { export default async function TournamentPlayersPage({ params, searchParams }: PageProps) {
const { tournament_key } = await params; const { tournament_key } = await params;
const players = await getPlayersForTournament(tournament_key); const { page: pageRaw } = await searchParams;
if (players === null) { const parsed = parseInt(pageRaw ?? '1', 10);
const page = Number.isFinite(parsed) && parsed >= 1 ? Math.floor(parsed) : 1;
const result = await getPlayersForTournamentPaged(tournament_key, page);
if (result === null) {
notFound(); notFound();
} }
return <PlayersList players={players} />;
const { players, total } = result;
const totalPages = Math.max(1, Math.ceil(total / PLAYERS_PAGE_SIZE));
const basePath = `/tournaments/${encodeURIComponent(tournament_key)}/players`;
if (page > totalPages) {
redirect(totalPages <= 1 ? basePath : `${basePath}?page=${totalPages}`);
}
return (
<PlayersList
players={players}
pagination={{
page,
total,
pageSize: PLAYERS_PAGE_SIZE,
basePath,
}}
/>
);
} }

View File

@@ -1,9 +1,9 @@
import style from '@/styles/web.module.scss'; import { mainBody } from '@/lib/siteContentClasses';
export default function ScoreboardPage() { export default function ScoreboardPage() {
return ( return (
<div className={style.mainBody}> <div className={mainBody}>
<div className={style.scoreboardImage} /> <div className="aspect-[567/485] w-full max-w-[567px] bg-[url(https://static.catherine-fc.com/media/scoreboard.png)] bg-contain bg-center [background-repeat:no-repeat]" />
</div> </div>
); );
} }

View File

@@ -4,7 +4,20 @@ import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import style from '@/styles/admin.module.scss'; import {
btn,
btnDanger,
card,
error as errorClass,
formWide,
hr,
input,
label,
rowActions,
sectionBlock,
sectionTitle,
title,
} from '@/lib/adminUi';
type ArchiveRow = { type ArchiveRow = {
id: number; id: number;
@@ -16,7 +29,7 @@ export function ArchiveAdminClient() {
const [items, setItems] = useState<ArchiveRow[]>([]); const [items, setItems] = useState<ArchiveRow[]>([]);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [title, setTitle] = useState(''); const [titleVal, setTitleVal] = useState('');
const [youtubeId, setYoutubeId] = useState(''); const [youtubeId, setYoutubeId] = useState('');
const load = useCallback(async () => { const load = useCallback(async () => {
@@ -75,70 +88,68 @@ export function ArchiveAdminClient() {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
body: JSON.stringify({ title, youtube_id: youtubeId }), body: JSON.stringify({ title: titleVal, youtube_id: youtubeId }),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
setError((data as { error?: string }).error ?? 'Create failed'); setError((data as { error?: string }).error ?? 'Create failed');
return; return;
} }
setTitle(''); setTitleVal('');
setYoutubeId(''); setYoutubeId('');
await load(); await load();
} }
return ( return (
<div className={style.card}> <div className={card}>
<p> <p>
<Link href="/admin"> Dashboard</Link> <Link href="/admin"> Dashboard</Link>
</p> </p>
<h1 className={style.title}>Archive</h1> <h1 className={title}>Archive</h1>
{error ? <p className={style.error}>{error}</p> : null} {error ? <p className={errorClass}>{error}</p> : null}
{loading ? <p>Loading</p> : null} {loading ? <p>Loading</p> : null}
{!loading && {!loading &&
items.map((row) => ( items.map((row) => (
<div key={row.id} style={{ marginBottom: 24, paddingBottom: 16, borderBottom: '1px solid #eee' }}> <div key={row.id} className={sectionBlock}>
<label className={style.label}> <label className={label}>
title title
<input <input
className={style.input} className={input}
value={row.title} value={row.title}
onChange={(e) => updateLocal(row.id, { title: e.target.value })} onChange={(e) => updateLocal(row.id, { title: e.target.value })}
/> />
</label> </label>
<label className={style.label}> <label className={label}>
youtube_id youtube_id
<input <input
className={style.input} className={input}
value={row.youtube_id} value={row.youtube_id}
onChange={(e) => updateLocal(row.id, { youtube_id: e.target.value })} onChange={(e) => updateLocal(row.id, { youtube_id: e.target.value })}
/> />
</label> </label>
<div className={style.rowActions}> <div className={rowActions}>
<button type="button" className={style.btn} onClick={() => save(row)}> <button type="button" className={btn} onClick={() => save(row)}>
Save Save
</button> </button>
<button type="button" className={style.btnDanger} onClick={() => remove(row.id)}> <button type="button" className={btnDanger} onClick={() => remove(row.id)}>
Delete Delete
</button> </button>
</div> </div>
</div> </div>
))} ))}
<hr className={style.hr} /> <hr className={hr} />
<h2 className={style.title} style={{ fontSize: '1.1rem' }}> <h2 className={sectionTitle}>New entry</h2>
New entry <form className={formWide} onSubmit={add}>
</h2> <label className={label}>
<form className={style.form} style={{ maxWidth: 480 }} onSubmit={add}>
<label className={style.label}>
title title
<input className={style.input} value={title} onChange={(e) => setTitle(e.target.value)} required /> <input className={input} value={titleVal} onChange={(e) => setTitleVal(e.target.value)} required />
</label> </label>
<label className={style.label}> <label className={label}>
youtube_id youtube_id
<input className={style.input} value={youtubeId} onChange={(e) => setYoutubeId(e.target.value)} required /> <input className={input} value={youtubeId} onChange={(e) => setYoutubeId(e.target.value)} required />
</label> </label>
<button className={style.btn} type="submit"> <button className={btn} type="submit">
Add Add
</button> </button>
</form> </form>

View File

@@ -4,7 +4,21 @@ import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import style from '@/styles/admin.module.scss'; import {
btn,
btnDanger,
card,
error as errorClass,
formWide,
hr,
input,
label,
rowActions,
sectionBlock,
sectionTitle,
textarea,
title,
} from '@/lib/adminUi';
type GuideRow = { type GuideRow = {
id: number; id: number;
@@ -13,11 +27,13 @@ type GuideRow = {
youtube_id: string; youtube_id: string;
}; };
const field = `${input} ${textarea}`;
export function GuideAdminClient() { export function GuideAdminClient() {
const [items, setItems] = useState<GuideRow[]>([]); const [items, setItems] = useState<GuideRow[]>([]);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [title, setTitle] = useState(''); const [titleVal, setTitleVal] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [youtubeId, setYoutubeId] = useState(''); const [youtubeId, setYoutubeId] = useState('');
@@ -81,87 +97,81 @@ export function GuideAdminClient() {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
body: JSON.stringify({ title, description, youtube_id: youtubeId }), body: JSON.stringify({ title: titleVal, description, youtube_id: youtubeId }),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
setError((data as { error?: string }).error ?? 'Create failed'); setError((data as { error?: string }).error ?? 'Create failed');
return; return;
} }
setTitle(''); setTitleVal('');
setDescription(''); setDescription('');
setYoutubeId(''); setYoutubeId('');
await load(); await load();
} }
return ( return (
<div className={style.card}> <div className={card}>
<p> <p>
<Link href="/admin"> Dashboard</Link> <Link href="/admin"> Dashboard</Link>
</p> </p>
<h1 className={style.title}>Guide</h1> <h1 className={title}>Guide</h1>
{error ? <p className={style.error}>{error}</p> : null} {error ? <p className={errorClass}>{error}</p> : null}
{loading ? <p>Loading</p> : null} {loading ? <p>Loading</p> : null}
{!loading && {!loading &&
items.map((row) => ( items.map((row) => (
<div key={row.id} style={{ marginBottom: 24, paddingBottom: 16, borderBottom: '1px solid #eee' }}> <div key={row.id} className={sectionBlock}>
<label className={style.label}> <label className={label}>
title title
<input <input
className={style.input} className={input}
value={row.title} value={row.title}
onChange={(e) => updateLocal(row.id, { title: e.target.value })} onChange={(e) => updateLocal(row.id, { title: e.target.value })}
/> />
</label> </label>
<label className={style.label}> <label className={label}>
description description
<textarea <textarea
className={`${style.input} ${style.textarea}`} className={field}
value={row.description ?? ''} value={row.description ?? ''}
onChange={(e) => updateLocal(row.id, { description: e.target.value })} onChange={(e) => updateLocal(row.id, { description: e.target.value })}
/> />
</label> </label>
<label className={style.label}> <label className={label}>
youtube_id youtube_id
<input <input
className={style.input} className={input}
value={row.youtube_id} value={row.youtube_id}
onChange={(e) => updateLocal(row.id, { youtube_id: e.target.value })} onChange={(e) => updateLocal(row.id, { youtube_id: e.target.value })}
/> />
</label> </label>
<div className={style.rowActions}> <div className={rowActions}>
<button type="button" className={style.btn} onClick={() => save(row)}> <button type="button" className={btn} onClick={() => save(row)}>
Save Save
</button> </button>
<button type="button" className={style.btnDanger} onClick={() => remove(row.id)}> <button type="button" className={btnDanger} onClick={() => remove(row.id)}>
Delete Delete
</button> </button>
</div> </div>
</div> </div>
))} ))}
<hr className={style.hr} /> <hr className={hr} />
<h2 className={style.title} style={{ fontSize: '1.1rem' }}> <h2 className={sectionTitle}>New entry</h2>
New entry <form className={formWide} onSubmit={add}>
</h2> <label className={label}>
<form className={style.form} style={{ maxWidth: 480 }} onSubmit={add}>
<label className={style.label}>
title title
<input className={style.input} value={title} onChange={(e) => setTitle(e.target.value)} required /> <input className={input} value={titleVal} onChange={(e) => setTitleVal(e.target.value)} required />
</label> </label>
<label className={style.label}> <label className={label}>
description description
<textarea <textarea className={field} value={description} onChange={(e) => setDescription(e.target.value)} />
className={`${style.input} ${style.textarea}`}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</label> </label>
<label className={style.label}> <label className={label}>
youtube_id youtube_id
<input className={style.input} value={youtubeId} onChange={(e) => setYoutubeId(e.target.value)} required /> <input className={input} value={youtubeId} onChange={(e) => setYoutubeId(e.target.value)} required />
</label> </label>
<button className={style.btn} type="submit"> <button className={btn} type="submit">
Add Add
</button> </button>
</form> </form>

View File

@@ -1,8 +1,8 @@
import Link from 'next/link'; import Link from 'next/link';
import { LogoutButton } from '@/components/admin/LogoutButton'; import { LogoutButton } from '@/components/admin/LogoutButton';
import { card, error, sub, title } from '@/lib/adminUi';
import { getSessionUser } from '@/lib/auth/session'; import { getSessionUser } from '@/lib/auth/session';
import style from '@/styles/admin.module.scss';
export default async function AdminDashboardPage() { export default async function AdminDashboardPage() {
const user = await getSessionUser(); const user = await getSessionUser();
@@ -11,37 +11,47 @@ export default async function AdminDashboardPage() {
} }
return ( return (
<div className={style.card}> <div className={card}>
<h1 className={style.title}>Admin</h1> <h1 className={title}>Admin</h1>
<p className={style.sub}> <p className={sub}>
Signed in as <strong>{user.email}</strong> Signed in as <strong>{user.email}</strong>
{user.is_admin ? ' (administrator)' : ''} {user.is_admin ? ' (administrator)' : ''}
</p> </p>
{!user.is_admin && ( {!user.is_admin && (
<p className={style.error}> <p className={error}>
You do not have permission to edit content. Ask an administrator to grant admin access. You do not have permission to edit content. Ask an administrator to grant admin access.
</p> </p>
)} )}
{user.is_admin && ( {user.is_admin && (
<ul style={{ lineHeight: 1.8 }}> <ul className="leading-relaxed">
<li> <li>
<Link href="/admin/users">Users (approval &amp; roles)</Link> <Link className="text-blue-600 hover:underline" href="/admin/users">
Users (approval &amp; roles)
</Link>
</li> </li>
<li> <li>
<Link href="/admin/players">Players</Link> <Link className="text-blue-600 hover:underline" href="/admin/players">
Players
</Link>
</li> </li>
<li> <li>
<Link href="/admin/guide">Guide</Link> <Link className="text-blue-600 hover:underline" href="/admin/guide">
Guide
</Link>
</li> </li>
<li> <li>
<Link href="/admin/archive">Archive</Link> <Link className="text-blue-600 hover:underline" href="/admin/archive">
Archive
</Link>
</li> </li>
<li> <li>
<Link href="/admin/qa">Q&amp;A</Link> <Link className="text-blue-600 hover:underline" href="/admin/qa">
Q&amp;A
</Link>
</li> </li>
</ul> </ul>
)} )}
<div style={{ marginTop: 16 }}> <div className="mt-4">
<LogoutButton /> <LogoutButton />
</div> </div>
</div> </div>

View File

@@ -4,7 +4,22 @@ import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import style from '@/styles/admin.module.scss'; import {
btn,
btnDanger,
card,
error as errorClass,
formWide,
hr,
input,
label,
metaLine,
rowActions,
sectionBlock,
sectionTitle,
textarea,
title,
} from '@/lib/adminUi';
type Char = { id: number; name_id: string; name: string; name_jp: string }; type Char = { id: number; name_id: string; name: string; name_jp: string };
type PlayerRow = { type PlayerRow = {
@@ -18,6 +33,8 @@ type PlayerRow = {
name_jp?: string; name_jp?: string;
}; };
const field = `${input} ${textarea}`;
export function PlayersAdminClient() { export function PlayersAdminClient() {
const [players, setPlayers] = useState<PlayerRow[]>([]); const [players, setPlayers] = useState<PlayerRow[]>([]);
const [characters, setCharacters] = useState<Char[]>([]); const [characters, setCharacters] = useState<Char[]>([]);
@@ -129,60 +146,57 @@ export function PlayersAdminClient() {
} }
return ( return (
<div className={style.card}> <div className={card}>
<p> <p>
<Link href="/admin"> Dashboard</Link> <Link href="/admin"> Dashboard</Link>
</p> </p>
<h1 className={style.title}>Players</h1> <h1 className={title}>Players</h1>
{error ? <p className={style.error}>{error}</p> : null} {error ? <p className={errorClass}>{error}</p> : null}
{loading ? <p>Loading</p> : null} {loading ? <p>Loading</p> : null}
{!loading && ( {!loading && (
<> <>
{players.map((p) => ( {players.map((p) => (
<div <div key={p.id} className={sectionBlock}>
key={p.id} <p className={metaLine}>
style={{ marginBottom: 24, paddingBottom: 16, borderBottom: '1px solid #eee' }}
>
<p style={{ margin: '0 0 8px', fontSize: '0.85rem', color: '#666' }}>
id: {p.id} id: {p.id}
{p.name_jp ? ` · ${p.name_jp}` : ''} {p.name_jp ? ` · ${p.name_jp}` : ''}
</p> </p>
<label className={style.label}> <label className={label}>
player_key player_key
<input <input
className={style.input} className={input}
value={p.player_key} value={p.player_key}
onChange={(e) => updateLocal(p.id, { player_key: e.target.value })} onChange={(e) => updateLocal(p.id, { player_key: e.target.value })}
/> />
</label> </label>
<label className={style.label}> <label className={label}>
player_name player_name
<input <input
className={style.input} className={input}
value={p.player_name} value={p.player_name}
onChange={(e) => updateLocal(p.id, { player_name: e.target.value })} onChange={(e) => updateLocal(p.id, { player_name: e.target.value })}
/> />
</label> </label>
<label className={style.label}> <label className={label}>
description description
<textarea <textarea
className={`${style.input} ${style.textarea}`} className={field}
value={p.description ?? ''} value={p.description ?? ''}
onChange={(e) => updateLocal(p.id, { description: e.target.value })} onChange={(e) => updateLocal(p.id, { description: e.target.value })}
/> />
</label> </label>
<label className={style.label}> <label className={label}>
image URL (optional) image URL (optional)
<input <input
className={style.input} className={input}
value={p.image ?? ''} value={p.image ?? ''}
onChange={(e) => updateLocal(p.id, { image: e.target.value || null })} onChange={(e) => updateLocal(p.id, { image: e.target.value || null })}
/> />
</label> </label>
<label className={style.label}> <label className={label}>
character character
<select <select
className={style.input} className={input}
value={p.character_id != null && p.character_id !== '' ? String(p.character_id) : ''} value={p.character_id != null && p.character_id !== '' ? String(p.character_id) : ''}
onChange={(e) => updateLocal(p.id, { character_id: e.target.value })} onChange={(e) => updateLocal(p.id, { character_id: e.target.value })}
required required
@@ -195,46 +209,40 @@ export function PlayersAdminClient() {
))} ))}
</select> </select>
</label> </label>
<div className={style.rowActions}> <div className={rowActions}>
<button type="button" className={style.btn} onClick={() => save(p)}> <button type="button" className={btn} onClick={() => save(p)}>
Save Save
</button> </button>
<button type="button" className={style.btnDanger} onClick={() => remove(p.id)}> <button type="button" className={btnDanger} onClick={() => remove(p.id)}>
Delete Delete
</button> </button>
</div> </div>
</div> </div>
))} ))}
<hr className={style.hr} /> <hr className={hr} />
<h2 className={style.title} style={{ fontSize: '1.1rem' }}> <h2 className={sectionTitle}>Add player</h2>
Add player <form className={formWide} onSubmit={addPlayer}>
</h2> <label className={label}>
<form className={style.form} style={{ maxWidth: 480 }} onSubmit={addPlayer}>
<label className={style.label}>
player_key player_key
<input className={style.input} value={playerKey} onChange={(e) => setPlayerKey(e.target.value)} required /> <input className={input} value={playerKey} onChange={(e) => setPlayerKey(e.target.value)} required />
</label> </label>
<label className={style.label}> <label className={label}>
player_name player_name
<input className={style.input} value={playerName} onChange={(e) => setPlayerName(e.target.value)} required /> <input className={input} value={playerName} onChange={(e) => setPlayerName(e.target.value)} required />
</label> </label>
<label className={style.label}> <label className={label}>
description description
<textarea <textarea className={field} value={description} onChange={(e) => setDescription(e.target.value)} />
className={`${style.input} ${style.textarea}`}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</label> </label>
<label className={style.label}> <label className={label}>
image URL (optional) image URL (optional)
<input className={style.input} value={image} onChange={(e) => setImage(e.target.value)} /> <input className={input} value={image} onChange={(e) => setImage(e.target.value)} />
</label> </label>
<label className={style.label}> <label className={label}>
character character
<select <select
className={style.input} className={input}
value={characterId} value={characterId}
onChange={(e) => setCharacterId(e.target.value)} onChange={(e) => setCharacterId(e.target.value)}
required required
@@ -247,7 +255,7 @@ export function PlayersAdminClient() {
))} ))}
</select> </select>
</label> </label>
<button className={style.btn} type="submit"> <button className={btn} type="submit">
Add Add
</button> </button>
</form> </form>

View File

@@ -4,7 +4,21 @@ import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import style from '@/styles/admin.module.scss'; import {
btn,
btnDanger,
card,
error as errorClass,
formWide,
hr,
input,
label,
rowActions,
sectionBlock,
sectionTitle,
textarea,
title,
} from '@/lib/adminUi';
type ContactRow = { type ContactRow = {
id: number; id: number;
@@ -12,6 +26,8 @@ type ContactRow = {
answer: string; answer: string;
}; };
const field = `${input} ${textarea}`;
export function QaAdminClient() { export function QaAdminClient() {
const [items, setItems] = useState<ContactRow[]>([]); const [items, setItems] = useState<ContactRow[]>([]);
const [error, setError] = useState(''); const [error, setError] = useState('');
@@ -88,67 +104,65 @@ export function QaAdminClient() {
} }
return ( return (
<div className={style.card}> <div className={card}>
<p> <p>
<Link href="/admin"> Dashboard</Link> <Link href="/admin"> Dashboard</Link>
</p> </p>
<h1 className={style.title}>Q&amp;A</h1> <h1 className={title}>Q&amp;A</h1>
{error ? <p className={style.error}>{error}</p> : null} {error ? <p className={errorClass}>{error}</p> : null}
{loading ? <p>Loading</p> : null} {loading ? <p>Loading</p> : null}
{!loading && {!loading &&
items.map((row) => ( items.map((row) => (
<div key={row.id} style={{ marginBottom: 24, paddingBottom: 16, borderBottom: '1px solid #eee' }}> <div key={row.id} className={sectionBlock}>
<label className={style.label}> <label className={label}>
question question
<textarea <textarea
className={`${style.input} ${style.textarea}`} className={field}
value={row.question} value={row.question}
onChange={(e) => updateLocal(row.id, { question: e.target.value })} onChange={(e) => updateLocal(row.id, { question: e.target.value })}
/> />
</label> </label>
<label className={style.label}> <label className={label}>
answer answer
<textarea <textarea
className={`${style.input} ${style.textarea}`} className={field}
value={row.answer} value={row.answer}
onChange={(e) => updateLocal(row.id, { answer: e.target.value })} onChange={(e) => updateLocal(row.id, { answer: e.target.value })}
/> />
</label> </label>
<div className={style.rowActions}> <div className={rowActions}>
<button type="button" className={style.btn} onClick={() => save(row)}> <button type="button" className={btn} onClick={() => save(row)}>
Save Save
</button> </button>
<button type="button" className={style.btnDanger} onClick={() => remove(row.id)}> <button type="button" className={btnDanger} onClick={() => remove(row.id)}>
Delete Delete
</button> </button>
</div> </div>
</div> </div>
))} ))}
<hr className={style.hr} /> <hr className={hr} />
<h2 className={style.title} style={{ fontSize: '1.1rem' }}> <h2 className={sectionTitle}>New Q&amp;A</h2>
New Q&amp;A <form className={formWide} onSubmit={add}>
</h2> <label className={label}>
<form className={style.form} style={{ maxWidth: 480 }} onSubmit={add}>
<label className={style.label}>
question question
<textarea <textarea
className={`${style.input} ${style.textarea}`} className={field}
value={question} value={question}
onChange={(e) => setQuestion(e.target.value)} onChange={(e) => setQuestion(e.target.value)}
required required
/> />
</label> </label>
<label className={style.label}> <label className={label}>
answer answer
<textarea <textarea
className={`${style.input} ${style.textarea}`} className={field}
value={answer} value={answer}
onChange={(e) => setAnswer(e.target.value)} onChange={(e) => setAnswer(e.target.value)}
required required
/> />
</label> </label>
<button className={style.btn} type="submit"> <button className={btn} type="submit">
Add Add
</button> </button>
</form> </form>

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import style from '@/styles/admin.module.scss'; import { card, error as errorClass, sub, table, th, thTd, title } from '@/lib/adminUi';
type UserRow = { type UserRow = {
id: number; id: number;
@@ -53,43 +53,43 @@ export function UsersAdminClient() {
} }
return ( return (
<div className={style.card}> <div className={card}>
<p> <p>
<Link href="/admin"> Dashboard</Link> <Link href="/admin"> Dashboard</Link>
</p> </p>
<h1 className={style.title}>Users</h1> <h1 className={title}>Users</h1>
<p className={style.sub}>Approve new accounts and grant admin role.</p> <p className={sub}>Approve new accounts and grant admin role.</p>
{error ? <p className={style.error}>{error}</p> : null} {error ? <p className={errorClass}>{error}</p> : null}
{loading ? <p>Loading</p> : null} {loading ? <p>Loading</p> : null}
{!loading && ( {!loading && (
<table className={style.table}> <table className={table}>
<thead> <thead>
<tr> <tr>
<th>Email</th> <th className={`${thTd} ${th}`}>Email</th>
<th>Approved</th> <th className={`${thTd} ${th}`}>Approved</th>
<th>Admin</th> <th className={`${thTd} ${th}`}>Admin</th>
<th>Created</th> <th className={`${thTd} ${th}`}>Created</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{users.map((u) => ( {users.map((u) => (
<tr key={u.id}> <tr key={u.id}>
<td>{u.email}</td> <td className={thTd}>{u.email}</td>
<td> <td className={thTd}>
<input <input
type="checkbox" type="checkbox"
checked={Boolean(u.is_approved)} checked={Boolean(u.is_approved)}
onChange={(e) => patch(u.id, { is_approved: e.target.checked })} onChange={(e) => patch(u.id, { is_approved: e.target.checked })}
/> />
</td> </td>
<td> <td className={thTd}>
<input <input
type="checkbox" type="checkbox"
checked={Boolean(u.is_admin)} checked={Boolean(u.is_admin)}
onChange={(e) => patch(u.id, { is_admin: e.target.checked })} onChange={(e) => patch(u.id, { is_admin: e.target.checked })}
/> />
</td> </td>
<td>{String(u.created_at)}</td> <td className={thTd}>{String(u.created_at)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -3,7 +3,7 @@ import type { ReactNode } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import style from '@/styles/admin.module.scss'; import { nav, navLink, wrap } from '@/lib/adminUi';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'Admin — Catherine League', title: 'Admin — Catherine League',
@@ -12,12 +12,20 @@ export const metadata: Metadata = {
export default function AdminRootLayout({ children }: { children: ReactNode }) { export default function AdminRootLayout({ children }: { children: ReactNode }) {
return ( return (
<div className={style.wrap}> <div className={wrap}>
<nav className={style.nav}> <nav className={nav}>
<Link href="/"> Site</Link> <Link className={navLink} href="/">
<Link href="/admin/login">Login</Link> Site
<Link href="/admin/register">Register</Link> </Link>
<Link href="/admin">Dashboard</Link> <Link className={navLink} href="/admin/login">
Login
</Link>
<Link className={navLink} href="/admin/register">
Register
</Link>
<Link className={navLink} href="/admin">
Dashboard
</Link>
</nav> </nav>
{children} {children}
</div> </div>

View File

@@ -5,7 +5,7 @@ import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import style from '@/styles/admin.module.scss'; import { btn, card, error as errorClass, form, input, label, sub, title } from '@/lib/adminUi';
export default function AdminLoginPage() { export default function AdminLoginPage() {
const router = useRouter(); const router = useRouter();
@@ -40,16 +40,16 @@ export default function AdminLoginPage() {
} }
return ( return (
<div className={style.card}> <div className={card}>
<h1 className={style.title}>Admin login</h1> <h1 className={title}>Admin login</h1>
<p className={style.sub}> <p className={sub}>
No account? <Link href="/admin/register">Register</Link> an administrator must approve you before you can sign in. No account? <Link href="/admin/register">Register</Link> an administrator must approve you before you can sign in.
</p> </p>
<form className={style.form} onSubmit={onSubmit}> <form className={form} onSubmit={onSubmit}>
<label className={style.label}> <label className={label}>
Email Email
<input <input
className={style.input} className={input}
type="email" type="email"
autoComplete="email" autoComplete="email"
value={email} value={email}
@@ -57,10 +57,10 @@ export default function AdminLoginPage() {
required required
/> />
</label> </label>
<label className={style.label}> <label className={label}>
Password Password
<input <input
className={style.input} className={input}
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
value={password} value={password}
@@ -68,8 +68,8 @@ export default function AdminLoginPage() {
required required
/> />
</label> </label>
{error ? <p className={style.error}>{error}</p> : null} {error ? <p className={errorClass}>{error}</p> : null}
<button className={style.btn} type="submit" disabled={loading}> <button className={btn} type="submit" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'} {loading ? 'Signing in…' : 'Sign in'}
</button> </button>
</form> </form>

View File

@@ -1,12 +1,12 @@
import Link from 'next/link'; import Link from 'next/link';
import style from '@/styles/admin.module.scss'; import { card, sub, title } from '@/lib/adminUi';
export default function AdminNoAccessPage() { export default function AdminNoAccessPage() {
return ( return (
<div className={style.card}> <div className={card}>
<h1 className={style.title}>No admin access</h1> <h1 className={title}>No admin access</h1>
<p className={style.sub}> <p className={sub}>
Your account is approved, but you do not have administrator privileges for content management. Ask an existing admin to Your account is approved, but you do not have administrator privileges for content management. Ask an existing admin to
grant you the admin role. grant you the admin role.
</p> </p>

View File

@@ -1,12 +1,12 @@
import Link from 'next/link'; import Link from 'next/link';
import style from '@/styles/admin.module.scss'; import { card, sub, title } from '@/lib/adminUi';
export default function AdminPendingPage() { export default function AdminPendingPage() {
return ( return (
<div className={style.card}> <div className={card}>
<h1 className={style.title}>Account pending</h1> <h1 className={title}>Account pending</h1>
<p className={style.sub}> <p className={sub}>
Your account has not been approved yet, or approval was revoked. You cannot use the admin area until an administrator Your account has not been approved yet, or approval was revoked. You cannot use the admin area until an administrator
approves you. approves you.
</p> </p>

View File

@@ -4,7 +4,7 @@ import { useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import style from '@/styles/admin.module.scss'; import { btn, card, error as errorClass, form, input, label, sub, success as successClass, title } from '@/lib/adminUi';
export default function AdminRegisterPage() { export default function AdminRegisterPage() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
@@ -40,17 +40,17 @@ export default function AdminRegisterPage() {
} }
return ( return (
<div className={style.card}> <div className={card}>
<h1 className={style.title}>Register</h1> <h1 className={title}>Register</h1>
<p className={style.sub}> <p className={sub}>
After registering, wait for an administrator to approve your account. Then you can{' '} After registering, wait for an administrator to approve your account. Then you can{' '}
<Link href="/admin/login">sign in</Link>. <Link href="/admin/login">sign in</Link>.
</p> </p>
<form className={style.form} onSubmit={onSubmit}> <form className={form} onSubmit={onSubmit}>
<label className={style.label}> <label className={label}>
Email Email
<input <input
className={style.input} className={input}
type="email" type="email"
autoComplete="email" autoComplete="email"
value={email} value={email}
@@ -58,10 +58,10 @@ export default function AdminRegisterPage() {
required required
/> />
</label> </label>
<label className={style.label}> <label className={label}>
Password (min. 8 characters) Password (min. 8 characters)
<input <input
className={style.input} className={input}
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
value={password} value={password}
@@ -70,9 +70,9 @@ export default function AdminRegisterPage() {
required required
/> />
</label> </label>
{error ? <p className={style.error}>{error}</p> : null} {error ? <p className={errorClass}>{error}</p> : null}
{success ? <p className={style.success}>{success}</p> : null} {success ? <p className={successClass}>{success}</p> : null}
<button className={style.btn} type="submit" disabled={loading}> <button className={btn} type="submit" disabled={loading}>
{loading ? 'Submitting…' : 'Register'} {loading ? 'Submitting…' : 'Register'}
</button> </button>
</form> </form>

View File

@@ -1,17 +1,18 @@
body { @tailwind base;
margin: 0; @tailwind components;
font-family: var(--font-mplus), -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', @tailwind utilities;
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code { @layer base {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', body {
monospace; @apply m-0 font-mplus antialiased;
} }
html, code {
body { font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
min-height: 100%; }
html,
body {
@apply min-h-full overflow-x-hidden;
}
} }

View File

@@ -4,7 +4,6 @@ import type { ReactNode } from 'react';
import { I18nProvider } from '@/components/I18nProvider'; import { I18nProvider } from '@/components/I18nProvider';
import 'semantic-ui-css/semantic.min.css';
import './globals.css'; import './globals.css';
const indieFlower = Indie_Flower({ const indieFlower = Indie_Flower({

View File

@@ -3,8 +3,8 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import YouTube from 'react-youtube'; import YouTube from 'react-youtube';
import { centerVideo, characterBlock, guideBody } from '@/lib/siteContentClasses';
import type { IGuideItem } from '@/models/IGuideItem'; import type { IGuideItem } from '@/models/IGuideItem';
import style from '@/styles/web.module.scss';
type Props = { type Props = {
items: IGuideItem[]; items: IGuideItem[];
@@ -19,24 +19,26 @@ export function ArchiveVideoList({ items }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className={style.guideBody}> <div className={guideBody}>
<h2>{t('archive')}</h2> <h2 className="text-xl sm:text-2xl">{t('archive')}</h2>
<hr /> <hr />
<br /> <br />
{items.map((el) => ( {items.map((el) => (
<div <div
key={`archiveItemsLoop-${el.id}`} key={`archiveItemsLoop-${el.id}`}
className={style.characterBlock} className={characterBlock}
style={{ style={{
animationDelay: `${el.id * 0.2}s`, animationDelay: `${el.id * 0.2}s`,
}} }}
> >
<h4>{el.title}</h4> <h4>{el.title}</h4>
<hr /> <hr />
<div className={style.centerVideo}> <div className={centerVideo}>
<div className="mx-auto w-full max-w-[360px] overflow-x-auto">
<YouTube videoId={el.youtube_id} opts={opts} /> <YouTube videoId={el.youtube_id} opts={opts} />
</div> </div>
</div> </div>
</div>
))} ))}
</div> </div>
); );

View File

@@ -2,8 +2,8 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { characterBlock, guideBody } from '@/lib/siteContentClasses';
import type { IContactQuestion } from '@/models/IContactQuestion'; import type { IContactQuestion } from '@/models/IContactQuestion';
import style from '@/styles/web.module.scss';
type Props = { type Props = {
items: IContactQuestion[]; items: IContactQuestion[];
@@ -13,18 +13,23 @@ export function ContactList({ items }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className={style.guideBody}> <div className={guideBody}>
<h2>{t('qanda')}</h2> <h2 className="text-xl sm:text-2xl">{t('qanda')}</h2>
<hr /> <hr />
<div className={style.contactBox}> <div className="relative mx-auto my-5 h-32 w-32 bg-[url(https://static.catherine-fc.com/media/letterbox.png)] bg-[length:128px_128px] [background-repeat:no-repeat]">
<a href="https://forms.gle/Dn4p7cFEPLK1zcTz5" rel="noopener noreferrer" target="_blank"> <a
className="block h-32 w-32"
href="https://forms.gle/Dn4p7cFEPLK1zcTz5"
rel="noopener noreferrer"
target="_blank"
>
&nbsp; &nbsp;
</a> </a>
</div> </div>
{items.map((el) => ( {items.map((el) => (
<div <div
key={`contactItemsLoop-${el.id}`} key={`contactItemsLoop-${el.id}`}
className={style.characterBlock} className={characterBlock}
style={{ style={{
animationDelay: `${el.id * 0.2}s`, animationDelay: `${el.id * 0.2}s`,
}} }}

View File

@@ -3,8 +3,8 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import YouTube from 'react-youtube'; import YouTube from 'react-youtube';
import { centerVideo, characterBlock, guideBody } from '@/lib/siteContentClasses';
import type { IGuideItem } from '@/models/IGuideItem'; import type { IGuideItem } from '@/models/IGuideItem';
import style from '@/styles/web.module.scss';
type Props = { type Props = {
items: IGuideItem[]; items: IGuideItem[];
@@ -19,14 +19,14 @@ export function GuideVideoList({ items }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className={style.guideBody}> <div className={guideBody}>
<h2>{t('guide')}</h2> <h2 className="text-xl sm:text-2xl">{t('guide')}</h2>
<hr /> <hr />
<br /> <br />
{items.map((el) => ( {items.map((el) => (
<div <div
key={`guideItemsLoop-${el.id}`} key={`guideItemsLoop-${el.id}`}
className={style.characterBlock} className={characterBlock}
style={{ style={{
animationDelay: `${el.id * 0.2}s`, animationDelay: `${el.id * 0.2}s`,
}} }}
@@ -34,10 +34,12 @@ export function GuideVideoList({ items }: Props) {
<h4>{el.title}</h4> <h4>{el.title}</h4>
<hr /> <hr />
<div>{el.description}</div> <div>{el.description}</div>
<div className={style.centerVideo}> <div className={centerVideo}>
<div className="mx-auto w-full max-w-[360px] overflow-x-auto">
<YouTube videoId={el.youtube_id} opts={opts} /> <YouTube videoId={el.youtube_id} opts={opts} />
</div> </div>
</div> </div>
</div>
))} ))}
</div> </div>
); );

View File

@@ -1,49 +1,97 @@
'use client'; 'use client';
import Link from 'next/link';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { characterPortraitBox, characterPortraitStyle } from '@/lib/characterPortrait';
import {
characterBlock,
guideBody,
titlePlayer,
titlePlayerBlock,
} from '@/lib/siteContentClasses';
import type { IPlayerInfo } from '@/models/IPlayerInfo'; import type { IPlayerInfo } from '@/models/IPlayerInfo';
import style from '@/styles/web.module.scss';
export type PlayersListPagination = {
page: number;
total: number;
pageSize: number;
basePath: string;
};
type Props = { type Props = {
players: IPlayerInfo[]; players: IPlayerInfo[];
pagination?: PlayersListPagination;
}; };
export function PlayersList({ players }: Props) { const linkClass =
'rounded border border-white/40 px-3 py-2 text-white underline-offset-4 hover:border-white hover:underline';
const disabledClass = 'cursor-not-allowed rounded border border-white/20 px-3 py-2 opacity-40';
export function PlayersList({ players, pagination }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const totalPages =
pagination != null ? Math.max(1, Math.ceil(pagination.total / pagination.pageSize)) : 1;
const showPager = pagination != null && totalPages > 1;
function hrefForPage(p: number): string {
if (pagination == null) return '#';
return p <= 1 ? pagination.basePath : `${pagination.basePath}?page=${p}`;
}
return ( return (
<div className={style.guideBody}> <div className={guideBody}>
<h2>Players</h2> <h2 className="text-xl sm:text-2xl">Players</h2>
<hr /> <hr />
<br /> <br />
{players.map((el) => { {players.map((el) => (
const charClass =
style[`character-${el.name_id}` as keyof typeof style] ?? '';
return (
<div <div
key={`playerInfoLoop-${el.id}`} key={`playerInfoLoop-${el.id}`}
className={style.characterBlock} className={characterBlock}
style={{ style={{
animationDelay: `${el.id * 0.2}s`, animationDelay: `${el.id * 0.2}s`,
}} }}
> >
<div className={charClass} /> <div className={characterPortraitBox} style={characterPortraitStyle(el.name_id)} />
<div className={style.titlePlayer}> <div className={titlePlayer}>
<span className={style.titlePlayerBlock}>{t('player_name')}</span> <span className={titlePlayerBlock}>{t('player_name')}</span>
</div> </div>
<div>{el.player_name}</div> <div>{el.player_name}</div>
<div className={style.titlePlayer}> <div className={titlePlayer}>
<span className={style.titlePlayerBlock}>{t('use_character')}</span> <span className={titlePlayerBlock}>{t('use_character')}</span>
</div> </div>
<div>{el.name_jp}</div> <div>{el.name_jp}</div>
<div className={style.titlePlayer}> <div className={titlePlayer}>
<span className={style.titlePlayerBlock}>{t('description')}</span> <span className={titlePlayerBlock}>{t('description')}</span>
</div> </div>
<div>{el.description}</div> <div>{el.description}</div>
</div> </div>
); ))}
})} {showPager && pagination != null ? (
<nav
className="mt-8 flex flex-wrap items-center justify-center gap-3 text-sm sm:gap-6"
aria-label="Pagination"
>
{pagination.page > 1 ? (
<Link className={linkClass} href={hrefForPage(pagination.page - 1)}>
Previous
</Link>
) : (
<span className={disabledClass}> </span>
)}
<span className="tabular-nums opacity-90">
{pagination.page} / {totalPages} 
</span>
{pagination.page < totalPages ? (
<Link className={linkClass} href={hrefForPage(pagination.page + 1)}>
Next
</Link>
) : (
<span className={disabledClass}> </span>
)}
</nav>
) : null}
</div> </div>
); );
} }

View File

@@ -1,15 +1,27 @@
import Link from 'next/link'; import Link from 'next/link';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import style from '@/styles/web.module.scss'; const navLinkClass =
'flex min-h-[44px] w-full items-center justify-center bg-black px-2 font-indie text-sm text-white hover:-rotate-[10deg] hover:font-bold hover:text-pink-300 sm:h-[30px] sm:min-h-0 sm:w-[90px] sm:flex-none sm:text-base';
export function SiteLayout({ children }: { children: ReactNode }) { export function SiteLayout({ children }: { children: ReactNode }) {
return ( return (
<div className={style.main}> <div className="flex min-h-screen w-full max-w-[100vw] flex-col overflow-x-hidden bg-[url(https://static.catherine-fc.com/media/background.png)] bg-cover bg-top bg-no-repeat text-white md:bg-fixed">
<div className={style.header}> <div className="relative h-[300px] w-full overflow-hidden bg-[url(https://static.catherine-fc.com/media/bgbg.png)] bg-top bg-no-repeat md:h-[460px] max-md:bg-cover max-md:bg-center">
<div className={style.background} /> {/* Outer: horizontal center only. Inner: animate-up-down sets transform and would override -translate-x-1/2 on a single node */}
<div className={style.buynow}> <div className="absolute left-1/2 top-0 w-full max-w-[1366px] -translate-x-1/2">
<div
className="group h-[220px] w-full animate-up-down bg-[url(https://static.catherine-fc.com/media/bgimage.png)] bg-no-repeat [background-position:center_top] sm:h-[320px] md:h-[401px]"
>
<div
className="pointer-events-none absolute left-0 top-0 h-full w-full bg-[url(https://static.catherine-fc.com/media/bgimagenight.png)] bg-no-repeat [background-position:center_top] opacity-0 transition-opacity duration-500 ease-in-out group-hover:opacity-100"
aria-hidden
/>
</div>
</div>
<div className="fixed bottom-0 right-0 top-0 z-[1] m-auto hidden h-[99px] w-[235px] min-[601px]:block">
<a <a
className="block h-[99px] w-[235px] bg-[url(https://static.catherine-fc.com/media/buynow.png)] hover:animate-bounce"
href="https://www.atlus.co.jp/news/13264/" href="https://www.atlus.co.jp/news/13264/"
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
@@ -17,11 +29,12 @@ export function SiteLayout({ children }: { children: ReactNode }) {
&nbsp; &nbsp;
</a> </a>
</div> </div>
<div className={style.logoHeader} /> {/* Logo: always translate-centered; md+ uses original pixel size, narrow viewports scale width */}
<div className={style.logoSwitch} /> <div className="absolute left-1/2 top-[-20px] z-[1] h-[200px] w-[min(92vw,577px)] max-w-[577px] -translate-x-1/2 bg-[url(https://static.catherine-fc.com/media/cat_fb_logo.png)] bg-contain bg-center bg-no-repeat sm:top-[-30px] sm:h-[280px] md:top-[-30px] md:h-[401px] md:w-[577px] md:bg-auto md:bg-top" />
<div className={style.waveContainer}> <div className="absolute left-1/2 top-[150px] z-[1] h-12 w-[min(88vw,415px)] max-w-[415px] -translate-x-1/2 bg-[url(https://static.catherine-fc.com/media/faito_crab.png)] bg-contain bg-center bg-no-repeat sm:top-[240px] sm:h-14 md:top-[310px] md:h-16 md:w-[415px] md:bg-auto md:bg-top" />
<div className="absolute left-0 right-0 top-[180px] z-0 sm:top-[240px] md:top-[260px]">
<svg <svg
className={style.waves} className="relative -mb-[7px] h-[100px] min-h-[80px] w-full max-h-[200px] sm:h-[150px] md:h-[200px]"
viewBox="0 24 150 28" viewBox="0 24 150 28"
preserveAspectRatio="none" preserveAspectRatio="none"
shapeRendering="auto" shapeRendering="auto"
@@ -34,14 +47,14 @@ export function SiteLayout({ children }: { children: ReactNode }) {
</defs> </defs>
<g> <g>
<use <use
className={style.firstWave} className="animate-wave-1"
x={48} x={48}
y={7} y={7}
href="#gentle-wave" href="#gentle-wave"
fill="rgba(255,45,124,0.7)" fill="rgba(255,45,124,0.7)"
/> />
<use <use
className={style.secondWave} className="animate-wave-2"
x={48} x={48}
y={0} y={0}
href="#gentle-wave" href="#gentle-wave"
@@ -51,31 +64,43 @@ export function SiteLayout({ children }: { children: ReactNode }) {
</svg> </svg>
</div> </div>
</div> </div>
<nav className={style.navigation}> <nav className="absolute left-0 right-0 top-[260px] z-[1] mx-auto max-w-[100vw] px-2 sm:top-[320px] md:top-[400px]">
<ul> <ul className="mx-auto grid max-w-[min(100%,420px)] list-none grid-cols-2 gap-2 sm:flex sm:max-w-none sm:flex-wrap sm:justify-center sm:gap-x-2.5 sm:gap-y-2">
<li> <li>
<Link href="/">Home</Link> <Link className={navLinkClass} href="/">
Home
</Link>
</li> </li>
<li> <li>
<Link href="/players">Players</Link> <Link className={navLinkClass} href="/players">
Players
</Link>
</li> </li>
<li> <li>
<Link href="/guide">Guide</Link> <Link className={navLinkClass} href="/guide">
Guide
</Link>
</li> </li>
<li> <li>
<Link href="/archive">Archive</Link> <Link className={navLinkClass} href="/archive">
Archive
</Link>
</li> </li>
<li> <li>
<Link href="/about">About</Link> <Link className={navLinkClass} href="/about">
About
</Link>
</li> </li>
<li> <li>
<Link href="/contact">Q&A</Link> <Link className={navLinkClass} href="/contact">
Q&A
</Link>
</li> </li>
</ul> </ul>
</nav> </nav>
<div className={style.contents}>{children}</div> <div className="flex-grow px-4 pb-6 pt-28 sm:p-[30px]">{children}</div>
<div className={style.footer}> <div className="flex w-full flex-col flex-wrap items-center justify-center gap-6 bg-black p-5 text-center text-sm text-white sm:flex-row sm:text-left sm:text-base">
<div className={style.footerText}> <div className="max-w-xl [&>p]:my-1.5">
<p> <p>
<a href="https://www.atlus.co.jp/copyright/" rel="noopener noreferrer" target="_blank"> <a href="https://www.atlus.co.jp/copyright/" rel="noopener noreferrer" target="_blank">
©ATLUS ©ATLUS
@@ -85,13 +110,20 @@ export function SiteLayout({ children }: { children: ReactNode }) {
<p>ATLUS様のページとは異なるユーザーサイトです</p> <p>ATLUS様のページとは異なるユーザーサイトです</p>
<p>ATLUS様に出されないよう</p> <p>ATLUS様に出されないよう</p>
</div> </div>
<div className={style.twitter}> <div className="flex flex-wrap items-center justify-center gap-5">
<a href="https://twitter.com/catherine_f_c" rel="noopener noreferrer" target="_blank"> <div className="h-[70px] w-[70px] bg-[url(https://static.catherine-fc.com/media/twitter.png)] bg-contain">
<a
className="block h-[70px] w-[70px]"
href="https://twitter.com/catherine_f_c"
rel="noopener noreferrer"
target="_blank"
>
&nbsp; &nbsp;
</a> </a>
</div> </div>
<div className={style.twitch}> <div className="h-[70px] w-[113px] bg-[url(https://static.catherine-fc.com/media/twitch.png)] bg-contain">
<a <a
className="block h-[70px] w-[113px]"
href="https://www.twitch.tv/catherine_faito_crab" href="https://www.twitch.tv/catherine_faito_crab"
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
@@ -99,8 +131,9 @@ export function SiteLayout({ children }: { children: ReactNode }) {
&nbsp; &nbsp;
</a> </a>
</div> </div>
<div className={style.youtube}> <div className="h-[70px] w-[100px] bg-[url(https://static.catherine-fc.com/media/youtube.png)] bg-contain">
<a <a
className="block h-[70px] w-[100px]"
href="https://www.youtube.com/channel/UC5cvyvCdOMbxwZqCT09cGNA/" href="https://www.youtube.com/channel/UC5cvyvCdOMbxwZqCT09cGNA/"
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
@@ -110,5 +143,6 @@ export function SiteLayout({ children }: { children: ReactNode }) {
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -1,12 +1,12 @@
'use client'; 'use client';
import style from '@/styles/admin.module.scss'; import { btnGhost } from '@/lib/adminUi';
export function LogoutButton() { export function LogoutButton() {
return ( return (
<button <button
type="button" type="button"
className={style.btnGhost} className={btnGhost}
onClick={async () => { onClick={async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' }); await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
window.location.href = '/admin/login'; window.location.href = '/admin/login';

54
nextjs/src/lib/adminUi.ts Normal file
View File

@@ -0,0 +1,54 @@
/** Tailwind class bundles for admin (replaces admin.module.scss). */
export const wrap = 'max-w-[960px] mx-auto px-4 py-6 pb-12 font-sans text-neutral-900';
export const card = 'mb-5 rounded-lg border border-neutral-200 bg-white p-5';
export const title = 'mb-2 text-2xl font-semibold';
export const sub = 'mb-4 text-[0.95rem] text-neutral-600';
export const form = 'flex max-w-[360px] flex-col gap-3';
/** Same as `form` but max-width 480px (add/edit blocks). */
export const formWide = 'flex max-w-[480px] flex-col gap-3';
export const label = 'flex flex-col gap-1 text-sm';
export const input = 'rounded border border-neutral-300 px-2.5 py-2 text-base';
export const textarea = 'min-h-[100px] font-inherit';
export const btn =
'cursor-pointer rounded border-none bg-blue-600 px-4 py-2.5 text-base text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60';
export const btnDanger =
'cursor-pointer rounded border-none bg-red-600 px-4 py-2.5 text-base text-white hover:bg-red-700';
export const btnGhost =
'cursor-pointer rounded border-none bg-neutral-100 px-4 py-2.5 text-base text-neutral-900 hover:bg-neutral-200';
export const error = 'text-sm text-red-700';
export const success = 'text-sm text-green-700';
export const nav = 'mb-6 flex flex-wrap gap-3 border-b border-neutral-200 pb-4';
export const navLink = 'text-blue-600 no-underline hover:underline';
export const table = 'w-full border-collapse text-sm';
export const thTd = 'border border-neutral-200 p-2 text-left align-top';
export const th = 'bg-neutral-50';
export const rowActions = 'flex flex-wrap gap-2';
export const hr = 'my-4 border-0 border-t border-neutral-200';
export const sectionBlock = 'mb-6 border-b border-neutral-200 pb-4';
export const metaLine = 'mb-2 text-sm text-neutral-600';
/** Smaller heading for “Add player” / “New entry” blocks (replaces title + inline font size). */
export const sectionTitle = 'mb-2 text-[1.1rem] font-semibold';

View File

@@ -0,0 +1,32 @@
import type { CSSProperties } from 'react';
/** Character portrait background URLs (former .character-* SCSS). */
const M = 'https://static.catherine-fc.com/media';
const PORTRAIT_BY_NAME_ID: Record<string, string> = {
blue_cap: `${M}/00_bluecap_1204.png`,
red_cap: `${M}/00_redcap_1204.png`,
vincent_shirt: `${M}/01_vincent_1204.png`,
vincent_sheep: `${M}/01_sheep_vincent_1204.png`,
katherine: `${M}/02_katherine_1204.png`,
catherine: `${M}/03_catherine_1204.png`,
rin: `${M}/04_rin_1204.png`,
orlando: `${M}/05_orlando_1204.png`,
johny: `${M}/06_jonny_1204.png`,
tobby: `${M}/07_tobby_1204.png`,
erica: `${M}/08_erika_1204.png`,
master: `${M}/09_master_1204.png`,
joker: `${M}/13_joker_1204.png`,
};
export function characterPortraitStyle(nameId: string): CSSProperties {
const url = PORTRAIT_BY_NAME_ID[nameId];
if (!url) return {};
return {
backgroundImage: `url(${url})`,
};
}
export const characterPortraitBox =
'float-none mx-auto mb-4 h-[200px] w-[200px] shrink-0 bg-contain bg-no-repeat sm:float-left sm:mb-0 sm:mr-5 sm:h-[240px] sm:w-[240px] sm:mx-0';

View File

@@ -4,6 +4,9 @@ import type { IContactQuestion } from '@/models/IContactQuestion';
import type { IGuideItem } from '@/models/IGuideItem'; import type { IGuideItem } from '@/models/IGuideItem';
import type { IPlayerInfo } from '@/models/IPlayerInfo'; import type { IPlayerInfo } from '@/models/IPlayerInfo';
/** Public players list: items per page (main /tournaments/.../players). */
export const PLAYERS_PAGE_SIZE = 5;
let tournamentIdByKeyPromise: Promise<Record<string, number>> | null = null; let tournamentIdByKeyPromise: Promise<Record<string, number>> | null = null;
async function loadTournamentMap(): Promise<Record<string, number>> { async function loadTournamentMap(): Promise<Record<string, number>> {
@@ -30,11 +33,38 @@ export async function getAllPlayers(): Promise<IPlayerInfo[]> {
const [rows] = await pool.query<RowDataPacket[]>( const [rows] = await pool.query<RowDataPacket[]>(
`SELECT p.id, p.player_key, p.player_name, p.description, p.image, c.name_id, c.name, c.name_jp `SELECT p.id, p.player_key, p.player_name, p.description, p.image, c.name_id, c.name, c.name_jp
FROM players p FROM players p
JOIN characters c ON c.id = p.character_id` JOIN characters c ON c.id = p.character_id
ORDER BY p.id`
); );
return rows as IPlayerInfo[]; return rows as IPlayerInfo[];
} }
export async function getAllPlayersPaged(
page: number
): Promise<{ players: IPlayerInfo[]; total: number }> {
const pool = getPool();
const pageSize = PLAYERS_PAGE_SIZE;
const offset = (page - 1) * pageSize;
const [countRows] = await pool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS cnt
FROM players p
JOIN characters c ON c.id = p.character_id`
);
const total = Number((countRows[0] as { cnt: number }).cnt);
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT p.id, p.player_key, p.player_name, p.description, p.image, c.name_id, c.name, c.name_jp
FROM players p
JOIN characters c ON c.id = p.character_id
ORDER BY p.id
LIMIT ? OFFSET ?`,
[pageSize, offset]
);
return { players: rows as IPlayerInfo[], total };
}
export async function getPlayersForTournament( export async function getPlayersForTournament(
tournamentKey: string tournamentKey: string
): Promise<IPlayerInfo[] | null> { ): Promise<IPlayerInfo[] | null> {
@@ -49,12 +79,50 @@ export async function getPlayersForTournament(
FROM players p FROM players p
JOIN player_to_tournament ptt ON p.id = ptt.player_id JOIN player_to_tournament ptt ON p.id = ptt.player_id
JOIN characters c ON c.id = p.character_id JOIN characters c ON c.id = p.character_id
WHERE ptt.tournament_id = ?`, WHERE ptt.tournament_id = ?
ORDER BY p.id`,
[tournamentId] [tournamentId]
); );
return rows as IPlayerInfo[]; return rows as IPlayerInfo[];
} }
export async function getPlayersForTournamentPaged(
tournamentKey: string,
page: number
): Promise<{ players: IPlayerInfo[]; total: number } | null> {
const tournaments = await getTournamentIdByKey();
const tournamentId = tournaments[tournamentKey];
if (tournamentId === undefined) {
return null;
}
const pool = getPool();
const pageSize = PLAYERS_PAGE_SIZE;
const offset = (page - 1) * pageSize;
const [countRows] = await pool.query<RowDataPacket[]>(
`SELECT COUNT(*) AS cnt
FROM players p
JOIN player_to_tournament ptt ON p.id = ptt.player_id
JOIN characters c ON c.id = p.character_id
WHERE ptt.tournament_id = ?`,
[tournamentId]
);
const total = Number((countRows[0] as { cnt: number }).cnt);
const [rows] = await pool.query<RowDataPacket[]>(
`SELECT p.id, p.player_key, p.player_name, p.description, p.image, c.name_id, c.name, c.name_jp
FROM players p
JOIN player_to_tournament ptt ON p.id = ptt.player_id
JOIN characters c ON c.id = p.character_id
WHERE ptt.tournament_id = ?
ORDER BY p.id
LIMIT ? OFFSET ?`,
[tournamentId, pageSize, offset]
);
return { players: rows as IPlayerInfo[], total };
}
export async function getGuideItems(): Promise<IGuideItem[]> { export async function getGuideItems(): Promise<IGuideItem[]> {
const pool = getPool(); const pool = getPool();
const [rows] = await pool.query<RowDataPacket[]>('SELECT * FROM guide'); const [rows] = await pool.query<RowDataPacket[]>('SELECT * FROM guide');

View File

@@ -0,0 +1,19 @@
/** Shared Tailwind class strings for public site (matches former web.module.scss). */
export const guideBody =
'relative my-5 mx-auto w-full max-w-[980px] px-4 sm:px-5 md:px-0';
/** Player/guide/archive Q&A cards. Use 100%×100% so the frame stretches with the box; 100%×auto only painted one band and tall content spilled past the art. */
export const characterBlock =
'relative overflow-hidden break-words opacity-0 w-full max-w-[980px] min-h-0 md:h-[314px] h-auto my-5 mx-auto p-4 sm:p-6 md:p-[30px] bg-[url(https://static.catherine-fc.com/media/playerbg.png)] bg-no-repeat [background-position:center] [background-size:100%_100%] font-mplus odd:animate-slide-in-l even:animate-slide-in-r';
export const titlePlayer = 'relative flex mb-2.5';
export const titlePlayerBlock =
'px-2.5 py-1.5 bg-white -rotate-2 text-black font-mplus text-sm sm:text-base';
export const centerVideo =
'w-full flex justify-center overflow-x-auto [&_iframe]:max-w-full';
export const mainBody =
'flex my-5 mx-auto w-full max-w-[980px] items-center justify-center px-4 sm:px-5 md:px-0';

View File

@@ -1,141 +0,0 @@
.wrap {
max-width: 960px;
margin: 0 auto;
padding: 24px 16px 48px;
font-family: system-ui, sans-serif;
color: #1a1a1a;
}
.card {
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.title {
margin: 0 0 8px;
font-size: 1.5rem;
}
.sub {
color: #666;
margin: 0 0 16px;
font-size: 0.95rem;
}
.form {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 360px;
}
.label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.9rem;
}
.input {
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}
.textarea {
min-height: 100px;
font-family: inherit;
}
.btn {
padding: 10px 16px;
border: none;
border-radius: 4px;
background: #2563eb;
color: #fff;
font-size: 1rem;
cursor: pointer;
&:hover {
background: #1d4ed8;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.btnDanger {
background: #dc2626;
&:hover {
background: #b91c1c;
}
}
.btnGhost {
background: #f3f4f6;
color: #111;
&:hover {
background: #e5e7eb;
}
}
.error {
color: #b91c1c;
font-size: 0.9rem;
}
.success {
color: #15803d;
font-size: 0.9rem;
}
.nav {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #e5e7eb;
}
.nav a {
color: #2563eb;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.table th,
.table td {
border: 1px solid #e5e7eb;
padding: 8px;
text-align: left;
vertical-align: top;
}
.table th {
background: #f9fafb;
}
.rowActions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.hr {
border: none;
border-top: 1px solid #e5e7eb;
margin: 16px 0;
}

View File

@@ -1,623 +0,0 @@
.header {
background-image: url('https://static.catherine-fc.com/media/bgbg.png');
background-repeat: none;
width: 100%;
height: 460px;
}
.buynow {
position: fixed;
top: 0;
bottom: 0;
right: 0;
margin: auto;
width: 235px;
height: 99px;
background-image: url('https://static.catherine-fc.com/media/buynow.png');
z-index: 1;
&> a {
display:block;
width: 235px;
height: 99px;
}
&:hover {
animation: bounce 1s;
}
}
// Day + night: Firefox does not transition between two background-image URLs.
// Stack night in ::after and fade opacity (works everywhere).
.background {
position: absolute;
top: 0;
left: 0;
right: 0;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/bgimage.png');
background-repeat: no-repeat;
background-position: center top;
width: 1366px;
height: 401px;
animation: upAndDown 10s linear infinite;
&::after {
content: '';
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-image: url('https://static.catherine-fc.com/media/bgimagenight.png');
background-repeat: no-repeat;
background-position: center top;
opacity: 0;
transition: opacity 0.5s ease-in-out;
pointer-events: none;
}
&:hover::after {
opacity: 1;
}
}
.logoHeader {
position: absolute;
top: -30px;
left: 0;
right: 0;
margin: auto;
width: 577px;
height: 401px;
background-image: url('https://static.catherine-fc.com/media/cat_fb_logo.png');
background-repeat: none;
z-index: 1;
}
.logoSwitch {
position: absolute;
top: 310px;
left: 0;
right: 0;
margin: auto;
width: 415px;
height: 64px;
background-image: url('https://static.catherine-fc.com/media/faito_crab.png');
background-repeat: none;
z-index: 1;
}
.contents {
flex-grow: 1;
padding: 30px;
}
.main {
display: flex;
flex-direction: column;
width: 100%;
background-image: url('https://static.catherine-fc.com/media/background.png');
background-repeat: none;
color: white;
min-height: 100vh;
}
.footer {
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
padding: 20px;
width: 100%;
background-color: black;
color: white;
bottom: 0;
}
.footerText {
float: left;
display: block;
justify-content: center;
align-items: center;
flex-direction: row;
& > p {
margin: 5px 0;
}
}
.twitch {
float: right;
width: 113px;
height: 70px;
background-image: url('https://static.catherine-fc.com/media/twitch.png');
background-size: 142px 70px;
margin: 0 20px;
background-repeat: none;
background-size: contain;
&> a {
display:block;
width: 113px;
height: 70px;
}
}
.twitchHome {
width: 113px;
height: 70px;
background-image: url('https://static.catherine-fc.com/media/twitch.png');
background-size: 142px 70px;
margin: auto;
background-repeat: none;
background-size: contain;
&> a {
display:block;
width: 113px;
height: 70px;
}
}
.twitter {
float: right;
width: 70px;
height: 70px;
margin: 0 20px;
background-image: url('https://static.catherine-fc.com/media/twitter.png');
background-size: contain;
&> a {
display:block;
width: 70px;
height: 70px;
}
}
.youtube {
float: right;
width: 100px;
height: 70px;
margin: 0 20px;
background-image: url('https://static.catherine-fc.com/media/youtube.png');
background-size: contain;
&> a {
display:block;
width: 100px;
height: 70px;
}
}
.waveContainer {
position: absolute;
top: 260px;
left: 0;
right: 0;
margin: auto;
z-index: 0;
}
.waves {
position: relative;
width: 100%;
height: 200px;
margin-bottom: -7px;
min-height: 100px;
max-height: 200px;
}
.firstWave {
animation: moveforever 25s cubic-bezier(.55, .5, .45, .5) infinite;
animation-delay: -2s;
animation-duration: 2s;
}
.secondWave {
animation: moveforever 25s cubic-bezier(.55, .5, .45, .5) infinite;
animation-delay: -4s;
animation-duration: 5s;
}
@keyframes moveforever {
0% {
transform: translate3d(-90px, 0, 0);
}
100% {
transform: translate3d(85px, 0, 0);
}
}
@keyframes bounce {
0%, 20%, 60%, 100% {
-webkit-transform: translateY(0);
transform: translateY(0);
}
40% {
-webkit-transform: translateY(-20px);
transform: translateY(-20px);
}
80% {
-webkit-transform: translateY(-10px);
transform: translateY(-10px);
}
}
.navigation {
position: absolute;
top: 400px;
left: 0;
right: 0;
margin: auto;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
&>ul {
list-style-type: none;
&>li {
float: left;
margin-left: 10px;
margin-right: 10px;
&>a {
background-color: black;
width: 90px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 16px;
font-family: var(--font-indie), cursive;
color: white;
&:hover {
transform: rotate(-10deg);
font-weight: bold;
color: pink;
}
}
}
}
}
.characterBlock {
position: relative;
opacity: 0;
width: 980px;
height: 314px;
margin: 20px auto;
padding: 30px;
background-image: url('https://static.catherine-fc.com/media/playerbg.png');
background-repeat: none;
font-family: var(--font-mplus), sans-serif;
&:nth-child(odd) {
animation: slideInFromLeft 1s forwards;
}
&:nth-child(even) {
animation: slideInFromRight 1s forwards;
}
}
@keyframes slideInFromLeft {
0% {
transform: translateX(-100%);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideInFromRight {
0% {
transform: translateX(100%);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
@keyframes upAndDown {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-5%);
}
}
@media (max-width: 600px) {
.buynow {
display: none;
}
}
@mixin characterImage($url) {
width: 240px;
height: 240px;
margin: 0 20px 0 0;
background-image: url($url);
background-size: contain;
float: left;
}
.character-blue_cap {
@include characterImage('https://static.catherine-fc.com/media/00_bluecap_1204.png');
}
.character-red_cap {
@include characterImage('https://static.catherine-fc.com/media/00_redcap_1204.png');
}
.character-vincent_shirt {
@include characterImage('https://static.catherine-fc.com/media/01_vincent_1204.png');
}
.character-vincent_sheep {
@include characterImage('https://static.catherine-fc.com/media/01_sheep_vincent_1204.png');
}
.character-katherine {
@include characterImage('https://static.catherine-fc.com/media/02_katherine_1204.png');
}
.character-catherine {
@include characterImage('https://static.catherine-fc.com/media/03_catherine_1204.png');
}
.character-rin {
@include characterImage('https://static.catherine-fc.com/media/04_rin_1204.png');
}
.character-orlando {
@include characterImage('https://static.catherine-fc.com/media/05_orlando_1204.png');
}
.character-johny {
@include characterImage('https://static.catherine-fc.com/media/06_jonny_1204.png');
}
.character-tobby {
@include characterImage('https://static.catherine-fc.com/media/07_tobby_1204.png');
}
.character-erica {
@include characterImage('https://static.catherine-fc.com/media/08_erika_1204.png');
}
.character-master {
@include characterImage('https://static.catherine-fc.com/media/09_master_1204.png');
}
.character-joker {
@include characterImage('https://static.catherine-fc.com/media/13_joker_1204.png');
}
.titlePlayer {
position: relative;
display: flex;
margin-bottom: 10px;
}
.titlePlayerBlock {
padding: 5px 10px;
background-color: white;
transform: rotate(-5deg);
color: black;
font-family: var(--font-mplus), sans-serif;
}
.aboutNoticeBlock {
position: relative;
width: 980px;
height: 800px;
margin: 20px auto;
padding: 50px;
background-image: url('https://static.catherine-fc.com/media/playerbg.png');
background-repeat: none;
background-size: 980px 800px;
font-family: var(--font-mplus), sans-serif;
}
.sheepAbout {
position: relative;
width: 207px;
height: 350px;
float: left;
margin-top: 70px;
bottom: 0;
left: 0;
background-image: url('https://static.catherine-fc.com/media/title_sheep.png');
background-repeat: none;
}
.guideBody {
position: relative;
margin: 20px auto;
width: 980px;
}
.centerVideo {
width: 100%;
display: flex;
justify-content: center;
}
.aboutContactBox {
position: relative;
width: 128px;
height: 128px;
margin: 20px;
background-image: url('https://static.catherine-fc.com/media/letterbox.png');
background-size: 128px 128px;
background-repeat: none;
& > a {
display: block;
width: 128px;
height: 128px;
}
}
.contactBox {
position: relative;
width: 128px;
height: 128px;
margin: 20px auto;
background-image: url('https://static.catherine-fc.com/media/letterbox.png');
background-size: 128px 128px;
background-repeat: none;
& > a {
display: block;
width: 128px;
height: 128px;
}
}
.mainBody {
position: flex;
margin: 20px auto;
width: 980px;
align-items: center;
justify-content: center;
}
.chalice {
width: 400px;
height: 340px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/chalice.png');
background-size: contain;
background-repeat: none;
}
.evojapan2023 {
width: 500px;
height: 471px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/evo_logo_shadow_drop.png');
background-size: contain;
background-repeat: none;
}
.evojapan2024 {
width: 500px;
height: 471px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/evo_2024_logo_shadow_drop.png');
background-size: contain;
background-repeat: none;
}
.evojapan2025 {
width: 500px;
height: 471px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/evo_logo_2025_shadow_drop.png');
background-size: contain;
background-repeat: none;
}
.evojapan2026 {
width: 500px;
height: 471px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/evo_logo_2026_shadow_drop.png');
background-size: contain;
background-repeat: none;
}
.evojapan2023kaisaikettei {
width: 846px;
height: 285px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/kaisaikettei3.png');
background-size: contain;
}
.evojapan2023catherinetonamel {
width: 412px;
height: 389px;
background-image: url('https://static.catherine-fc.com/media/catherine_logo_classic.png');
background-size: contain;
background-repeat: none;
&> a {
display:block;
width: 412px;
height: 389px;
}
}
.evojapan2023catherinefullbodytonamel {
width: 517px;
height: 319px;
background-image: url('https://static.catherine-fc.com/media/catherine_fullbody_logo.png');
background-size: contain;
background-repeat: none;
&> a {
display:block;
width: 517px;
height: 319px;
}
}
.group {
width: 900px;
height: 389px;
margin: auto;
display: flex;
background-size: contain;
background-repeat: none;
}
.players {
width: 900px;
height: 606px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/players.png');
background-size: contain;
background-repeat: none;
}
.scoreboardImage {
width: 567px;
height: 485px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/scoreboard.png');
background-size: contain;
background-repeat: none;
}
.titleImageStrayShip0 {
width: 808px;
height: 119px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/straysheepcup0.png');
background-size: contain;
background-repeat: none;
}
.titleImageStrayShip4 {
width: 808px;
height: 119px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/straysheepcup4.png');
background-size: contain;
background-repeat: none;
}
.rule {
width: 900px;
height: 637px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/rule.png');
background-size: contain;
background-repeat: none;
}
.tonamel {
width: 900px;
height: 637px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/tonamel.png');
background-size: contain;
background-repeat: none;
&> a {
display:block;
width: 900px;
height: 637px;
}
}
.padding {
width: 900px;
height: 100px;
margin: auto;
}
.players0801 {
width: 899px;
height: 696px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/0801.png');
background-size: contain;
background-repeat: none;
}
.players0802 {
width: 900px;
height: 710px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/0802.png');
background-size: contain;
background-repeat: none;
}
.players0808 {
width: 900px;
height: 710px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/0808.png');
background-size: contain;
background-repeat: none;
}
.players0809 {
width: 900px;
height: 696px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/0809.png');
background-size: contain;
background-repeat: none;
}
.players0815 {
width: 900px;
height: 696px;
margin: auto;
background-image: url('https://static.catherine-fc.com/media/0815.png');
background-size: contain;
background-repeat: none;
}

47
nextjs/tailwind.config.ts Normal file
View File

@@ -0,0 +1,47 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
theme: {
extend: {
fontFamily: {
indie: ['var(--font-indie)', 'cursive'],
mplus: ['var(--font-mplus)', 'sans-serif'],
},
keyframes: {
moveforever: {
'0%': { transform: 'translate3d(-90px, 0, 0)' },
'100%': { transform: 'translate3d(85px, 0, 0)' },
},
bounce: {
'0%, 20%, 60%, 100%': { transform: 'translateY(0)' },
'40%': { transform: 'translateY(-20px)' },
'80%': { transform: 'translateY(-10px)' },
},
slideInFromLeft: {
'0%': { transform: 'translateX(-100%)' },
'100%': { opacity: '1', transform: 'translateX(0)' },
},
slideInFromRight: {
'0%': { transform: 'translateX(100%)' },
'100%': { opacity: '1', transform: 'translateX(0)' },
},
upAndDown: {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-5%)' },
},
},
animation: {
'up-down': 'upAndDown 10s linear infinite',
'wave-1': 'moveforever 2s cubic-bezier(0.55, 0.5, 0.45, 0.5) -2s infinite',
'wave-2': 'moveforever 5s cubic-bezier(0.55, 0.5, 0.45, 0.5) -4s infinite',
bounce: 'bounce 1s',
'slide-in-l': 'slideInFromLeft 1s forwards',
'slide-in-r': 'slideInFromRight 1s forwards',
},
},
},
plugins: [],
};
export default config;