refactor: standardize code formatting across components and utilities
Some checks failed
Push to github container register / push-docker (push) Has been cancelled

- Updated formatting in SiteFooter and SiteHeader components for consistency.
- Refactored Badge, Button, Card, Separator components to improve readability.
- Enhanced markdown parsing logic in announcements and markdown utility functions.
- Adjusted ESLint configuration for better code quality checks.
- Added biome.json for BiomeJS configuration.
- Updated package.json and configuration files for improved dependency management.
This commit is contained in:
2026-04-06 03:44:49 +00:00
parent 8624d2c805
commit 88233ff069
24 changed files with 935 additions and 869 deletions

View File

@@ -3,64 +3,75 @@ import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { getAnnouncementBySlug, getAnnouncementSlugs } from "@/lib/announcements"; import {
getAnnouncementBySlug,
getAnnouncementSlugs,
} from "@/lib/announcements";
type PageProps = { type PageProps = {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
}; };
export async function generateStaticParams() { export async function generateStaticParams() {
const slugs = await getAnnouncementSlugs(); const slugs = await getAnnouncementSlugs();
return slugs.map((slug) => ({ slug })); return slugs.map((slug) => ({ slug }));
} }
export async function generateMetadata({ params }: PageProps): Promise<Metadata> { export async function generateMetadata({
const { slug } = await params; params,
}: PageProps): Promise<Metadata> {
const { slug } = await params;
try { try {
const announcement = await getAnnouncementBySlug(slug); const announcement = await getAnnouncementBySlug(slug);
return { return {
title: `${announcement.title} | お知らせ`, title: `${announcement.title} | お知らせ`,
description: announcement.summary || `${announcement.title} のお知らせです。`, description:
}; announcement.summary || `${announcement.title} のお知らせです。`,
} catch { };
return { } catch {
title: "お知らせ", return {
description: "お知らせページ", title: "お知らせ",
}; description: "お知らせページ",
} };
}
} }
export default async function AnnouncementDetailPage({ params }: PageProps) { export default async function AnnouncementDetailPage({ params }: PageProps) {
const { slug } = await params; const { slug } = await params;
let announcement; let announcement;
try { try {
announcement = await getAnnouncementBySlug(slug); announcement = await getAnnouncementBySlug(slug);
} catch { } catch {
notFound(); notFound();
} }
return ( return (
<article className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 px-4 py-8 sm:px-8 sm:py-12"> <article className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 px-4 py-8 sm:px-8 sm:py-12">
<header className="rounded-3xl border bg-card/70 p-6 shadow-sm backdrop-blur motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-3 motion-safe:duration-700 sm:p-8"> <header className="rounded-3xl border bg-card/70 p-6 shadow-sm backdrop-blur motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-3 motion-safe:duration-700 sm:p-8">
<Badge variant="outline" className="mb-3"> <Badge variant="outline" className="mb-3">
</Badge> </Badge>
<p className="text-xs text-muted-foreground">{announcement.date}</p> <p className="text-xs text-muted-foreground">{announcement.date}</p>
<h1 className="mt-2 text-2xl font-semibold tracking-tight sm:text-4xl">{announcement.title}</h1> <h1 className="mt-2 text-2xl font-semibold tracking-tight sm:text-4xl">
</header> {announcement.title}
</h1>
</header>
<section className="rounded-3xl border bg-card p-6 shadow-sm motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2 motion-safe:duration-700 sm:p-8"> <section className="rounded-3xl border bg-card p-6 shadow-sm motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2 motion-safe:duration-700 sm:p-8">
<div <div
className="markdown-body space-y-4 text-sm leading-7 sm:text-base [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-4 [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_h1]:text-2xl [&_h1]:font-semibold [&_h2]:text-xl [&_h2]:font-semibold [&_h3]:text-lg [&_h3]:font-semibold [&_li]:ml-5 [&_ol]:list-decimal [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-3 [&_ul]:list-disc" className="markdown-body space-y-4 text-sm leading-7 sm:text-base [&_a]:text-primary [&_a]:underline [&_a]:underline-offset-4 [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_code]:rounded [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_h1]:text-2xl [&_h1]:font-semibold [&_h2]:text-xl [&_h2]:font-semibold [&_h3]:text-lg [&_h3]:font-semibold [&_li]:ml-5 [&_ol]:list-decimal [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:bg-muted [&_pre]:p-3 [&_ul]:list-disc"
dangerouslySetInnerHTML={{ __html: announcement.contentHtml }} dangerouslySetInnerHTML={{ __html: announcement.contentHtml }}
/> />
</section> </section>
<Link href="/announcements" className="text-sm text-primary underline underline-offset-4 transition-transform duration-200 hover:-translate-y-0.5"> <Link
href="/announcements"
</Link> className="text-sm text-primary underline underline-offset-4 transition-transform duration-200 hover:-translate-y-0.5"
</article> >
);
</Link>
</article>
);
} }

View File

@@ -1,10 +1,10 @@
export default function AnnouncementsLoading() { export default function AnnouncementsLoading() {
return ( return (
<div className="mx-auto flex w-full max-w-5xl flex-1 items-center justify-center px-4 py-12 sm:px-8"> <div className="mx-auto flex w-full max-w-5xl flex-1 items-center justify-center px-4 py-12 sm:px-8">
<div className="flex items-center gap-3 rounded-xl border bg-card px-5 py-3 text-sm text-muted-foreground shadow-sm"> <div className="flex items-center gap-3 rounded-xl border bg-card px-5 py-3 text-sm text-muted-foreground shadow-sm">
<span className="inline-block size-2.5 animate-pulse rounded-full bg-primary" /> <span className="inline-block size-2.5 animate-pulse rounded-full bg-primary" />
... ...
</div> </div>
</div> </div>
); );
} }

View File

