feat: add initial implementation for fetching avatar data from API

- Created a new sync.ts file to handle API requests.
- Defined interfaces for AvatarApiResponse, Data, and Avatar.
- Implemented a main function to fetch avatar data from the specified endpoint.
This commit is contained in:
2025-09-23 09:27:51 +07:00 Unverified
parent be6b01a182
commit bdef3c3bd0
21 changed files with 694 additions and 171 deletions
+78
View File
@@ -0,0 +1,78 @@
import { NextResponse } from "next/server";
export const revalidate = 900;
export type YoutubeLiveInfo = {
url: string;
thumbnails: {
url: string;
width: number;
height: number;
}
} | "none";
type APISearchResource = {
"kind": "youtube#searchResult",
"etag": string,
"id": {
"kind": string,
"videoId": string,
"channelId": string,
"playlistId": string
},
"snippet": {
"publishedAt": string,
"channelId": string,
"title": string,
"description": string,
"thumbnails": {
[key: string]: {
"url": string,
"width": number,
"height": number
}
},
"channelTitle": string,
"liveBroadcastContent": string
}
};
type APISearchResponse = {
"kind": "youtube#searchListResponse",
"etag": string,
"nextPageToken": string,
"prevPageToken": string,
"regionCode": string,
"pageInfo": {
"totalResults": number,
"resultsPerPage": number
},
"items": APISearchResource[],
};
export async function GET() {
const channelId = process.env.YOUTUBE_CHANNEL_ID;
const apiKey = process.env.YOUTUBE_API_KEY;
const response = await fetch(
`https://www.googleapis.com/youtube/v3/search?part=snippet&channelId=${channelId}&eventType=live&type=video&maxResults=1&key=${apiKey}`,
{
next: {
revalidate: 900, // refresh every 15 minutes
},
cache: "force-cache",
}
);
if (!response.ok)
throw new Error(`YouTube API error: ${response.statusText}`);
const data: APISearchResponse = await response.json();
if (data.items.length <= 0)
return NextResponse.json<YoutubeLiveInfo>("none");
const live = data.items[0];
return NextResponse.json<YoutubeLiveInfo>({
url: `https://www.youtube.com/watch?v=${live.id.videoId}`,
thumbnails: live.snippet.thumbnails[Object.keys(live.snippet.thumbnails)[0]]
});
}
+110
View File
@@ -0,0 +1,110 @@
"use client";
import Avatar from "@/components/avatar";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
export function CharacterChooser() {
return (
<>
<div className="grid gap-2">
<Label htmlFor="password">UID</Label>
<Input id="password" type="number" required placeholder="887654321" />
</div>
<ScrollArea>
<div className="flex gap-2">
<Avatar
char={{
name: "Traveler",
stars: 5,
vision: "anemo",
weapon: "sword",
image: "1234567890",
version: "5.7",
}}
scale={0.6}
/>
<Avatar
char={{
name: "Traveler",
stars: 5,
vision: "anemo",
weapon: "sword",
image: "1234567890",
version: "5.7",
}}
scale={0.6}
/>
<Avatar
char={{
name: "Traveler",
stars: 5,
vision: "anemo",
weapon: "sword",
image: "1234567890",
version: "5.7",
}}
scale={0.6}
/>
<Avatar
char={{
name: "Traveler",
stars: 5,
vision: "anemo",
weapon: "sword",
image: "1234567890",
version: "5.7",
}}
scale={0.6}
/>
<Avatar
char={{
name: "Traveler",
stars: 5,
vision: "anemo",
weapon: "sword",
image: "1234567890",
version: "5.7",
}}
scale={0.6}
/>
<Avatar
char={{
name: "Traveler",
stars: 5,
vision: "anemo",
weapon: "sword",
image: "1234567890",
version: "5.7",
}}
scale={0.6}
/>
<Avatar
char={{
name: "Traveler",
stars: 5,
vision: "anemo",
weapon: "sword",
image: "1234567890",
version: "5.7",
}}
scale={0.6}
/>
<Avatar
char={{
name: "Traveler",
stars: 5,
vision: "anemo",
weapon: "sword",
image: "1234567890",
version: "5.7",
}}
scale={0.6}
/>
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { TooltipContent, TooltipTrigger } from "@radix-ui/react-tooltip";
import { TvMinimalPlay } from "lucide-react";
import Image from "next/image";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Tooltip } from "@/components/ui/tooltip";
import { base } from "@/lib/const";
import type { YoutubeLiveInfo } from "../api/live/route";
export async function LiveButton() {
const live: YoutubeLiveInfo = await fetch(`${base}/api/live`).then((e) =>
e.json(),
);
if (live === "none") return;
const { url, thumbnails } = live;
return (
<Tooltip>
<TooltipTrigger asChild>
<Link href={url}>
<Button variant="outline" type="button">
<TvMinimalPlay className="text-red-500 animate-pulse" /> LIVE
</Button>
</Link>
</TooltipTrigger>
<TooltipContent>
<Image
height={thumbnails.height / 10}
width={thumbnails.width / 10}
src={thumbnails.url}
alt="yt thumbnail"
/>
</TooltipContent>
</Tooltip>
);
}
+99
View File
@@ -0,0 +1,99 @@
import { TooltipTrigger } from "@radix-ui/react-tooltip";
import { BookAlert, CircleDollarSign, SendHorizonal } from "lucide-react";
import Image from "next/image";
import { Suspense } from "react";
import banner from "#/logos/artifact.webp";
import { Blocker } from "@/components/blocker";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Tooltip, TooltipContent } from "@/components/ui/tooltip";
import { CharacterChooser } from "./chooser";
import { LiveButton } from "./live";
export default async function ArtifactFormPage() {
return (
<div className="flex flex-col justify-around items-center h-full">
<Card className="w-full max-w-md">
<CardHeader className="justify-center">
<CardTitle>
<div className="w-[276.5px]">
<Image
style={{ position: "absolute", transform: "translateY(-70%)" }}
height={137.5}
width={276.5}
src={banner}
alt="เสือกไอดีชาวบ้าน"
/>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<form>
<div className="flex flex-col gap-3">
<Blocker>
<Button variant="destructive" type="button">
<BookAlert /> อ่านกฎ
</Button>
</Blocker>
<div className="grid gap-2">
<Label htmlFor="name">ชื่อ</Label>
<Input
id="name"
type="text"
placeholder="Mr. Buzz"
autoComplete="name"
required
/>
</div>
<CharacterChooser />
<div className="grid gap-2">
<Label htmlFor="comment">ข้อความเพิ่มเติม</Label>
<Textarea id="comment" required placeholder="(ไม่บังคับ)" />
</div>
</div>
</form>
</CardContent>
<CardFooter className="flex gap-2 justify-between">
<div className="flex gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" type="button">
<CircleDollarSign />
</Button>
</TooltipTrigger>
<TooltipContent>โดเนทลัดคิว ขั้นต่ำ 10 บาท</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="destructive" type="button">
<BookAlert />
</Button>
</TooltipTrigger>
<TooltipContent>อ่านกฎการลงทะเบียน</TooltipContent>
</Tooltip>
<Suspense>
<LiveButton />
</Suspense>
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button type="submit">
<SendHorizonal />
</Button>
</TooltipTrigger>
<TooltipContent>ส่งเลยคับพี่</TooltipContent>
</Tooltip>
</CardFooter>
</Card>
</div>
);
}
+13 -2
View File
@@ -6,7 +6,7 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-sans: var(--font-anuphan);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
@@ -86,7 +86,7 @@
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.546 0.245 262.881);
--primary-foreground: oklch(0.379 0.146 265.522);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
@@ -120,3 +120,14 @@
@apply bg-background text-foreground;
}
}
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
*:has(.blocker) {
z-index: 0;
position: relative;
}
+26 -8
View File
@@ -1,10 +1,12 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Anuphan, Geist_Mono } from "next/font/google";
import "./globals.css";
import Image from "next/image";
import Background from "#/bg.jpg";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
const anuphan = Anuphan({
variable: "--font-anuphan",
subsets: ["latin", "thai"],
});
const geistMono = Geist_Mono({
@@ -13,8 +15,18 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
metadataBase: new URL("https://buzz.gunshiz.top"),
title: "เกนชินไม่ใช่เกมมือถือ",
description: "ระบบอีเวนท์ของเกนชินไม่ใช่เกมมือถือ",
icons: "https://buzz.gunshiz.top/favicon.webp",
openGraph: {
title: "เกนชินไม่ใช่เกมมือถือ",
description: "ระบบอีเวนท์ของเกนชินไม่ใช่เกมมือถือ",
url: "https://buzz.gunshiz.top",
siteName: "เกนชินไม่ใช่เกมมือถือ",
locale: "th_TH",
type: "website",
},
};
export default function RootLayout({
@@ -23,10 +35,16 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" className="h-full">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${anuphan.variable} ${geistMono.variable} font-sans antialiased dark h-full`}
>
<Image
src={Background}
alt="Background"
fill
className="z-[-1] opacity-20"
/>
{children}
</body>
</html>