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 (
+
+
+
+
+
+
+
특정인원 투표 생성
+
+ 참여자별 선택 제한 투표
+
+
+ 기존 투표 생성 흐름에 참여자와 선택지별 제외 인원을 추가합니다.
+
+
+
+
+
+
+
+ 투표 정보
+
+
+
+
+
+
+
+
+ );
+}
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 */}
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