@@ -4,43 +4,48 @@ import { Badge } from "@/components/ui/badge";
import { getAllAnnouncements } from "@/lib/announcements"; import { getAllAnnouncements } from "@/lib/announcements";
export const metadata = { export const metadata = {
title: "お知らせ | Takasumi-Neodyマイクラサーバプロジェクト", title: "お知らせ | Takasumi-Neodyマイクラサーバプロジェクト",
description: "Takasumi-Neodyマイクラサーバプロジェクトのお知らせ一覧です。", description: "Takasumi-Neodyマイクラサーバプロジェクトのお知らせ一覧です。",
}; };
export default async function AnnouncementsPage() { export default async function AnnouncementsPage() {
const announcements = await getAllAnnouncements(); const announcements = await getAllAnnouncements();
return ( return (
<div className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 px-4 py-8 sm:px-8 sm:py-12"> <div className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 px-4 py-8 sm:px-8 sm:py-12">
<header className="rounded-3xl border bg-card/70 p-6 shadow-sm backdrop-blur motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-3 motion-safe:duration-700 sm:p-8"> <header className="rounded-3xl border bg-card/70 p-6 shadow-sm backdrop-blur motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-3 motion-safe:duration-700 sm:p-8">
<Badge variant="outline" className="mb-3"> <Badge variant="outline" className="mb-3">
</Badge> </Badge>
<h1 className="text-3xl font-semibold tracking-tight sm:text-4xl"></h1> <h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">
<p className="mt-3 text-sm leading-7 text-muted-foreground sm:text-base">
Markdown </h1>
</p> <p className="mt-3 text-sm leading-7 text-muted-foreground sm:text-base">
</header> Markdown
</p>
</header>
<section className="grid gap-4"> <section className="grid gap-4">
{announcements.map((item) => ( {announcements.map((item) => (
<article className="rounded-2xl border border-foreground/10 bg-card p-5 shadow-xs transition-all duration-300 motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-4 hover:-translate-y-1 hover:shadow-md sm:p-6" key={item.slug}> <article
<header> className="rounded-2xl border border-foreground/10 bg-card p-5 shadow-xs transition-all duration-300 motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-4 hover:-translate-y-1 hover:shadow-md sm:p-6"
<p className="text-xs text-muted-foreground">{item.date}</p> key={item.slug}
<h2 className="mt-1 text-xl font-semibold"> >
<Link <header>
href={`/announcements/${item.slug}`} <p className="text-xs text-muted-foreground">{item.date}</p>
className="underline-offset-4 transition-colors duration-200 hover:text-primary hover:underline" <h2 className="mt-1 text-xl font-semibold">
> <Link
{item.title} href={`/announcements/${item.slug}`}
</Link> className="underline-offset-4 transition-colors duration-200 hover:text-primary hover:underline"
</h2> >
</header> {item.title}
<p className="mt-3 text-sm text-muted-foreground">{item.summary}</p> </Link>
</article> </h2>
))} </header>
</section> <p className="mt-3 text-sm text-muted-foreground">{item.summary}</p>
</div> </article>
); ))}
</section>
</div>
);
} }

View File

@@ -5,126 +5,126 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme inline { @theme inline {
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--font-sans: var(--font-sans); --font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono); --font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans); --font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring); --color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent); --color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary); --color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar); --color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5); --color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4); --color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3); --color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2); --color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1); --color-chart-1: var(--chart-1);
--color-ring: var(--ring); --color-ring: var(--ring);
--color-input: var(--input); --color-input: var(--input);
--color-border: var(--border); --color-border: var(--border);
--color-destructive: var(--destructive); --color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground); --color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent); --color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground); --color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted); --color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground); --color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary); --color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground); --color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary); --color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground); --color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover); --color-popover: var(--popover);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-card: var(--card); --color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6); --radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8); --radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4); --radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8); --radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2); --radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6); --radius-4xl: calc(var(--radius) * 2.6);
} }
:root { :root {
--background: oklch(1 0 0); --background: oklch(1 0 0);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0); --popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0); --popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0); --primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0); --secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0); --secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0); --muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0); --muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0); --accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0); --accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0); --border: oklch(0.922 0 0);
--input: oklch(0.922 0 0); --input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0); --ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0); --chart-5: oklch(0.269 0 0);
--radius: 0.625rem; --radius: 0.625rem;
--sidebar: oklch(0.985 0 0); --sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0); --sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0); --sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0); --sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0); --sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0); --sidebar-ring: oklch(0.708 0 0);
} }
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0); --card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0); --popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0); --popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0); --primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0); --primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0); --secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0); --muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0); --muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0); --accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0); --accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%); --border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%); --input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0); --ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0); --chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0); --chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0); --chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0); --sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0); --sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0); --sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0); --sidebar-ring: oklch(0.556 0 0);
} }
@layer base { @layer base {
* { * {
@apply border-border outline-ring/50; @apply border-border outline-ring/50;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;
} }
html { html {
@apply font-sans; @apply font-sans;
} }
} }

View File

