From c605ef70bc2de8929f5089b72667d5d91e99a758 Mon Sep 17 00:00:00 2001 From: Shun Ikeda Date: Sat, 2 May 2026 19:52:27 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20=E8=AA=95=E7=94=9F=E6=97=A5?= =?UTF-8?q?=E3=82=AB=E3=83=AC=E3=83=B3=E3=83=80=E3=83=BC=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=20(#193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /internal/birthdays に誕生日カレンダーページを追加 - サイドバーに「誕生日」ナビリンク(Cake アイコン)を追加 - internal ホームに今日の誕生日バナー+紙吹雪アニメーション(canvas-confetti) - 自分の誕生日までのカウントダウン表示 - 今日が誕生日のメンバーへ geek ジョークを Discord DM で送信する機能 - 月ナビ付き年間カレンダー(1ヶ月表示・前後矢印で移動) - 誕生日がある日をクリックするとメンバー情報をポップアップ表示 - /api/cron/birthday エンドポイントで朝に Discord 運営チャンネルへ通知 Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 5 + app/api/cron/birthday/route.ts | 66 +++++ app/internal/(protected)/birthdays/page.tsx | 36 +++ app/internal/(protected)/page.tsx | 23 +- components/app-sidebar.tsx | 2 + components/birthday-list.tsx | 274 ++++++++++++++++++++ components/birthday-year-calendar.tsx | 254 ++++++++++++++++++ components/today-birthday-banner.tsx | 61 +++++ lib/birthday/actions.ts | 50 ++++ lib/discord-dm.ts | 24 ++ package.json | 2 + pnpm-lock.yaml | 54 ++++ 12 files changed, 849 insertions(+), 2 deletions(-) create mode 100644 app/api/cron/birthday/route.ts create mode 100644 app/internal/(protected)/birthdays/page.tsx create mode 100644 components/birthday-list.tsx create mode 100644 components/birthday-year-calendar.tsx create mode 100644 components/today-birthday-banner.tsx create mode 100644 lib/birthday/actions.ts diff --git a/.env.example b/.env.example index 3a7ce3e..316919e 100644 --- a/.env.example +++ b/.env.example @@ -30,6 +30,11 @@ RETURNING_MEMBER_ROLE_ID="1234567890" # 空文字の場合は通知をスキップ ADMIN_NOTIFICATION_CHANNEL_WEBHOOK="" +# Cron ジョブ認証シークレット(/api/cron/* エンドポイントの保護) +# `openssl rand -hex 32` で生成。Cloud Scheduler の Authorization ヘッダーに設定する。 +# 空文字の場合は認証をスキップ(ローカル開発用) +CRON_SECRET="" + # --- GitHub OAuth (SNS連携) --- # GitHub Developer Settings > OAuth Apps で取得 # Callback URL: http://localhost:3000/api/auth/link/github/callback diff --git a/app/api/cron/birthday/route.ts b/app/api/cron/birthday/route.ts new file mode 100644 index 0000000..5309081 --- /dev/null +++ b/app/api/cron/birthday/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from "next/server"; +import { getDb } from "@/lib/firebase"; +import { profileToMemberInternal } from "@/lib/members"; +import type { MemberDocument } from "@/lib/members"; +import { + notifyAdminChannel, + buildBirthdayNotification, +} from "@/lib/discord-dm"; + +// Cloud Scheduler から呼び出される誕生日通知エンドポイント。 +// 認証: Authorization: Bearer {CRON_SECRET} ヘッダーで保護。 +// +// Cloud Scheduler 設定例: +// URL: https:///api/cron/birthday +// Schedule: 0 9 * * * (毎朝 9:00 JST = 0:00 UTC) +// HTTP method: GET +// Headers: Authorization: Bearer + +export async function GET(request: Request) { + const secret = process.env.CRON_SECRET; + if (secret) { + const auth = request.headers.get("authorization"); + if (auth !== `Bearer ${secret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + } + + const db = getDb(); + const snap = await db + .collection("members") + .where("onboardingCompleted", "==", true) + .get(); + + const now = new Date(); + const mm = String(now.getMonth() + 1).padStart(2, "0"); + const dd = String(now.getDate()).padStart(2, "0"); + const today = `${mm}-${dd}`; + + const todayMembers = snap.docs.flatMap((doc) => { + const data = doc.data() as MemberDocument; + if (data.optedOut) return []; + const member = profileToMemberInternal(doc.id, data); + if (!member.birthDate) return []; + if (member.birthDate.slice(5) !== today) return []; + return [member]; + }); + + if (todayMembers.length === 0) { + return NextResponse.json({ notified: false, count: 0 }); + } + + const names = todayMembers.map((m) => m.nickname || m.name); + + try { + await notifyAdminChannel(buildBirthdayNotification(names)); + } catch (e) { + console.error("[cron/birthday] Failed to notify:", e); + return NextResponse.json({ error: String(e) }, { status: 500 }); + } + + return NextResponse.json({ + notified: true, + count: todayMembers.length, + names, + }); +} diff --git a/app/internal/(protected)/birthdays/page.tsx b/app/internal/(protected)/birthdays/page.tsx new file mode 100644 index 0000000..766827b --- /dev/null +++ b/app/internal/(protected)/birthdays/page.tsx @@ -0,0 +1,36 @@ +import { auth } from "@/lib/auth"; +import { getMembersInternal } from "@/lib/members"; +import { BirthdayList } from "@/components/birthday-list"; +import { Cake } from "lucide-react"; + +export const dynamic = "force-dynamic"; + +export default async function BirthdaysPage() { + const [session, members] = await Promise.all([auth(), getMembersInternal()]); + + const myBirthDate = + members.find((m) => m.id === session?.user?.id)?.birthDate ?? null; + + const entries = members + .filter((m) => m.birthDate) + .map((m) => ({ + id: m.id, + name: m.name, + nickname: m.nickname, + birthDate: m.birthDate!, + avatarUrl: m.snsAvatar || m.publicImage || undefined, + })); + + return ( +
+
+ +

誕生日カレンダー

+ + {entries.length} 件 + +
+ +
+ ); +} diff --git a/app/internal/(protected)/page.tsx b/app/internal/(protected)/page.tsx index 93c5c5e..5a6eaa9 100644 --- a/app/internal/(protected)/page.tsx +++ b/app/internal/(protected)/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import { auth } from "@/lib/auth"; -import { getMember } from "@/lib/members"; +import { getMember, getMembersInternal } from "@/lib/members"; +import { TodayBirthdayBanner } from "@/components/today-birthday-banner"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Progress } from "@/components/ui/progress"; import { @@ -72,10 +73,25 @@ function getProfileCompletionItems( ]; } +function getTodayBirthdayNames( + members: { name: string; nickname?: string; birthDate?: string }[], +): string[] { + const now = new Date(); + const mm = String(now.getMonth() + 1).padStart(2, "0"); + const dd = String(now.getDate()).padStart(2, "0"); + return members + .filter((m) => m.birthDate?.slice(5) === `${mm}-${dd}`) + .map((m) => m.nickname || m.name); +} + export default async function InternalPage() { const session = await auth(); - const member = await getMember(session!.user!.id); + const [member, allMembers] = await Promise.all([ + getMember(session!.user!.id), + getMembersInternal(), + ]); const displayName = member?.nickname || session?.user?.name || "メンバー"; + const todayBirthdayNames = getTodayBirthdayNames(allMembers); const items = getProfileCompletionItems( member as Record | null, ); @@ -92,6 +108,9 @@ export default async function InternalPage() { return (
+ {todayBirthdayNames.length > 0 && ( + + )} {/* Welcome Banner */}
diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index fb50a66..3ceb5f2 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -6,6 +6,7 @@ import { LayoutDashboard, Users, CalendarDays, + Cake, UserCircle, Settings, ExternalLink, @@ -35,6 +36,7 @@ const NAV_ITEMS = [ { href: "/internal", icon: LayoutDashboard, label: "ホーム", exact: true }, { href: "/internal/members", icon: Users, label: "メンバー" }, { href: "/internal/events", icon: CalendarDays, label: "イベント" }, + { href: "/internal/birthdays", icon: Cake, label: "誕生日" }, { href: "/internal/profile", icon: UserCircle, label: "プロフィール" }, { href: "/internal/settings", icon: Settings, label: "設定" }, ]; diff --git a/components/birthday-list.tsx b/components/birthday-list.tsx new file mode 100644 index 0000000..8d1aeca --- /dev/null +++ b/components/birthday-list.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { useMemo, useState, useTransition } from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Loader2 } from "lucide-react"; +import { useToast } from "@/components/ui/use-toast"; +import { formatBirthDate } from "@/lib/date"; +import { sendBirthdayJokeDm } from "@/lib/birthday/actions"; +import { BirthdayYearCalendar } from "@/components/birthday-year-calendar"; + +type BirthdayEntry = { + id: string; + name: string; + nickname?: string; + birthDate: string; + avatarUrl?: string; +}; + +const MONTH_NAMES = [ + "1月", + "2月", + "3月", + "4月", + "5月", + "6月", + "7月", + "8月", + "9月", + "10月", + "11月", + "12月", +]; + +const GEEK_JOKES = [ + { + label: "age++", + message: + "age++ を実行しました。コンパイルエラー: 0件。テスト: all passed。", + }, + { + label: "git tag", + message: + "git tag -a birthday-$(date +%Y) -m 'Happy Birthday! No breaking changes detected in this release.'", + }, + { + label: "console.log", + message: + 'console.log("Happy Birthday! Stack usage: optimal. Memory leaks: none. Uptime: 1 year+.");', + }, + { + label: "deploy", + message: + "本番環境へのデプロイが完了しました。ロールバック: 不要。ステータス: healthy。今年もよろしくお願いします。", + }, +]; + +function daysUntilNextBirthday(birthDate: string): number { + const now = new Date(); + const [, m, d] = birthDate.split("-").map(Number); + const next = new Date(now.getFullYear(), m - 1, d); + if ( + next.getMonth() < now.getMonth() || + (next.getMonth() === now.getMonth() && next.getDate() < now.getDate()) + ) { + next.setFullYear(now.getFullYear() + 1); + } + const diff = + next.getTime() - + new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + return Math.round(diff / (1000 * 60 * 60 * 24)); +} + +function MyBirthdayCountdown({ birthDate }: { birthDate: string }) { + const days = daysUntilNextBirthday(birthDate); + if (days === 0) return null; + + return ( +
+

あなたの誕生日まで

+

あと {days} 日

+

+ {formatBirthDate(birthDate)} +

+
+ ); +} + +function JokeButtons({ discordId, name }: { discordId: string; name: string }) { + const [sending, setSending] = useState(null); + const [sent, setSent] = useState>(new Set()); + const [isPending, startTransition] = useTransition(); + const { toast } = useToast(); + + function handleSend(joke: (typeof GEEK_JOKES)[number]) { + if (isPending) return; + setSending(joke.label); + startTransition(async () => { + const result = await sendBirthdayJokeDm(discordId, name, joke.message); + setSending(null); + if (result.success) { + setSent((prev) => new Set(prev).add(joke.label)); + toast({ + title: "Discord DM を送信しました", + description: `${name}さんに「${joke.label}」を届けました`, + }); + } else { + toast({ + title: "送信に失敗しました", + description: result.error, + variant: "destructive", + }); + } + }); + } + + return ( +
+

+ Discord DM でジョークを送る +

+
+ {GEEK_JOKES.map((j) => { + const isSending = sending === j.label; + const isSent = sent.has(j.label); + return ( + + ); + })} +
+
+ ); +} + +export function BirthdayList({ + entries, + myBirthDate, +}: { + entries: BirthdayEntry[]; + myBirthDate?: string | null; +}) { + const now = new Date(); + const todayMonth = now.getMonth() + 1; + const todayDay = now.getDate(); + + const grouped = useMemo(() => { + const map = new Map(); + for (const entry of entries) { + const month = parseInt(entry.birthDate.split("-")[1]); + if (!map.has(month)) map.set(month, []); + map.get(month)!.push(entry); + } + for (const list of map.values()) { + list.sort( + (a, b) => + parseInt(a.birthDate.split("-")[2]) - + parseInt(b.birthDate.split("-")[2]), + ); + } + const months: number[] = []; + for (let i = 0; i < 12; i++) { + const m = ((todayMonth - 1 + i) % 12) + 1; + if (map.has(m)) months.push(m); + } + return months.map((m) => ({ month: m, entries: map.get(m)! })); + }, [entries, todayMonth]); + + if (entries.length === 0) { + return ( +

+ 誕生日を公開しているメンバーがいません。 +

+ ); + } + + return ( +
+ {myBirthDate && } + +
+ {grouped.map(({ month, entries: monthEntries }) => { + const isCurrentMonth = month === todayMonth; + return ( +
+

+ {MONTH_NAMES[month - 1]} + {isCurrentMonth && ( + + 今月 + + )} +

+
    + {monthEntries.map((entry) => { + const displayName = + entry.nickname && entry.nickname !== entry.name + ? entry.nickname + : entry.name; + const isToday = + month === todayMonth && + parseInt(entry.birthDate.split("-")[2]) === todayDay; + return ( +
  • +
    + + + + {displayName.charAt(0)} + + +
    +

    + {displayName} +

    + {entry.nickname && entry.nickname !== entry.name && ( +

    + {entry.name} +

    + )} +
    +
    + {isToday && ( + + 今日 + + )} + + {formatBirthDate(entry.birthDate)} + +
    +
    + {isToday && ( + + )} +
  • + ); + })} +
+
+ ); + })} +
+ +
+ +
+
+ ); +} diff --git a/components/birthday-year-calendar.tsx b/components/birthday-year-calendar.tsx new file mode 100644 index 0000000..159c0cb --- /dev/null +++ b/components/birthday-year-calendar.tsx @@ -0,0 +1,254 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { formatBirthDate } from "@/lib/date"; + +type BirthdayEntry = { + id: string; + name: string; + nickname?: string; + birthDate: string; + avatarUrl?: string; +}; + +const MONTH_NAMES = [ + "1月", + "2月", + "3月", + "4月", + "5月", + "6月", + "7月", + "8月", + "9月", + "10月", + "11月", + "12月", +]; +const WEEK_DAYS = ["月", "火", "水", "木", "金", "土", "日"]; + +export function BirthdayYearCalendar({ + entries, +}: { + entries: BirthdayEntry[]; +}) { + const now = new Date(); + const currentYear = now.getFullYear(); + const todayMonth = now.getMonth() + 1; + const todayDay = now.getDate(); + + const [month, setMonth] = useState(todayMonth); // 1-12 + const [selectedDay, setSelectedDay] = useState(null); + + const prev = () => setMonth((m) => (m === 1 ? 12 : m - 1)); + const next = () => setMonth((m) => (m === 12 ? 1 : m + 1)); + + // 月 → 日 → エントリ のマップ + const byDay = useMemo(() => { + const map = new Map(); + for (const entry of entries) { + const [, mm, dd] = entry.birthDate.split("-").map(Number); + if (mm !== month) continue; + if (!map.has(dd)) map.set(dd, []); + map.get(dd)!.push(entry); + } + return map; + }, [entries, month]); + + // 月曜始まり: 1日の曜日オフセット (Mon=0) + const firstDayOfWeek = (new Date(currentYear, month - 1, 1).getDay() + 6) % 7; + const daysInMonth = new Date(currentYear, month, 0).getDate(); + + const cells: (number | null)[] = [ + ...Array(firstDayOfWeek).fill(null), + ...Array.from({ length: daysInMonth }, (_, i) => i + 1), + ]; + + const selectedMembers = selectedDay ? (byDay.get(selectedDay) ?? []) : []; + + return ( +
+

年間カレンダー

+ +
+ {/* ヘッダー: 前月 / 月名 / 次月 */} +
+ + + {MONTH_NAMES[month - 1]} + + +
+ + {/* 曜日ヘッダー */} +
+ {WEEK_DAYS.map((d, i) => ( +
+ {d} +
+ ))} +
+ + {/* 日付グリッド */} +
+ {cells.map((day, i) => { + if (!day) return
; + const hasBirthday = byDay.has(day); + const isToday = month === todayMonth && day === todayDay; + return ( + + ); + })} +
+ + {/* 今月の誕生日サマリー */} + {byDay.size > 0 && ( +
+ {[...byDay.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([day, members]) => ( + + ))} +
+ )} +
+ + {/* 誕生日ポップアップ */} + !o && setSelectedDay(null)} + > + + + + {MONTH_NAMES[month - 1]} + {selectedDay}日の誕生日 + + +
    + {selectedMembers.map((m) => { + const display = + m.nickname && m.nickname !== m.name ? m.nickname : m.name; + return ( +
  • + + + {display.charAt(0)} + +
    +

    {display}

    + {m.nickname && m.nickname !== m.name && ( +

    {m.name}

    + )} +

    + {formatBirthDate(m.birthDate)} +

    +
    +
  • + ); + })} +
+
+
+
+ ); +} diff --git a/components/today-birthday-banner.tsx b/components/today-birthday-banner.tsx new file mode 100644 index 0000000..123633f --- /dev/null +++ b/components/today-birthday-banner.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import confetti from "canvas-confetti"; +import { Cake } from "lucide-react"; +import Link from "next/link"; + +interface TodayBirthdayBannerProps { + names: string[]; +} + +export function TodayBirthdayBanner({ names }: TodayBirthdayBannerProps) { + const firedRef = useRef(false); + + useEffect(() => { + if (firedRef.current) return; + firedRef.current = true; + + // 左右から紙吹雪を打ち上げる + const end = Date.now() + 3000; + const colors = ["#9333ea", "#3b82f6", "#f59e0b", "#10b981", "#ec4899"]; + + const frame = () => { + confetti({ + particleCount: 3, + angle: 60, + spread: 55, + origin: { x: 0 }, + colors, + }); + confetti({ + particleCount: 3, + angle: 120, + spread: 55, + origin: { x: 1 }, + colors, + }); + if (Date.now() < end) requestAnimationFrame(frame); + }; + frame(); + }, []); + + const label = + names.length === 1 + ? `${names[0]}さん` + : `${names.slice(0, -1).join("さん・")}さん・${names[names.length - 1]}さん`; + + return ( + +
+
+
+ +

+ 今日は {label} の誕生日です!おめでとうございます +

+
+
+ + ); +} diff --git a/lib/birthday/actions.ts b/lib/birthday/actions.ts new file mode 100644 index 0000000..652c533 --- /dev/null +++ b/lib/birthday/actions.ts @@ -0,0 +1,50 @@ +"use server"; + +import { auth } from "@/lib/auth"; +import { getMember } from "@/lib/members"; +import { sendDiscordDm } from "@/lib/discord-dm"; +import type { DiscordMessagePayload } from "@/lib/discord-dm"; + +function buildJokeDmPayload( + senderName: string, + recipientName: string, + joke: string, +): DiscordMessagePayload { + return { + embeds: [ + { + title: `${recipientName}さん、誕生日おめでとうございます!`, + description: `**${senderName}** さんからメッセージが届きました\n\`\`\`\n${joke}\n\`\`\``, + color: 0xf59e0b, + fields: [], + }, + ], + }; +} + +export async function sendBirthdayJokeDm( + recipientDiscordId: string, + recipientName: string, + joke: string, +): Promise<{ success: boolean; error?: string }> { + const session = await auth(); + if (!session?.user?.id) { + return { success: false, error: "ログインが必要です" }; + } + + const sender = await getMember(session.user.id); + const senderName = sender?.nickname || session.user.name || "Lumos メンバー"; + + try { + await sendDiscordDm( + recipientDiscordId, + buildJokeDmPayload(senderName, recipientName, joke), + ); + return { success: true }; + } catch (e) { + return { + success: false, + error: e instanceof Error ? e.message : String(e), + }; + } +} diff --git a/lib/discord-dm.ts b/lib/discord-dm.ts index 887ff0b..5998d77 100644 --- a/lib/discord-dm.ts +++ b/lib/discord-dm.ts @@ -845,3 +845,27 @@ export async function editDiscordDm( throw new Error(`Failed to edit DM message: ${response.status} ${error}`); } } + +// --- Birthday notification --- + +const BIRTHDAY_COLOR = 0xf59e0b; // Amber + +export function buildBirthdayNotification( + names: string[], +): DiscordMessagePayload { + const label = + names.length === 1 + ? `**${names[0]}** さん` + : names.map((n) => `**${n}**`).join(" さん・") + " さん"; + + return { + embeds: [ + { + title: "誕生日おめでとうございます!", + description: `本日は ${label} の誕生日です!\nLumos メンバーで一緒にお祝いしましょう。`, + color: BIRTHDAY_COLOR, + fields: [], + }, + ], + }; +} diff --git a/package.json b/package.json index f196a49..f1ee063 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@radix-ui/react-tooltip": "1.2.8", "@uiw/react-md-editor": "^4.1.0", "autoprefixer": "^10.4.27", + "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.0.4", @@ -77,6 +78,7 @@ }, "devDependencies": { "@tailwindcss/typography": "^0.5.19", + "@types/canvas-confetti": "^1.9.0", "@types/node": "^22.19.17", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3822d2..15c08ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -113,6 +113,9 @@ importers: autoprefixer: specifier: ^10.4.27 version: 10.4.27(postcss@8.5.9) + canvas-confetti: + specifier: ^1.9.4 + version: 1.9.4 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -198,6 +201,9 @@ importers: '@tailwindcss/typography': specifier: ^0.5.19 version: 0.5.19(tailwindcss@3.4.19(yaml@2.8.3)) + '@types/canvas-confetti': + specifier: ^1.9.0 + version: 1.9.0 '@types/node': specifier: ^22.19.17 version: 22.19.17 @@ -624,89 +630,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -940,24 +962,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.2.3': resolution: {integrity: sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.2.3': resolution: {integrity: sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.2.3': resolution: {integrity: sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.2.3': resolution: {integrity: sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==} @@ -1764,36 +1790,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.12': resolution: {integrity: sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.12': resolution: {integrity: sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.12': resolution: {integrity: sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.12': resolution: {integrity: sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.12': resolution: {integrity: sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==} @@ -1864,6 +1896,9 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/canvas-confetti@1.9.0': + resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==} + '@types/caseless@0.12.5': resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} @@ -2078,41 +2113,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -2545,6 +2588,9 @@ packages: caniuse-lite@1.0.30001787: resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} + canvas-confetti@1.9.4: + resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==} + ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -4241,24 +4287,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -8130,6 +8180,8 @@ snapshots: tslib: 2.8.1 optional: true + '@types/canvas-confetti@1.9.0': {} + '@types/caseless@0.12.5': {} '@types/chai@5.2.3': @@ -8878,6 +8930,8 @@ snapshots: caniuse-lite@1.0.30001787: {} + canvas-confetti@1.9.4: {} + ccount@2.0.1: {} chai@6.2.2: {} From 428fb138690a4f60039564b84ef2655330d2760e Mon Sep 17 00:00:00 2001 From: Shun Ikeda Date: Sun, 24 May 2026 23:33:02 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=94=A5=20=E8=AA=95=E7=94=9F=E6=97=A5D?= =?UTF-8?q?M=E9=80=81=E4=BF=A1=E6=A9=9F=E8=83=BD=E3=82=92=E5=89=8A?= =?UTF-8?q?=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discord DMでジョークを送る機能(JokeButtons)とそのServer Action(sendBirthdayJokeDm)を削除 Co-Authored-By: Claude Sonnet 4.6 --- components/birthday-list.tsx | 88 +----------------------------------- lib/birthday/actions.ts | 50 -------------------- 2 files changed, 1 insertion(+), 137 deletions(-) delete mode 100644 lib/birthday/actions.ts diff --git a/components/birthday-list.tsx b/components/birthday-list.tsx index 8d1aeca..6eb8944 100644 --- a/components/birthday-list.tsx +++ b/components/birthday-list.tsx @@ -1,13 +1,9 @@ "use client"; -import { useMemo, useState, useTransition } from "react"; +import { useMemo } from "react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Loader2 } from "lucide-react"; -import { useToast } from "@/components/ui/use-toast"; import { formatBirthDate } from "@/lib/date"; -import { sendBirthdayJokeDm } from "@/lib/birthday/actions"; import { BirthdayYearCalendar } from "@/components/birthday-year-calendar"; type BirthdayEntry = { @@ -33,29 +29,6 @@ const MONTH_NAMES = [ "12月", ]; -const GEEK_JOKES = [ - { - label: "age++", - message: - "age++ を実行しました。コンパイルエラー: 0件。テスト: all passed。", - }, - { - label: "git tag", - message: - "git tag -a birthday-$(date +%Y) -m 'Happy Birthday! No breaking changes detected in this release.'", - }, - { - label: "console.log", - message: - 'console.log("Happy Birthday! Stack usage: optimal. Memory leaks: none. Uptime: 1 year+.");', - }, - { - label: "deploy", - message: - "本番環境へのデプロイが完了しました。ロールバック: 不要。ステータス: healthy。今年もよろしくお願いします。", - }, -]; - function daysUntilNextBirthday(birthDate: string): number { const now = new Date(); const [, m, d] = birthDate.split("-").map(Number); @@ -87,62 +60,6 @@ function MyBirthdayCountdown({ birthDate }: { birthDate: string }) { ); } -function JokeButtons({ discordId, name }: { discordId: string; name: string }) { - const [sending, setSending] = useState(null); - const [sent, setSent] = useState>(new Set()); - const [isPending, startTransition] = useTransition(); - const { toast } = useToast(); - - function handleSend(joke: (typeof GEEK_JOKES)[number]) { - if (isPending) return; - setSending(joke.label); - startTransition(async () => { - const result = await sendBirthdayJokeDm(discordId, name, joke.message); - setSending(null); - if (result.success) { - setSent((prev) => new Set(prev).add(joke.label)); - toast({ - title: "Discord DM を送信しました", - description: `${name}さんに「${joke.label}」を届けました`, - }); - } else { - toast({ - title: "送信に失敗しました", - description: result.error, - variant: "destructive", - }); - } - }); - } - - return ( -
-

- Discord DM でジョークを送る -

-
- {GEEK_JOKES.map((j) => { - const isSending = sending === j.label; - const isSent = sent.has(j.label); - return ( - - ); - })} -
-
- ); -} - export function BirthdayList({ entries, myBirthDate, @@ -254,9 +171,6 @@ export function BirthdayList({
- {isToday && ( - - )} ); })} diff --git a/lib/birthday/actions.ts b/lib/birthday/actions.ts deleted file mode 100644 index 652c533..0000000 --- a/lib/birthday/actions.ts +++ /dev/null @@ -1,50 +0,0 @@ -"use server"; - -import { auth } from "@/lib/auth"; -import { getMember } from "@/lib/members"; -import { sendDiscordDm } from "@/lib/discord-dm"; -import type { DiscordMessagePayload } from "@/lib/discord-dm"; - -function buildJokeDmPayload( - senderName: string, - recipientName: string, - joke: string, -): DiscordMessagePayload { - return { - embeds: [ - { - title: `${recipientName}さん、誕生日おめでとうございます!`, - description: `**${senderName}** さんからメッセージが届きました\n\`\`\`\n${joke}\n\`\`\``, - color: 0xf59e0b, - fields: [], - }, - ], - }; -} - -export async function sendBirthdayJokeDm( - recipientDiscordId: string, - recipientName: string, - joke: string, -): Promise<{ success: boolean; error?: string }> { - const session = await auth(); - if (!session?.user?.id) { - return { success: false, error: "ログインが必要です" }; - } - - const sender = await getMember(session.user.id); - const senderName = sender?.nickname || session.user.name || "Lumos メンバー"; - - try { - await sendDiscordDm( - recipientDiscordId, - buildJokeDmPayload(senderName, recipientName, joke), - ); - return { success: true }; - } catch (e) { - return { - success: false, - error: e instanceof Error ? e.message : String(e), - }; - } -}