From 31c58aaa31dc55d9d93607b8de3f0d6aeb392db2 Mon Sep 17 00:00:00 2001 From: leesumok Date: Sat, 2 May 2026 01:11:57 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=ED=8A=B9=EC=A0=95=20=EC=9D=B8=EC=9B=90=20?= =?UTF-8?q?=ED=88=AC=ED=91=9C=20=EC=83=9D=EC=84=B1=EA=B3=BC=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=EC=9E=90=20=EC=A0=9C=ED=95=9C=20=ED=88=AC=ED=91=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/config.py | 3 + backend/app/models/schemas.py | 54 +- backend/app/routers/rooms.py | 26 +- backend/app/services/room.py | 10 +- frontend/app/create/restricted/page.tsx | 695 ++++++++++++++++++ frontend/app/page.tsx | 17 +- frontend/app/vote/[uuid]/vote-client.tsx | 127 +++- .../components/site/create-mode-dialog.tsx | 123 ++++ frontend/components/site/navbar.tsx | 5 +- frontend/lib/api.ts | 9 +- 10 files changed, 1044 insertions(+), 25 deletions(-) create mode 100644 frontend/app/create/restricted/page.tsx create mode 100644 frontend/components/site/create-mode-dialog.tsx diff --git a/backend/app/config.py b/backend/app/config.py index 19869d4..7f72ead 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -4,4 +4,7 @@ CORS_ORIGINS = [ "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:5173", + "http://127.0.0.1:5173", ] diff --git a/backend/app/models/schemas.py b/backend/app/models/schemas.py index 7add5d0..1359063 100644 --- a/backend/app/models/schemas.py +++ b/backend/app/models/schemas.py @@ -1,6 +1,6 @@ from enum import Enum -from pydantic import BaseModel, field_validator +from pydantic import BaseModel, Field, field_validator, model_validator class SortOrder(str, Enum): @@ -13,9 +13,11 @@ class RoomCreate(BaseModel): options: list[str] password: str | None = None ttl: int = 3600 - tags: list[str] = [] + tags: list[str] = Field(default_factory=list) allow_multiple: bool = False is_private: bool = False + participants: list[str] = Field(default_factory=list) + option_allowed_participants: list[list[str]] | None = None @field_validator('options') @classmethod @@ -24,6 +26,19 @@ def validate_options(cls, v): raise ValueError('최소 2개의 옵션이 필요합니다') return v + @field_validator('participants') + @classmethod + def validate_participants(cls, v): + participants = [] + seen = set() + for participant in v: + name = participant.strip() + if not name or name in seen: + continue + participants.append(name) + seen.add(name) + return participants + @field_validator('tags') @classmethod def validate_tags(cls, v): @@ -34,10 +49,40 @@ def validate_tags(cls, v): raise ValueError('태그는 20자 이내여야 합니다') return v + @model_validator(mode='after') + def validate_option_allowed_participants(self): + if self.option_allowed_participants is None: + return self + + if not self.participants: + raise ValueError('참여 가능 인원을 설정하려면 참여 인원이 필요합니다') + + if len(self.option_allowed_participants) != len(self.options): + raise ValueError('선택지별 참여 가능 인원 배열은 선택지 개수와 같아야 합니다') + + participant_names = set(self.participants) + normalized_permissions = [] + for allowed_participants in self.option_allowed_participants: + option_permissions = [] + seen = set() + for participant in allowed_participants: + name = participant.strip() + if not name or name in seen: + continue + if name not in participant_names: + raise ValueError(f'참여 인원에 없는 이름입니다: {name}') + option_permissions.append(name) + seen.add(name) + normalized_permissions.append(option_permissions) + + self.option_allowed_participants = normalized_permissions + return self + class VoteRequest(BaseModel): options: list[str] fingerprint: str + participant: str | None = None @field_validator('options') @classmethod @@ -46,6 +91,11 @@ def validate_options(cls, v): raise ValueError('최소 1개의 옵션을 선택해야 합니다') return v + @field_validator('participant') + @classmethod + def validate_participant(cls, v): + return v.strip() if v else None + class PasswordVerifyRequest(BaseModel): password: str | None = None diff --git a/backend/app/routers/rooms.py b/backend/app/routers/rooms.py index b20b5e1..2315e4f 100644 --- a/backend/app/routers/rooms.py +++ b/backend/app/routers/rooms.py @@ -39,7 +39,9 @@ async def create_room_endpoint(room: RoomCreate): ttl=room.ttl, tags=room.tags, allow_multiple=room.allow_multiple, - is_private=room.is_private + is_private=room.is_private, + participants=room.participants, + option_allowed_participants=room.option_allowed_participants, ) @@ -97,6 +99,28 @@ async def vote(room_uuid: str, vote_request: VoteRequest, request: Request): if not room.get("allow_multiple", False) and len(vote_request.options) > 1: raise HTTPException(status_code=400, detail="이 투표는 복수 선택이 허용되지 않습니다") + participants = room.get("participants", []) + if participants: + if not vote_request.participant: + raise HTTPException(status_code=400, detail="참여자를 선택해주세요") + + if vote_request.participant not in participants: + raise HTTPException(status_code=400, detail="참여 인원에 없는 이름입니다") + + option_allowed_participants = room.get("option_allowed_participants", []) + for option in vote_request.options: + option_index = room["options"].index(option) + if option_index < len(option_allowed_participants): + allowed_participants = option_allowed_participants[option_index] + else: + allowed_participants = participants + + if vote_request.participant not in allowed_participants: + raise HTTPException( + status_code=403, + detail=f"{vote_request.participant}님은 선택할 수 없는 옵션입니다: {option}", + ) + client_ip = request.client.host if await has_voted(room_uuid, vote_request.fingerprint, client_ip): raise HTTPException(status_code=409, detail="이미 투표하셨습니다") diff --git a/backend/app/services/room.py b/backend/app/services/room.py index c50e4be..ce653c1 100644 --- a/backend/app/services/room.py +++ b/backend/app/services/room.py @@ -16,7 +16,9 @@ async def create_room( ttl: int, tags: list[str] | None = None, allow_multiple: bool = False, - is_private: bool = False + is_private: bool = False, + participants: list[str] | None = None, + option_allowed_participants: list[list[str]] | None = None, ) -> dict: """투표방 생성""" redis = get_redis() @@ -26,11 +28,17 @@ async def create_room( timestamp = created_at.timestamp() tags = tags or [] + participants = participants or [] + if participants and option_allowed_participants is None: + option_allowed_participants = [participants.copy() for _ in options] + option_allowed_participants = option_allowed_participants or [] room_data = { "uuid": room_uuid, "title": title, "options": options, + "participants": participants, + "option_allowed_participants": option_allowed_participants, "created_at": created_at.isoformat(), "expires_at": expires_at.isoformat(), "has_password": password is not None, diff --git a/frontend/app/create/restricted/page.tsx b/frontend/app/create/restricted/page.tsx new file mode 100644 index 0000000..73e6822 --- /dev/null +++ b/frontend/app/create/restricted/page.tsx @@ -0,0 +1,695 @@ +"use client"; + +import { useState, type SyntheticEvent } from "react"; +import { useRouter } from "next/navigation"; +import { + ArrowLeft, + Calendar, + Check, + CheckSquare, + ChevronDown, + Lock, + Plus, + RotateCcw, + Trash2, + UserPlus, + X, +} from "lucide-react"; + +import { Navbar } from "@/components/site/navbar"; +import { useLocale } from "@/components/providers/locale-provider"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { api, type MyPollRecord } from "@/lib/api"; + +type OptionDraft = { + id: string; + label: string; + allowedParticipants: string[]; +}; + +const initialParticipants: string[] = []; + +const createInitialOptions = (): OptionDraft[] => [ + { + id: "option-1", + label: "", + allowedParticipants: initialParticipants, + }, + { + id: "option-2", + label: "", + allowedParticipants: initialParticipants, + }, +]; + +export default function RestrictedCreatePage() { + const router = useRouter(); + const { messages } = useLocale(); + const t = messages.create; + const [title, setTitle] = useState(""); + const [participantInput, setParticipantInput] = useState(""); + const [isParticipantComposing, setIsParticipantComposing] = useState(false); + const [participants, setParticipants] = useState(initialParticipants); + const [options, setOptions] = useState(createInitialOptions); + const [tags, setTags] = useState([]); + const [tagInput, setTagInput] = useState(""); + const [isTagComposing, setIsTagComposing] = useState(false); + const [expiresIn, setExpiresIn] = useState("24"); + const [password, setPassword] = useState(""); + const [allowMultiple, setAllowMultiple] = useState(false); + const [isPrivate, setIsPrivate] = useState(false); + const [showAdvanced, setShowAdvanced] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(""); + + const addParticipant = () => { + const name = participantInput.trim(); + + if (!name || participants.includes(name)) return; + + setParticipants((current) => [...current, name]); + setOptions((current) => + current.map((option) => ({ + ...option, + allowedParticipants: [...option.allowedParticipants, name], + })) + ); + setParticipantInput(""); + }; + + const removeParticipant = (name: string) => { + setParticipants((current) => + current.filter((participant) => participant !== name) + ); + setOptions((current) => + current.map((option) => ({ + ...option, + allowedParticipants: option.allowedParticipants.filter( + (participant) => participant !== name + ), + })) + ); + }; + + const updateOptionLabel = (optionId: string, label: string) => { + setOptions((current) => + current.map((option) => + option.id === optionId ? { ...option, label } : option + ) + ); + }; + + const removeOption = (optionId: string) => { + if (options.length <= 2) return; + + setOptions((current) => current.filter((option) => option.id !== optionId)); + }; + + const addOption = () => { + setOptions((current) => [ + ...current, + { + id: `option-${Date.now()}`, + label: "", + allowedParticipants: participants, + }, + ]); + }; + + const excludeParticipant = (optionId: string, name: string) => { + setOptions((current) => + current.map((option) => + option.id === optionId + ? { + ...option, + allowedParticipants: option.allowedParticipants.filter( + (participant) => participant !== name + ), + } + : option + ) + ); + }; + + const restoreParticipant = (optionId: string, name: string) => { + setOptions((current) => + current.map((option) => + option.id === optionId && !option.allowedParticipants.includes(name) + ? { + ...option, + allowedParticipants: [...option.allowedParticipants, name], + } + : option + ) + ); + }; + + const allowAll = (optionId: string) => { + setOptions((current) => + current.map((option) => + option.id === optionId + ? { ...option, allowedParticipants: participants } + : option + ) + ); + }; + + const addTag = () => { + const trimmedTag = tagInput.trim(); + + if (!trimmedTag) return; + + if (trimmedTag.length > 20) { + setError(t.errors.tagTooLong); + return; + } + + if (tags.length >= 5) { + setError(t.errors.tooManyTags); + return; + } + + if (!tags.includes(trimmedTag)) { + setTags((current) => [...current, trimmedTag]); + } + + setTagInput(""); + setError(""); + }; + + const removeTag = (tagToRemove: string) => { + setTags((current) => current.filter((tag) => tag !== tagToRemove)); + }; + + const handleSubmit = async (event: SyntheticEvent) => { + event.preventDefault(); + setError(""); + + if (!title.trim()) { + setError(t.errors.missingTitle); + return; + } + + const validOptionDrafts = options + .map((option) => ({ + label: option.label.trim(), + allowedParticipants: option.allowedParticipants.filter((name) => + participants.includes(name) + ), + })) + .filter((option) => option.label); + + if (validOptionDrafts.length < 2) { + setError(t.errors.missingOptions); + return; + } + + if (participants.length < 1) { + setError("참여 인원을 1명 이상 추가해주세요."); + return; + } + + setIsLoading(true); + + try { + const ttl = parseInt(expiresIn, 10) * 60 * 60; + const validOptions = validOptionDrafts.map((option) => option.label); + const optionAllowedParticipants = validOptionDrafts.map( + (option) => option.allowedParticipants + ); + const data = await api.createRoom({ + title: title.trim(), + options: validOptions, + participants, + option_allowed_participants: optionAllowedParticipants, + password: password.trim() || undefined, + ttl, + tags, + allow_multiple: allowMultiple, + is_private: isPrivate, + }); + + let record: MyPollRecord = { + uuid: data.uuid, + title: title.trim(), + created_at: new Date().toISOString(), + expires_at: null, + tags, + total_votes: 0, + has_password: Boolean(password.trim()), + allow_multiple: allowMultiple, + is_private: isPrivate, + share_token: data.share_token, + }; + + try { + const full = await api.getRoom(data.uuid); + record = { + uuid: full.uuid, + title: full.title, + created_at: full.created_at, + expires_at: full.expires_at ?? null, + tags: full.tags ?? tags, + total_votes: 0, + has_password: full.has_password, + allow_multiple: full.allow_multiple ?? allowMultiple, + is_private: isPrivate, + share_token: data.share_token, + }; + } catch { + // Keep the local fallback record if the detail fetch fails. + } + + try { + const key = "fastvote:my-polls"; + const raw = localStorage.getItem(key); + const list: MyPollRecord[] = raw ? (JSON.parse(raw) as MyPollRecord[]) : []; + const filtered = list.filter((item) => item.uuid !== record.uuid); + filtered.unshift(record); + localStorage.setItem(key, JSON.stringify(filtered.slice(0, 30))); + } catch (storageErr) { + console.warn("Failed to save my poll to localStorage", storageErr); + } + + const params = new URLSearchParams(); + if (data.share_token) { + params.set("share_token", data.share_token); + } + const query = params.toString(); + router.push(query ? `/vote/${data.uuid}?${query}` : `/vote/${data.uuid}`); + } catch (err) { + setError(err instanceof Error ? err.message : t.errors.submitFailed); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ +
+
+ +
+ 특정인원 투표 생성 +

+ 참여자별 선택 제한 투표 +

+

+ 기존 투표 생성 흐름에 참여자와 선택지별 제외 인원을 추가합니다. +

+
+
+ + + + + 투표 정보 + + + +
+
+ + setTitle(event.target.value)} + placeholder={t.questionPlaceholder} + /> +
+ +
+
+ + + {participants.length}명 + +
+
+ setParticipantInput(event.target.value)} + onCompositionStart={() => setIsParticipantComposing(true)} + onCompositionEnd={() => setIsParticipantComposing(false)} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing || isParticipantComposing) { + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + addParticipant(); + } + }} + placeholder="참여자 이름 입력 후 Enter" + /> + +
+
+ {participants.map((name) => ( + + ))} +
+
+ +
+
+
+ +

+ 기본값은 모든 참여자가 선택 가능입니다. 제외할 이름의 칩을 눌러 제거하세요. +

+
+ +
+ +
+ {options.map((option, index) => { + const excludedParticipants = participants.filter( + (name) => !option.allowedParticipants.includes(name) + ); + + return ( +
+
+
+ {index + 1} +
+
+
+ + updateOptionLabel(option.id, event.target.value) + } + placeholder={t.optionPlaceholder(index + 1)} + /> + {options.length > 2 && ( + + )} +
+ +
+
+ + 선택 가능 인원 + + +
+
+ {option.allowedParticipants.length > 0 ? ( + option.allowedParticipants.map((name) => ( + + )) + ) : ( + + 선택 가능한 인원이 없습니다 + + )} +
+
+ + {excludedParticipants.length > 0 && ( +
+ + 제외 인원 + +
+ {excludedParticipants.map((name) => ( + + ))} +
+
+ )} +
+
+
+ ); + })} +
+
+ +
+
+ + + {t.tagsHint} + +
+
+ setTagInput(event.target.value)} + onCompositionStart={() => setIsTagComposing(true)} + onCompositionEnd={() => setIsTagComposing(false)} + onKeyDown={(event) => { + if (event.nativeEvent.isComposing || isTagComposing) { + return; + } + + if (event.key === "Enter") { + event.preventDefault(); + addTag(); + } + }} + placeholder={t.tagsPlaceholder} + disabled={tags.length >= 5} + /> + +
+ {tags.length > 0 && ( +
+ {tags.map((tag) => ( + + ))} +
+ )} +
+ +
+ + +
+
+
+
+
+ +
+
+ {t.allowMultipleLabel} +
+
+ {t.allowMultipleHint} +
+
+
+ +
+ +
+
+ +
+
+ {t.isPrivateLabel} +
+
+ {t.isPrivateHint} +
+
+
+ +
+ + {isPrivate && ( +
+ + setPassword(event.target.value)} + placeholder={t.passwordPlaceholder} + /> +
+ )} + +
+
+ +
+
+ {t.expiresLabel} +
+
+ {t.expiresHint} +
+
+
+
+ + +
+
+
+
+
+
+ + {error && ( +
+ {error} +
+ )} + + +
+
+
+
+
+ ); +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index b30bb81..a82e628 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { ArrowRight, CheckCircle2, ShieldCheck, Zap } from "lucide-react"; +import { CheckCircle2, ShieldCheck, Zap } from "lucide-react"; import { Navbar } from "@/components/site/navbar"; import { useLocale } from "@/components/providers/locale-provider"; @@ -9,6 +9,7 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; +import { CreateModeDialog } from "@/components/site/create-mode-dialog"; export default function Home() { const { messages } = useLocale(); @@ -33,11 +34,9 @@ export default function Home() {

- + + {t.primaryCta} + @@ -121,9 +120,9 @@ export default function Home() {

{t.ctaDescription}

- + + {t.ctaPrimary} + diff --git a/frontend/app/vote/[uuid]/vote-client.tsx b/frontend/app/vote/[uuid]/vote-client.tsx index e45a13b..be5047e 100644 --- a/frontend/app/vote/[uuid]/vote-client.tsx +++ b/frontend/app/vote/[uuid]/vote-client.tsx @@ -36,6 +36,8 @@ export function VoteClient({ params }: PageProps) { const [passwordError, setPasswordError] = useState(''); const [selectedOptions, setSelectedOptions] = useState([]); + const [selectedParticipant, setSelectedParticipant] = useState(''); + const [isParticipantModalOpen, setIsParticipantModalOpen] = useState(false); const [voteError, setVoteError] = useState(''); const [copySuccess, setCopySuccess] = useState(false); @@ -63,6 +65,9 @@ export function VoteClient({ params }: PageProps) { try { const data = await api.getRoom(uuid); setRoom(data); + const hasParticipants = (data.participants?.length ?? 0) > 0; + setSelectedParticipant(''); + setIsParticipantModalOpen(hasParticipants); const params = new URLSearchParams(window.location.search); const shareToken = params.get('share_token'); @@ -233,8 +238,14 @@ export function VoteClient({ params }: PageProps) { setVoteError(''); const fingerprint = getFingerprint(); + if ((room?.participants?.length ?? 0) > 0 && !selectedParticipant) { + setVoteError('참여자를 선택해주세요.'); + setIsParticipantModalOpen(true); + return; + } + try { - await api.vote(uuid, selectedOptions, fingerprint); + await api.vote(uuid, selectedOptions, fingerprint, selectedParticipant || undefined); setState('voted'); showToast(t.voteCompleted); // Confetti effect @@ -248,6 +259,8 @@ export function VoteClient({ params }: PageProps) { if (err instanceof APIError && err.status === 409) { setVoteError(t.voteErrors.alreadyVoted); setState('voted'); + } else if (err instanceof APIError) { + setVoteError(err.message || t.voteErrors.submitFailed); } else { setVoteError(t.voteErrors.submitFailed); } @@ -321,6 +334,34 @@ export function VoteClient({ params }: PageProps) { return Math.max(1, hours); }; + const canParticipantVoteForOption = ( + participant: string, + optionIndex: number + ): boolean => { + if (!room || (room.participants?.length ?? 0) === 0) return true; + if (!participant) return false; + + const allowedParticipants = room.option_allowed_participants?.[optionIndex]; + const fallbackParticipants = room.participants ?? []; + const optionParticipants = Array.isArray(allowedParticipants) + ? allowedParticipants + : fallbackParticipants; + + return optionParticipants.includes(participant); + }; + + const handleParticipantSelect = (participant: string) => { + setSelectedParticipant(participant); + setIsParticipantModalOpen(false); + setVoteError(''); + setSelectedOptions((current) => + current.filter((option) => { + const optionIndex = room?.options.indexOf(option) ?? -1; + return optionIndex >= 0 && canParticipantVoteForOption(participant, optionIndex); + }) + ); + }; + // Loading state if (state === "loading") { return ( @@ -444,10 +485,51 @@ export function VoteClient({ params }: PageProps) { const winnerOption = getWinnerOption(); const isVoting = state === "voting"; const isVoted = state === "voted"; + const isRestrictedPoll = (room.participants?.length ?? 0) > 0; + const shouldShowParticipantModal = + isVoting && isRestrictedPoll && isParticipantModalOpen; return (
+ {shouldShowParticipantModal && ( +
+
+
+ 참여자 선택 +

+ 투표할 이름을 선택해주세요 +

+

+ 선택한 이름에 따라 투표 가능한 선택지가 달라집니다. +

+
+
+ {room.participants?.map((participant) => ( + + ))} +
+ {selectedParticipant && ( + + )} +
+
+ )}
{/* Back to List */} +
+ )}
- {room.options.map((option) => { + {room.options.map((option, optionIndex) => { const isSelected = selectedOptions.includes(option); + const isDisabled = + isRestrictedPoll && + !canParticipantVoteForOption(selectedParticipant, optionIndex); const handleToggle = () => { + if (isDisabled) return; + if (room.allow_multiple) { setSelectedOptions(prev => prev.includes(option) @@ -551,17 +654,21 @@ export function VoteClient({ params }: PageProps) { @@ -597,7 +710,7 @@ export function VoteClient({ params }: PageProps) { + + {open && ( +
setOpen(false)} + > +
event.stopPropagation()} + > +
+
+

+ FastVote +

+

+ 투표 생성 방식 선택 +

+

+ 만들고 싶은 투표 유형을 선택해 주세요. +

+
+ +
+ +
+ + + +
+
+
+ )} + + ); +} diff --git a/frontend/components/site/navbar.tsx b/frontend/components/site/navbar.tsx index 1b930bb..8949a51 100644 --- a/frontend/components/site/navbar.tsx +++ b/frontend/components/site/navbar.tsx @@ -9,6 +9,7 @@ import { useLocale } from "@/components/providers/locale-provider"; import { ThemeToggle } from "@/components/site/theme-toggle"; import { Tooltip } from "@/components/ui/tooltip"; import { Button } from "@/components/ui/button"; +import { CreateModeDialog } from "@/components/site/create-mode-dialog"; export function Navbar() { const router = useRouter(); @@ -66,9 +67,7 @@ export function Navbar() { - + {messages.navbar.actions.create}
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 7566a65..bfdbc44 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -31,6 +31,8 @@ export interface VoteRoom { uuid: string; title: string; options: string[]; + participants?: string[]; + option_allowed_participants?: string[][]; has_password: boolean; created_at: string; tags?: string[]; @@ -51,11 +53,14 @@ export interface VoteResults { export interface VoteRequest { options: string[]; fingerprint: string; + participant?: string; } export interface CreateRoomRequest { title: string; options: string[]; + participants?: string[]; + option_allowed_participants?: string[][]; password?: string; ttl: number; tags: string[]; @@ -194,10 +199,10 @@ export const api = { }), // Submit vote - vote: (uuid: string, options: string[], fingerprint: string) => + vote: (uuid: string, options: string[], fingerprint: string, participant?: string) => fetchAPI<{ success: boolean; message: string }>(`/rooms/${uuid}/vote`, { method: 'POST', - body: JSON.stringify({ options, fingerprint }), + body: JSON.stringify({ options, fingerprint, participant }), }), // Get current results From d5ffee504ea61d39e5b690857355c8a23947f2dd Mon Sep 17 00:00:00 2001 From: leesumok Date: Sat, 2 May 2026 01:23:38 +0900 Subject: [PATCH 2/3] =?UTF-8?q?CI=20=EC=8B=A4=ED=8C=A8=20=EC=9B=90?= =?UTF-8?q?=EC=9D=B8=EC=9D=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_health.py | 11 ++++++ frontend/app/create/page.tsx | 1 - frontend/app/my-polls/my-polls.tsx | 39 +++++++++++-------- frontend/app/vote/[uuid]/vote-client.tsx | 7 +--- .../components/providers/locale-provider.tsx | 28 +++++-------- frontend/components/site/theme-toggle.tsx | 16 +++++--- frontend/components/ui/input.tsx | 3 +- frontend/components/ui/textarea.tsx | 3 +- 8 files changed, 56 insertions(+), 52 deletions(-) create mode 100644 backend/tests/test_health.py diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py new file mode 100644 index 0000000..429c043 --- /dev/null +++ b/backend/tests/test_health.py @@ -0,0 +1,11 @@ +from fastapi.testclient import TestClient + +from app.main import app + + +def test_health_check(): + with TestClient(app) as client: + response = client.get("/api/health") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} diff --git a/frontend/app/create/page.tsx b/frontend/app/create/page.tsx index b815bce..9906108 100644 --- a/frontend/app/create/page.tsx +++ b/frontend/app/create/page.tsx @@ -156,7 +156,6 @@ export default function CreatePage() { localStorage.setItem(key, JSON.stringify(capped)); } catch (storageErr) { // ignore localStorage errors - // eslint-disable-next-line no-console console.warn("Failed to save my poll to localStorage", storageErr); } diff --git a/frontend/app/my-polls/my-polls.tsx b/frontend/app/my-polls/my-polls.tsx index 528e5b5..f61aabc 100644 --- a/frontend/app/my-polls/my-polls.tsx +++ b/frontend/app/my-polls/my-polls.tsx @@ -31,6 +31,7 @@ function saveStored(list: MyPollRecord[]) { export default function MyPolls({ localeCookie }: { localeCookie?: string | null }) { const [items, setItems] = useState([]); + const [nowMs] = useState(() => Date.now()); useEffect(() => { // Load from storage (keep expired items for creator result access) @@ -44,22 +45,26 @@ export default function MyPolls({ localeCookie }: { localeCookie?: string | null }); const limited = loaded.slice(0, MAX_ITEMS); - setItems(limited); - saveStored(limited); - - // Background verification: remove items that return 404 from server - limited.forEach((record) => { - api.getRoom(record.uuid).catch((err) => { - // If 404, remove from storage and state. Do not block rendering. - if (err instanceof APIError && err.status === 404) { - setItems((prev) => { - const next = prev.filter((p) => p.uuid !== record.uuid); - saveStored(next); - return next; - }); - } + const timeoutId = window.setTimeout(() => { + setItems(limited); + saveStored(limited); + + // Background verification: remove items that return 404 from server + limited.forEach((record) => { + api.getRoom(record.uuid).catch((err) => { + // If 404, remove from storage and state. Do not block rendering. + if (err instanceof APIError && err.status === 404) { + setItems((prev) => { + const next = prev.filter((p) => p.uuid !== record.uuid); + saveStored(next); + return next; + }); + } + }); }); - }); + }, 0); + + return () => window.clearTimeout(timeoutId); }, []); // locale for messages @@ -108,7 +113,7 @@ export default function MyPolls({ localeCookie }: { localeCookie?: string | null {/* Badges - fixed height */}
- {room.expires_at && Date.parse(room.expires_at) <= Date.now() && ( + {room.expires_at && Date.parse(room.expires_at) <= nowMs && ( {t.closedBadge} @@ -168,7 +173,7 @@ export default function MyPolls({ localeCookie }: { localeCookie?: string | null href={room.share_token ? `/vote/${room.uuid}?share_token=${encodeURIComponent(room.share_token)}` : `/vote/${room.uuid}`} className="flex items-center gap-1 text-sm font-medium text-emerald-600 transition-colors hover:text-emerald-700 dark:text-emerald-400 dark:hover:text-emerald-300" > - {room.expires_at && Date.parse(room.expires_at) <= Date.now() ? t.viewResults : t.viewPoll} + {room.expires_at && Date.parse(room.expires_at) <= nowMs ? t.viewResults : t.viewPoll}
diff --git a/frontend/app/vote/[uuid]/vote-client.tsx b/frontend/app/vote/[uuid]/vote-client.tsx index be5047e..b2f6dee 100644 --- a/frontend/app/vote/[uuid]/vote-client.tsx +++ b/frontend/app/vote/[uuid]/vote-client.tsx @@ -40,7 +40,6 @@ export function VoteClient({ params }: PageProps) { const [isParticipantModalOpen, setIsParticipantModalOpen] = useState(false); const [voteError, setVoteError] = useState(''); - const [copySuccess, setCopySuccess] = useState(false); const [shouldConnectWs, setShouldConnectWs] = useState(false); const [comments, setComments] = useState([]); @@ -96,7 +95,7 @@ export function VoteClient({ params }: PageProps) { if (shareToken) { try { await api.verifyPassword(uuid, undefined, shareToken); - } catch (err) { + } catch { setState('password'); return; } @@ -143,7 +142,7 @@ export function VoteClient({ params }: PageProps) { }; loadRoom(); - }, [uuid]); + }, [uuid, t.errors.loadFailed, t.errors.notFound]); // WebSocket connection for real-time updates useEffect(() => { @@ -271,9 +270,7 @@ export function VoteClient({ params }: PageProps) { const handleCopyLink = async () => { try { await navigator.clipboard.writeText(window.location.href); - setCopySuccess(true); showToast(t.copied); - setTimeout(() => setCopySuccess(false), 2000); } catch (err) { console.error('Failed to copy:', err); } diff --git a/frontend/components/providers/locale-provider.tsx b/frontend/components/providers/locale-provider.tsx index 5479e8e..f0edf80 100644 --- a/frontend/components/providers/locale-provider.tsx +++ b/frontend/components/providers/locale-provider.tsx @@ -1,11 +1,10 @@ "use client"; -import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { defaultLocale, getMessages, - getLocaleFromCookie, localeCookieName, type Locale, type Messages, @@ -26,26 +25,17 @@ type LocaleProviderProps = { }; const ONE_YEAR = 60 * 60 * 24 * 365; +const subscribeToClientMount = () => () => {}; +const getClientMountedSnapshot = () => true; +const getServerMountedSnapshot = () => false; export function LocaleProvider({ initialLocale, children }: LocaleProviderProps) { const [locale, setLocaleState] = useState(initialLocale ?? defaultLocale); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - // Sync locale from the cookie on the client (default is ko). - const cookieLocale = getLocaleFromCookie( - document.cookie - .split(";") - .map((part) => part.trim()) - .find((part) => part.startsWith(`${localeCookieName}=`)) - ?.split("=")[1] - ); - - if (cookieLocale && cookieLocale !== locale) { - setLocaleState(cookieLocale); - } - setMounted(true); - }, []); + const mounted = useSyncExternalStore( + subscribeToClientMount, + getClientMountedSnapshot, + getServerMountedSnapshot + ); useEffect(() => { document.documentElement.lang = locale; diff --git a/frontend/components/site/theme-toggle.tsx b/frontend/components/site/theme-toggle.tsx index 48efa7d..555c172 100644 --- a/frontend/components/site/theme-toggle.tsx +++ b/frontend/components/site/theme-toggle.tsx @@ -1,20 +1,24 @@ "use client"; -import { useEffect, useState } from "react"; +import { useSyncExternalStore } from "react"; import { Moon, Sun } from "lucide-react"; import { useTheme } from "next-themes"; import { Button } from "@/components/ui/button"; import { useLocale } from "@/components/providers/locale-provider"; +const subscribeToClientMount = () => () => {}; +const getClientMountedSnapshot = () => true; +const getServerMountedSnapshot = () => false; + export function ThemeToggle() { const { resolvedTheme, setTheme } = useTheme(); const { messages } = useLocale(); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); + const mounted = useSyncExternalStore( + subscribeToClientMount, + getClientMountedSnapshot, + getServerMountedSnapshot + ); const isDark = resolvedTheme === "dark"; diff --git a/frontend/components/ui/input.tsx b/frontend/components/ui/input.tsx index 2230698..14d4877 100644 --- a/frontend/components/ui/input.tsx +++ b/frontend/components/ui/input.tsx @@ -4,8 +4,7 @@ import * as React from "react"; import { cn } from "@/lib/utils"; -export interface InputProps - extends React.InputHTMLAttributes {} +export type InputProps = React.InputHTMLAttributes; const Input = React.forwardRef( ({ className, type, ...props }, ref) => { diff --git a/frontend/components/ui/textarea.tsx b/frontend/components/ui/textarea.tsx index 4d419dc..f275ba6 100644 --- a/frontend/components/ui/textarea.tsx +++ b/frontend/components/ui/textarea.tsx @@ -4,8 +4,7 @@ import * as React from "react"; import { cn } from "@/lib/utils"; -export interface TextareaProps - extends React.TextareaHTMLAttributes {} +export type TextareaProps = React.TextareaHTMLAttributes; const Textarea = React.forwardRef( ({ className, ...props }, ref) => { From 16348d3e8194dcb40341a40446b9c287ec048a44 Mon Sep 17 00:00:00 2001 From: leesumok Date: Sat, 2 May 2026 01:26:09 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=AA=A8=EB=93=88=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=B4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/tests/test_health.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/tests/test_health.py b/backend/tests/test_health.py index 429c043..f71aac5 100644 --- a/backend/tests/test_health.py +++ b/backend/tests/test_health.py @@ -1,5 +1,10 @@ +import sys +from pathlib import Path + from fastapi.testclient import TestClient +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + from app.main import app