@@ -5,38 +5,46 @@ import { cn } from "@/lib/utils";
import { SiteHeader } from "@/components/site-header"; import { SiteHeader } from "@/components/site-header";
import { SiteFooter } from "@/components/site-footer"; import { SiteFooter } from "@/components/site-footer";
const inter = Inter({subsets:['latin'],variable:'--font-sans'}); const inter = Inter({ subsets: ["latin"], variable: "--font-sans" });
const geistSans = Geist({ const geistSans = Geist({
variable: "--font-geist-sans", variable: "--font-geist-sans",
subsets: ["latin"], subsets: ["latin"],
}); });
const geistMono = Geist_Mono({ const geistMono = Geist_Mono({
variable: "--font-geist-mono", variable: "--font-geist-mono",
subsets: ["latin"], subsets: ["latin"],
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Takasumi-Neodyマイクラサーバプロジェクト接続ガイド", title: "Takasumi-Neodyマイクラサーバプロジェクト接続ガイド",
description: "Takasumi-Neodyマイクラサーバプロジェクトサバイバル鯖・建築鯖への接続方法とサーバアドレスを案内します。", description:
"Takasumi-Neodyマイクラサーバプロジェクトサバイバル鯖・建築鯖への接続方法とサーバアドレスを案内します。",
}; };
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
return ( return (
<html <html
lang="ja" lang="ja"
className={cn("h-full", "antialiased", geistSans.variable, geistMono.variable, "font-sans", inter.variable)} className={cn(
> "h-full",
<body className="min-h-full flex flex-col"> "antialiased",
<SiteHeader /> geistSans.variable,
{children} geistMono.variable,
<SiteFooter /> "font-sans",
</body> inter.variable,
</html> )}
); >
<body className="min-h-full flex flex-col">
<SiteHeader />
{children}
<SiteFooter />
</body>
</html>
);
} }

View File

@@ -1,10 +1,10 @@
export default function Loading() { export default function Loading() {
return ( return (
<div className="mx-auto flex w-full max-w-6xl flex-1 items-center justify-center px-4 py-16 sm:px-8"> <div className="mx-auto flex w-full max-w-6xl flex-1 items-center justify-center px-4 py-16 sm:px-8">
<div className="flex items-center gap-3 rounded-xl border bg-card px-5 py-3 text-sm text-muted-foreground shadow-sm"> <div className="flex items-center gap-3 rounded-xl border bg-card px-5 py-3 text-sm text-muted-foreground shadow-sm">
<span className="inline-block size-2.5 animate-pulse rounded-full bg-primary" /> <span className="inline-block size-2.5 animate-pulse rounded-full bg-primary" />
Loading... Loading...
</div> </div>
</div> </div>
); );
} }

View File

@@ -9,104 +9,116 @@ import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const servers = [ const servers = [
{ {
name: "サバイバル鯖", name: "サバイバル鯖",
description: "採掘・冒険・建築を楽しむ通常ワールドです。", description: "採掘・冒険・建築を楽しむ通常ワールドです。",
address: "survival.mc.neody.ad.jp", address: "survival.mc.neody.ad.jp",
icon: Sprout, icon: Sprout,
badgeVariant: "default" as const, badgeVariant: "default" as const,
}, },
{ {
name: "建築鯖", name: "建築鯖",
description: "大型建築や街づくり向けのクリエイティブ環境です。", description: "大型建築や街づくり向けのクリエイティブ環境です。",
address: "kenchiku.mc.neody.ad.jp", address: "kenchiku.mc.neody.ad.jp",
icon: Pickaxe, icon: Pickaxe,
badgeVariant: "secondary" as const, badgeVariant: "secondary" as const,
}, },
]; ];
export default function Home() { export default function Home() {
return ( return (
<div className="relative flex min-h-full flex-1 flex-col overflow-x-clip bg-background"> <div className="relative flex min-h-full flex-1 flex-col overflow-x-clip bg-background">
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[380px] bg-gradient-to-b from-emerald-200/40 via-sky-200/25 to-transparent blur-3xl dark:from-emerald-500/20 dark:via-sky-500/20" /> <div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[380px] bg-gradient-to-b from-emerald-200/40 via-sky-200/25 to-transparent blur-3xl dark:from-emerald-500/20 dark:via-sky-500/20" />
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-8 px-4 py-8 sm:gap-10 sm:px-8 sm:py-14"> <main className="mx-auto flex w-full max-w-6xl flex-1 flex-col gap-8 px-4 py-8 sm:gap-10 sm:px-8 sm:py-14">
<section className="rounded-3xl border bg-card/70 p-6 shadow-sm backdrop-blur motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-3 motion-safe:duration-700 sm:p-10"> <section className="rounded-3xl border bg-card/70 p-6 shadow-sm backdrop-blur motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-3 motion-safe:duration-700 sm:p-10">
<Badge variant="outline" className="mb-4 max-w-full text-[11px] sm:text-xs"> <Badge
Takasumi-Neodyマイクラサーバプロジェクト接続ガイド variant="outline"
</Badge> className="mb-4 max-w-full text-[11px] sm:text-xs"
<h1 className="text-3xl font-semibold tracking-tight sm:text-5xl"> >
Minecraft Takasumi-Neodyマイクラサーバプロジェクト接続ガイド
</h1> </Badge>
<p className="mt-4 max-w-3xl text-sm leading-7 text-muted-foreground sm:text-base"> <h1 className="text-3xl font-semibold tracking-tight sm:text-5xl">
Java版 Minecraft
</h1>
</p> <p className="mt-4 max-w-3xl text-sm leading-7 text-muted-foreground sm:text-base">
<div className="mt-6"> Java版
<Link
href="/announcements" </p>
className={cn( <div className="mt-6">
buttonVariants({ variant: "outline" }), <Link
"transition-transform duration-200 hover:-translate-y-0.5" href="/announcements"
)} className={cn(
> buttonVariants({ variant: "outline" }),
"transition-transform duration-200 hover:-translate-y-0.5",
</Link> )}
</div> >
</section>
</Link>
</div>
</section>
<section className="grid gap-4 md:grid-cols-2"> <section className="grid gap-4 md:grid-cols-2">
{servers.map((server) => { {servers.map((server) => {
const Icon = server.icon; const Icon = server.icon;
return ( return (
<article <article
key={server.name} key={server.name}
className="rounded-2xl border border-foreground/10 bg-card p-5 shadow-xs transition-all duration-300 motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-4 hover:-translate-y-1 hover:shadow-md sm:p-6" className="rounded-2xl border border-foreground/10 bg-card p-5 shadow-xs transition-all duration-300 motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-4 hover:-translate-y-1 hover:shadow-md sm:p-6"
> >
<header> <header>
<div className="mb-2 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3"> <div className="mb-2 flex flex-col items-start gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<Badge variant={server.badgeVariant}>{server.name}</Badge> <Badge variant={server.badgeVariant}>{server.name}</Badge>
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground"> <span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Wifi className="size-3.5" /> <Wifi className="size-3.5" />
Java版 / Java版 /
</span> </span>
</div> </div>
<h3 className="flex items-center gap-2 text-xl font-semibold"> <h3 className="flex items-center gap-2 text-xl font-semibold">
<Icon className="size-5" /> <Icon className="size-5" />
{server.name} {server.name}
</h3> </h3>
<p className="mt-1 text-sm text-muted-foreground">{server.description}</p> <p className="mt-1 text-sm text-muted-foreground">
</header> {server.description}
<div className="mt-5"> </p>
<div className="rounded-xl border bg-background px-4 py-3"> </header>
<p className="text-xs text-muted-foreground"></p> <div className="mt-5">
<p className="mt-1 break-all font-mono text-sm">{server.address}</p> <div className="rounded-xl border bg-background px-4 py-3">
</div> <p className="text-xs text-muted-foreground">
</div>
<div className="mt-5 flex gap-2"> </p>
<a <p className="mt-1 break-all font-mono text-sm">
className={cn(buttonVariants(), "w-full sm:w-auto")} {server.address}
href={`minecraft://?addExternalServer=${server.name}|${server.address}`} </p>
> </div>
Minecraft </div>
<ExternalLink className="size-4" /> <div className="mt-5 flex gap-2">
</a> <a
</div> className={cn(buttonVariants(), "w-full sm:w-auto")}
</article> href={`minecraft://?addExternalServer=${server.name}|${server.address}`}
); >
})} Minecraft
</section> <ExternalLink className="size-4" />
</a>
</div>
</article>
);
})}
</section>
<section className="rounded-3xl border bg-card p-6 shadow-sm motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2 motion-safe:duration-700 sm:p-8"> <section className="rounded-3xl border bg-card p-6 shadow-sm motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2 motion-safe:duration-700 sm:p-8">
<h2 className="text-xl font-semibold"></h2> <h2 className="text-xl font-semibold"></h2>
<Separator className="my-4" /> <Separator className="my-4" />
<ol className="space-y-3 text-sm leading-7 sm:text-base"> <ol className="space-y-3 text-sm leading-7 sm:text-base">
<li>1. MinecraftJava版 </li> <li>1. MinecraftJava版 </li>
<li>2. Java版: マルチプレイ / 統合版: サーバー</li> <li>
<li>3. </li> 2. Java版: マルチプレイ / 統合版:
<li>4. </li> サーバー
</ol> </li>
</section> <li>3. </li>
</main> <li>4. </li>
</div> </ol>
); </section>
</main>
</div>
);
} }

View File

@@ -5,42 +5,46 @@ import { getAllAnnouncements } from "@/lib/announcements";
export const dynamic = "force-static"; export const dynamic = "force-static";
const SITE_URL = const SITE_URL =
process.env.NEXT_PUBLIC_SITE_URL ?? process.env.NEXT_PUBLIC_SITE_URL ??
process.env.SITE_URL ?? process.env.SITE_URL ??
"https://mc.neody.ad.jp"; "https://mc.neody.ad.jp";
function toAbsoluteUrl(pathname: string): string { function toAbsoluteUrl(pathname: string): string {
return new URL(pathname, SITE_URL).toString(); return new URL(pathname, SITE_URL).toString();
} }
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const announcements = await getAllAnnouncements(); const announcements = await getAllAnnouncements();
const staticRoutes: MetadataRoute.Sitemap = [ const staticRoutes: MetadataRoute.Sitemap = [
{ {
url: toAbsoluteUrl("/"), url: toAbsoluteUrl("/"),
lastModified: new Date(), lastModified: new Date(),
changeFrequency: "weekly", changeFrequency: "weekly",
priority: 1, priority: 1,
}, },
{ {
url: toAbsoluteUrl("/announcements/"), url: toAbsoluteUrl("/announcements/"),
lastModified: new Date(), lastModified: new Date(),
changeFrequency: "daily", changeFrequency: "daily",
priority: 0.8, priority: 0.8,
}, },
]; ];
const announcementRoutes: MetadataRoute.Sitemap = announcements.map((item) => { const announcementRoutes: MetadataRoute.Sitemap = announcements.map(
const parsedDate = new Date(item.date); (item) => {
const parsedDate = new Date(item.date);
return { return {
url: toAbsoluteUrl(`/announcements/${item.slug}/`), url: toAbsoluteUrl(`/announcements/${item.slug}/`),
lastModified: Number.isNaN(parsedDate.getTime()) ? new Date() : parsedDate, lastModified: Number.isNaN(parsedDate.getTime())
changeFrequency: "monthly", ? new Date()
priority: 0.7, : parsedDate,
}; changeFrequency: "monthly",
}); priority: 0.7,
};
},
);
return [...staticRoutes, ...announcementRoutes]; return [...staticRoutes, ...announcementRoutes];
} }

8
biome.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
"css": {
"parser": {
"tailwindDirectives": true
}
}
}

View File

@@ -1,25 +1,25 @@
{ {
"$schema": "https://ui.shadcn.com/schema.json", "$schema": "https://ui.shadcn.com/schema.json",
"style": "base-vega", "style": "base-vega",
"rsc": true, "rsc": true,
"tsx": true, "tsx": true,
"tailwind": { "tailwind": {
"config": "", "config": "",
"css": "app/globals.css", "css": "app/globals.css",
"baseColor": "neutral", "baseColor": "neutral",
"cssVariables": true, "cssVariables": true,
"prefix": "" "prefix": ""
}, },
"iconLibrary": "lucide", "iconLibrary": "lucide",
"rtl": false, "rtl": false,
"aliases": { "aliases": {
"components": "@/components", "components": "@/components",
"utils": "@/lib/utils", "utils": "@/lib/utils",
"ui": "@/components/ui", "ui": "@/components/ui",
"lib": "@/lib", "lib": "@/lib",
"hooks": "@/hooks" "hooks": "@/hooks"
}, },
"menuColor": "default", "menuColor": "default",
"menuAccent": "subtle", "menuAccent": "subtle",
"registries": {} "registries": {}
} }

View File

@@ -1,17 +1,17 @@
export function SiteFooter() { export function SiteFooter() {
return ( return (
<footer className="border-t bg-card/60"> <footer className="border-t bg-card/60">
<div className="mx-auto flex w-full max-w-6xl items-center justify-center px-4 py-4 text-center text-xs text-muted-foreground sm:px-8 sm:text-sm"> <div className="mx-auto flex w-full max-w-6xl items-center justify-center px-4 py-4 text-center text-xs text-muted-foreground sm:px-8 sm:text-sm">
: :
<a <a
href="https://neody.land/ja" href="https://neody.land/ja"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="ml-1 font-medium text-foreground underline underline-offset-4 hover:text-primary" className="ml-1 font-medium text-foreground underline underline-offset-4 hover:text-primary"
> >
Neodyland Neodyland
</a> </a>
</div> </div>
</footer> </footer>
); );
} }

View File

@@ -1,30 +1,30 @@
import Link from "next/link"; import Link from "next/link";
export function SiteHeader() { export function SiteHeader() {
return ( return (
<header className="sticky top-0 z-40 border-b bg-background/85 backdrop-blur motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-top-2 motion-safe:duration-500"> <header className="sticky top-0 z-40 border-b bg-background/85 backdrop-blur motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-top-2 motion-safe:duration-500">
<div className="mx-auto flex w-full max-w-6xl items-center justify-between px-4 py-3 sm:px-8"> <div className="mx-auto flex w-full max-w-6xl items-center justify-between px-4 py-3 sm:px-8">
<Link <Link
href="/" href="/"
className="text-sm font-semibold tracking-tight transition-colors duration-200 hover:text-primary sm:text-base" className="text-sm font-semibold tracking-tight transition-colors duration-200 hover:text-primary sm:text-base"
> >
Takasumi-Neodyマイクラサーバプロジェクト Takasumi-Neodyマイクラサーバプロジェクト
</Link> </Link>
<nav className="flex items-center gap-4 text-sm"> <nav className="flex items-center gap-4 text-sm">
<Link <Link
href="/" href="/"
className="underline-offset-4 transition-colors duration-200 hover:text-primary hover:underline" className="underline-offset-4 transition-colors duration-200 hover:text-primary hover:underline"
> >
</Link> </Link>
<Link <Link
href="/announcements" href="/announcements"
className="underline-offset-4 transition-colors duration-200 hover:text-primary hover:underline" className="underline-offset-4 transition-colors duration-200 hover:text-primary hover:underline"
> >
</Link> </Link>
</nav> </nav>
</div> </div>
</header> </header>
); );
} }

View File

@@ -1,52 +1,52 @@
import { mergeProps } from "@base-ui/react/merge-props" import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render" import { useRender } from "@base-ui/react/use-render";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const badgeVariants = cva( const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary: secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive: destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline: outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
}, },
} },
) );
function Badge({ function Badge({
className, className,
variant = "default", variant = "default",
render, render,
...props ...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) { }: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({ return useRender({
defaultTagName: "span", defaultTagName: "span",
props: mergeProps<"span">( props: mergeProps<"span">(
{ {
className: cn(badgeVariants({ variant }), className), className: cn(badgeVariants({ variant }), className),
}, },
props props,
), ),
render, render,
state: { state: {
slot: "badge", slot: "badge",
variant, variant,
}, },
}) });
} }
export { Badge, badgeVariants } export { Badge, badgeVariants };

View File

@@ -1,60 +1,60 @@
"use client" "use client";
import { Button as ButtonPrimitive } from "@base-ui/react/button" import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
const buttonVariants = cva( const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "group/button inline-flex shrink-0 items-center justify-center rounded-md border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80", default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline: outline:
"border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", "border-border bg-background shadow-xs hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary: secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost: ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive: destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
}, },
size: { size: {
default: default:
"h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", "h-9 gap-1.5 px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", xs: "h-6 gap-1 rounded-[min(var(--radius-md),8px)] px-2 text-xs in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5", sm: "h-8 gap-1 rounded-[min(var(--radius-md),10px)] px-2.5 in-data-[slot=button-group]:rounded-md has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3", lg: "h-10 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
icon: "size-9", icon: "size-9",
"icon-xs": "icon-xs":
"size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3", "size-6 rounded-[min(var(--radius-md),8px)] in-data-[slot=button-group]:rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "icon-sm":
"size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md", "size-8 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-md",
"icon-lg": "size-10", "icon-lg": "size-10",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "default", variant: "default",
size: "default", size: "default",
}, },
} },
) );
function Button({ function Button({
className, className,
variant = "default", variant = "default",
size = "default", size = "default",
...props ...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) { }: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return ( return (
<ButtonPrimitive <ButtonPrimitive
data-slot="button" data-slot="button"
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
/> />
) );
} }
export { Button, buttonVariants } export { Button, buttonVariants };

View File

@@ -1,103 +1,103 @@
import * as React from "react" import * as React from "react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Card({ function Card({
className, className,
size = "default", size = "default",
...props ...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { }: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return ( return (
<div <div
data-slot="card" data-slot="card"
data-size={size} data-size={size}
className={cn( className={cn(
"group/card flex flex-col gap-6 overflow-hidden rounded-xl bg-card py-6 text-sm text-card-foreground shadow-xs ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", "group/card flex flex-col gap-6 overflow-hidden rounded-xl bg-card py-6 text-sm text-card-foreground shadow-xs ring-1 ring-foreground/10 has-[>img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardHeader({ className, ...props }: React.ComponentProps<"div">) { function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-6 group-data-[size=sm]/card:px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4", "group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-6 group-data-[size=sm]/card:px-4 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-6 group-data-[size=sm]/card:[.border-b]:pb-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardTitle({ className, ...props }: React.ComponentProps<"div">) { function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-title" data-slot="card-title"
className={cn( className={cn(
"font-heading text-base leading-normal font-medium group-data-[size=sm]/card:text-sm", "font-heading text-base leading-normal font-medium group-data-[size=sm]/card:text-sm",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardDescription({ className, ...props }: React.ComponentProps<"div">) { function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-description" data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)} className={cn("text-sm text-muted-foreground", className)}
{...props} {...props}
/> />
) );
} }
function CardAction({ className, ...props }: React.ComponentProps<"div">) { function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-action" data-slot="card-action"
className={cn( className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end", "col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-content" data-slot="card-content"
className={cn("px-6 group-data-[size=sm]/card:px-4", className)} className={cn("px-6 group-data-[size=sm]/card:px-4", className)}
{...props} {...props}
/> />
) );
} }
function CardFooter({ className, ...props }: React.ComponentProps<"div">) { function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="card-footer" data-slot="card-footer"
className={cn( className={cn(
"flex items-center rounded-b-xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4", "flex items-center rounded-b-xl px-6 group-data-[size=sm]/card:px-4 [.border-t]:pt-6 group-data-[size=sm]/card:[.border-t]:pt-4",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
Card, Card,
CardHeader, CardHeader,
CardFooter, CardFooter,
CardTitle, CardTitle,
CardAction, CardAction,
CardDescription, CardDescription,
CardContent, CardContent,
} };

View File

@@ -1,25 +1,25 @@
"use client" "use client";
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator" import { Separator as SeparatorPrimitive } from "@base-ui/react/separator";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Separator({ function Separator({
className, className,
orientation = "horizontal", orientation = "horizontal",
...props ...props
}: SeparatorPrimitive.Props) { }: SeparatorPrimitive.Props) {
return ( return (
<SeparatorPrimitive <SeparatorPrimitive
data-slot="separator" data-slot="separator"
orientation={orientation} orientation={orientation}
className={cn( className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch", "shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { Separator } export { Separator };

View File

@@ -3,16 +3,16 @@ import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript"; import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([ const eslintConfig = defineConfig([
...nextVitals, ...nextVitals,
...nextTs, ...nextTs,
// Override default ignores of eslint-config-next. // Override default ignores of eslint-config-next.
globalIgnores([ globalIgnores([
// Default ignores of eslint-config-next: // Default ignores of eslint-config-next:
".next/**", ".next/**",
"out/**", "out/**",
"build/**", "build/**",
"next-env.d.ts", "next-env.d.ts",
]), ]),
]); ]);
export default eslintConfig; export default eslintConfig;

View File

@@ -6,88 +6,95 @@ import { markdownToHtml } from "@/lib/markdown";
const ANNOUNCEMENTS_DIR = path.join(process.cwd(), "content", "announcements"); const ANNOUNCEMENTS_DIR = path.join(process.cwd(), "content", "announcements");
type AnnouncementFrontmatter = { type AnnouncementFrontmatter = {
title?: string; title?: string;
date?: string; date?: string;
summary?: string; summary?: string;
}; };
export type AnnouncementListItem = { export type AnnouncementListItem = {
slug: string; slug: string;
title: string; title: string;
date: string; date: string;
summary: string; summary: string;
}; };
export type AnnouncementDetail = AnnouncementListItem & { export type AnnouncementDetail = AnnouncementListItem & {
contentHtml: string; contentHtml: string;
}; };
function parseFrontmatter(markdown: string): { function parseFrontmatter(markdown: string): {
frontmatter: AnnouncementFrontmatter; frontmatter: AnnouncementFrontmatter;
body: string; body: string;
} { } {
if (!markdown.startsWith("---\n")) { if (!markdown.startsWith("---\n")) {
return { frontmatter: {}, body: markdown }; return { frontmatter: {}, body: markdown };
} }
const end = markdown.indexOf("\n---\n", 4); const end = markdown.indexOf("\n---\n", 4);
if (end === -1) { if (end === -1) {
return { frontmatter: {}, body: markdown }; return { frontmatter: {}, body: markdown };
} }
const rawFrontmatter = markdown.slice(4, end); const rawFrontmatter = markdown.slice(4, end);
const body = markdown.slice(end + 5); const body = markdown.slice(end + 5);
const frontmatter: AnnouncementFrontmatter = {}; const frontmatter: AnnouncementFrontmatter = {};
for (const line of rawFrontmatter.split("\n")) { for (const line of rawFrontmatter.split("\n")) {
const delimiterIndex = line.indexOf(":"); const delimiterIndex = line.indexOf(":");
if (delimiterIndex <= 0) { if (delimiterIndex <= 0) {
continue; continue;
} }
const key = line.slice(0, delimiterIndex).trim(); const key = line.slice(0, delimiterIndex).trim();
const value = line.slice(delimiterIndex + 1).trim().replace(/^"|"$/g, ""); const value = line
.slice(delimiterIndex + 1)
.trim()
.replace(/^"|"$/g, "");
if (key === "title" || key === "date" || key === "summary") { if (key === "title" || key === "date" || key === "summary") {
frontmatter[key] = value; frontmatter[key] = value;
} }
} }
return { frontmatter, body: body.trim() }; return { frontmatter, body: body.trim() };
} }
export async function getAnnouncementSlugs(): Promise<string[]> { export async function getAnnouncementSlugs(): Promise<string[]> {
const entries = await fs.readdir(ANNOUNCEMENTS_DIR, { withFileTypes: true }); const entries = await fs.readdir(ANNOUNCEMENTS_DIR, { withFileTypes: true });
return entries return entries
.filter((entry) => entry.isFile() && entry.name.endsWith(".md")) .filter((entry) => entry.isFile() && entry.name.endsWith(".md"))
.map((entry) => entry.name.replace(/\.md$/, "")); .map((entry) => entry.name.replace(/\.md$/, ""));
} }
export async function getAnnouncementBySlug(slug: string): Promise<AnnouncementDetail> { export async function getAnnouncementBySlug(
const filePath = path.join(ANNOUNCEMENTS_DIR, `${slug}.md`); slug: string,
const markdown = await fs.readFile(filePath, "utf-8"); ): Promise<AnnouncementDetail> {
const { frontmatter, body } = parseFrontmatter(markdown); const filePath = path.join(ANNOUNCEMENTS_DIR, `${slug}.md`);
const markdown = await fs.readFile(filePath, "utf-8");
const { frontmatter, body } = parseFrontmatter(markdown);
return { return {
slug, slug,
title: frontmatter.title ?? slug, title: frontmatter.title ?? slug,
date: frontmatter.date ?? "日付未設定", date: frontmatter.date ?? "日付未設定",
summary: frontmatter.summary ?? "", summary: frontmatter.summary ?? "",
contentHtml: markdownToHtml(body), contentHtml: markdownToHtml(body),
}; };
} }
export async function getAllAnnouncements(): Promise<AnnouncementListItem[]> { export async function getAllAnnouncements(): Promise<AnnouncementListItem[]> {
const slugs = await getAnnouncementSlugs(); const slugs = await getAnnouncementSlugs();
const items = await Promise.all(slugs.map((slug) => getAnnouncementBySlug(slug))); const items = await Promise.all(
slugs.map((slug) => getAnnouncementBySlug(slug)),
);
return items return items
.map((item) => ({ .map((item) => ({
slug: item.slug, slug: item.slug,
title: item.title, title: item.title,
date: item.date, date: item.date,
summary: item.summary, summary: item.summary,
})) }))
.sort((a, b) => b.date.localeCompare(a.date)); .sort((a, b) => b.date.localeCompare(a.date));
} }

View File

@@ -1,144 +1,155 @@
type ParseState = { type ParseState = {
inCodeBlock: boolean; inCodeBlock: boolean;
codeLang: string; codeLang: string;
paragraphBuffer: string[]; paragraphBuffer: string[];
listType: "ul" | "ol" | null; listType: "ul" | "ol" | null;
listBuffer: string[]; listBuffer: string[];
}; };
function escapeHtml(value: string): string { function escapeHtml(value: string): string {
return value return value
.replaceAll("&", "&amp;") .replaceAll("&", "&amp;")
.replaceAll("<", "&lt;") .replaceAll("<", "&lt;")
.replaceAll(">", "&gt;") .replaceAll(">", "&gt;")
.replaceAll('"', "&quot;") .replaceAll('"', "&quot;")
.replaceAll("'", "&#39;"); .replaceAll("'", "&#39;");
} }
function applyInlineMarkdown(value: string): string { function applyInlineMarkdown(value: string): string {
const escaped = escapeHtml(value); const escaped = escapeHtml(value);
return escaped return escaped
.replace(/`([^`]+)`/g, "<code>$1</code>") .replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>") .replace(/\*([^*]+)\*/g, "<em>$1</em>")
.replace(/~~([^~]+)~~/g, "<del>$1</del>") .replace(/~~([^~]+)~~/g, "<del>$1</del>")
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'); .replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
);
} }
function flushParagraph(state: ParseState, html: string[]): void { function flushParagraph(state: ParseState, html: string[]): void {
if (state.paragraphBuffer.length === 0) { if (state.paragraphBuffer.length === 0) {
return; return;
} }
html.push(`<p>${applyInlineMarkdown(state.paragraphBuffer.join(" "))}</p>`); html.push(`<p>${applyInlineMarkdown(state.paragraphBuffer.join(" "))}</p>`);
state.paragraphBuffer = []; state.paragraphBuffer = [];
} }
function flushList(state: ParseState, html: string[]): void { function flushList(state: ParseState, html: string[]): void {
if (!state.listType || state.listBuffer.length === 0) { if (!state.listType || state.listBuffer.length === 0) {
return; return;
} }
html.push(`<${state.listType}>${state.listBuffer.join("")}</${state.listType}>`); html.push(
state.listType = null; `<${state.listType}>${state.listBuffer.join("")}</${state.listType}>`,
state.listBuffer = []; );
state.listType = null;
state.listBuffer = [];
} }
export function markdownToHtml(markdown: string): string { export function markdownToHtml(markdown: string): string {
const lines = markdown.replace(/\r\n/g, "\n").split("\n"); const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const html: string[] = []; const html: string[] = [];
const state: ParseState = { const state: ParseState = {
inCodeBlock: false, inCodeBlock: false,
codeLang: "", codeLang: "",
paragraphBuffer: [], paragraphBuffer: [],
listType: null, listType: null,
listBuffer: [], listBuffer: [],
}; };
for (const rawLine of lines) { for (const rawLine of lines) {
const line = rawLine.trimEnd(); const line = rawLine.trimEnd();
if (line.startsWith("```")) { if (line.startsWith("```")) {
flushParagraph(state, html); flushParagraph(state, html);
flushList(state, html); flushList(state, html);
if (!state.inCodeBlock) { if (!state.inCodeBlock) {
state.inCodeBlock = true; state.inCodeBlock = true;
state.codeLang = line.slice(3).trim(); state.codeLang = line.slice(3).trim();
const className = state.codeLang ? ` class="language-${escapeHtml(state.codeLang)}"` : ""; const className = state.codeLang
html.push(`<pre><code${className}>`); ? ` class="language-${escapeHtml(state.codeLang)}"`
} else { : "";
state.inCodeBlock = false; html.push(`<pre><code${className}>`);
state.codeLang = ""; } else {
html.push("</code></pre>"); state.inCodeBlock = false;
} state.codeLang = "";
continue; html.push("</code></pre>");
} }
continue;
}
if (state.inCodeBlock) { if (state.inCodeBlock) {
html.push(`${escapeHtml(rawLine)}\n`); html.push(`${escapeHtml(rawLine)}\n`);
continue; continue;
} }
if (line.length === 0) { if (line.length === 0) {
flushParagraph(state, html); flushParagraph(state, html);
flushList(state, html); flushList(state, html);
continue; continue;
} }
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/); const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) { if (headingMatch) {
flushParagraph(state, html); flushParagraph(state, html);
flushList(state, html); flushList(state, html);
const level = headingMatch[1].length; const level = headingMatch[1].length;
html.push(`<h${level}>${applyInlineMarkdown(headingMatch[2])}</h${level}>`); html.push(
continue; `<h${level}>${applyInlineMarkdown(headingMatch[2])}</h${level}>`,
} );
continue;
}
if (/^---$/.test(line)) { if (/^---$/.test(line)) {
flushParagraph(state, html); flushParagraph(state, html);
flushList(state, html); flushList(state, html);
html.push("<hr />"); html.push("<hr />");
continue; continue;
} }
const blockquoteMatch = line.match(/^>\s?(.*)$/); const blockquoteMatch = line.match(/^>\s?(.*)$/);
if (blockquoteMatch) { if (blockquoteMatch) {
flushParagraph(state, html); flushParagraph(state, html);
flushList(state, html); flushList(state, html);
html.push(`<blockquote><p>${applyInlineMarkdown(blockquoteMatch[1])}</p></blockquote>`); html.push(
continue; `<blockquote><p>${applyInlineMarkdown(blockquoteMatch[1])}</p></blockquote>`,
} );
continue;
}
const ulMatch = line.match(/^[-*+]\s+(.+)$/); const ulMatch = line.match(/^[-*+]\s+(.+)$/);
if (ulMatch) { if (ulMatch) {
flushParagraph(state, html); flushParagraph(state, html);
if (state.listType !== "ul") { if (state.listType !== "ul") {
flushList(state, html); flushList(state, html);
state.listType = "ul"; state.listType = "ul";
} }
state.listBuffer.push(`<li>${applyInlineMarkdown(ulMatch[1])}</li>`); state.listBuffer.push(`<li>${applyInlineMarkdown(ulMatch[1])}</li>`);
continue; continue;
} }
const olMatch = line.match(/^\d+\.\s+(.+)$/); const olMatch = line.match(/^\d+\.\s+(.+)$/);
if (olMatch) { if (olMatch) {
flushParagraph(state, html); flushParagraph(state, html);
if (state.listType !== "ol") { if (state.listType !== "ol") {
flushList(state, html); flushList(state, html);
state.listType = "ol"; state.listType = "ol";
} }
state.listBuffer.push(`<li>${applyInlineMarkdown(olMatch[1])}</li>`); state.listBuffer.push(`<li>${applyInlineMarkdown(olMatch[1])}</li>`);
continue; continue;
} }
flushList(state, html); flushList(state, html);
state.paragraphBuffer.push(line.trim()); state.paragraphBuffer.push(line.trim());
} }
flushParagraph(state, html); flushParagraph(state, html);
flushList(state, html); flushList(state, html);
return html.join("\n"); return html.join("\n");
} }

View File

@@ -1,6 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs));
} }

View File

@@ -1,8 +1,8 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "export", output: "export",
trailingSlash: true, trailingSlash: true,
}; };
export default nextConfig; export default nextConfig;

View File

@@ -1,43 +1,43 @@
{ {
"name": "minecraft-server-web", "name": "minecraft-server-web",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@base-ui/react": "^1.3.0", "@base-ui/react": "^1.3.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"lucide-react": "^1.7.0", "lucide-react": "^1.7.0",
"marked": "^17.0.5", "marked": "^17.0.5",
"next": "16.2.1", "next": "16.2.1",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"shadcn": "^4.1.1", "shadcn": "^4.1.1",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.1", "eslint-config-next": "16.2.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
}, },
"ignoreScripts": [ "ignoreScripts": [
"sharp", "sharp",
"unrs-resolver" "unrs-resolver"
], ],
"trustedDependencies": [ "trustedDependencies": [
"sharp", "sharp",
"unrs-resolver" "unrs-resolver"
] ]
} }

View File

@@ -1,7 +1,7 @@
const config = { const config = {
plugins: { plugins: {
"@tailwindcss/postcss": {}, "@tailwindcss/postcss": {},
}, },
}; };
export default config; export default config;

View File

@@ -1,34 +1,34 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
"noEmit": true, "noEmit": true,
"esModuleInterop": true, "esModuleInterop": true,
"module": "esnext", "module": "esnext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
"name": "next" "name": "next"
} }
], ],
"paths": { "paths": {
"@/*": ["./*"] "@/*": ["./*"]
} }
}, },
"include": [ "include": [
"next-env.d.ts", "next-env.d.ts",
"**/*.ts", "**/*.ts",
"**/*.tsx", "**/*.tsx",
".next/types/**/*.ts", ".next/types/**/*.ts",
".next/dev/types/**/*.ts", ".next/dev/types/**/*.ts",
"**/*.mts" "**/*.mts"
], ],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }