Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions client/src/components/Calendar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ type Props = {
editingSlots: EditingSlot[];
viewingSlots: ViewingSlot[];
guestIdToName: Record<string, string>;
guestIdToComment: Record<string, string>;
participationOptions: ParticipationOption[];
currentParticipationOptionId: string;
editMode: boolean;
Expand Down Expand Up @@ -96,6 +97,7 @@ export const Calendar = ({
editingSlots,
viewingSlots,
guestIdToName,
guestIdToComment,
participationOptions,
currentParticipationOptionId,
editMode,
Expand Down Expand Up @@ -172,7 +174,10 @@ export const Calendar = ({
.filter((option) => optionGroups.has(option.id))
.map((option) => {
const guestIds = optionGroups.get(option.id) || [];
const guestNames = guestIds.map((guestId) => guestIdToName[guestId] || guestId);
const guestNames = guestIds.map((guestId) => {
const name = guestIdToName[guestId] || guestId;
return guestIdToComment[guestId] ? `${name} 💬` : name;
});
const optionOpacity = 1 - (1 - OPACITY) ** guestIds.length;

return {
Expand Down Expand Up @@ -228,7 +233,7 @@ export const Calendar = ({
});

setEvents([...editingEvents, ...viewingEvents]);
}, [editingSlots, viewingSlots, guestIdToName, participationOptions]);
}, [editingSlots, viewingSlots, guestIdToName, guestIdToComment, participationOptions]);

// viewing events の背景スタイルを動的に注入
useEffect(() => {
Expand Down
3 changes: 3 additions & 0 deletions client/src/pages/Project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ export default function ProjectPage() {
<input
{...register("name")}
id="input-name"
maxLength={100}
className={`w-full rounded-lg border px-3 py-2 text-base transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:px-4 sm:py-2.5 ${errors.name ? "border-red-500" : "border-slate-300"}`}
placeholder="例: 〇〇サークル 対面定例会議"
onBlur={() => trigger("name")}
Expand All @@ -333,6 +334,7 @@ export default function ProjectPage() {
<textarea
{...register("description")}
id="input-description"
maxLength={1000}
className={`w-full rounded-lg border px-3 py-2 text-base transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:px-4 sm:py-2.5 ${errors.description ? "border-red-500" : "border-slate-300"}`}
placeholder="イベントの詳細や注意事項などを入力"
rows={3}
Expand Down Expand Up @@ -592,6 +594,7 @@ export default function ProjectPage() {
type="text"
{...register(`participationOptions.${index}.label`)}
defaultValue={field.label}
maxLength={50}
placeholder="参加形態名(例:対面、オンライン)"
className={`flex-1 rounded-lg border px-3 py-2 text-base transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:px-4 sm:py-2.5 ${errors.participationOptions?.[index]?.label ? "border-red-500" : "border-slate-300"}`}
onBlur={() => {
Expand Down
129 changes: 92 additions & 37 deletions client/src/pages/eventId/Submission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,10 @@ export default function SubmissionPage() {

const [selectedParticipationOptionId, setSelectedParticipationOptionId] = useState<string | null>(null);

const [comment, setComment] = useState(meAsGuest?.comment ?? "");

const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const [guestListExpanded, setGuestListExpanded] = useState(true);

const [toast, setToast] = useState<{
message: string;
Expand All @@ -153,6 +156,7 @@ export default function SubmissionPage() {
setPostLoading(true);
const payload = {
name: guestName,
comment: comment.trim() || null,
projectId: projectId || "",
slots: slots.map((slot) => ({
start: slot.start.toISOString(),
Expand Down Expand Up @@ -210,12 +214,13 @@ export default function SubmissionPage() {
await Promise.all([fetchProject()]);
setPostLoading(false);
},
[guestName, projectId, fetchProject],
[guestName, comment, projectId, fetchProject],
);

useEffect(() => {
if (meAsGuest) {
setGuestName(meAsGuest.name);
setComment(meAsGuest.comment ?? "");
setEditMode(false);
}
}, [meAsGuest]);
Expand All @@ -232,6 +237,11 @@ export default function SubmissionPage() {
return Object.fromEntries(project.guests.map((g) => [g.id, g.name]));
}, [project]);

const guestIdToComment = useMemo(() => {
if (!project) return {};
return Object.fromEntries(project.guests.filter((g) => g.comment).map((g) => [g.id, g.comment as string]));
}, [project]);

// init viewing slots
const viewingSlots = useMemo(() => {
if (!project) return [];
Expand Down Expand Up @@ -383,58 +393,103 @@ export default function SubmissionPage() {
editingSlots={editMode ? editingSlots : []}
viewingSlots={viewingSlots}
guestIdToName={guestIdToName}
guestIdToComment={guestIdToComment}
participationOptions={project.participationOptions}
currentParticipationOptionId={selectedParticipationOptionId}
editMode={editMode}
onChangeEditingSlots={setEditingSlots}
/>

{/* 参加者一覧 */}
{project.guests.length > 0 && (
<div className="mt-1 border-slate-200 border-t pt-3 pb-2">
<button
type="button"
onClick={() => setGuestListExpanded((prev) => !prev)}
className="flex items-center gap-1.5 font-medium text-slate-700 text-sm hover:text-slate-900"
>
参加者 ({project.guests.length}人)
{guestListExpanded ? <LuChevronUp className="h-4 w-4" /> : <LuChevronDown className="h-4 w-4" />}
</button>
{guestListExpanded && (
<ul className="mt-1 divide-y divide-slate-100">
{project.guests.map((guest) => {
const commentText = guestIdToComment[guest.id];
return (
<li key={guest.id} className="py-2.5">
<div className="font-medium text-slate-800 text-sm">{guest.name}</div>
{commentText && (
<div className="mt-0.5 whitespace-pre-wrap break-words text-slate-500 text-sm">
{commentText}
</div>
)}
</li>
);
})}
</ul>
)}
</div>
)}
</div>

<div className="sticky bottom-0 z-10 border-slate-200 border-t bg-white">
<div className="mx-auto max-w-7xl px-4 py-2 sm:px-6 sm:py-3 lg:px-8">
{editMode ? (
<div className="flex items-center gap-2">
<input
type="text"
placeholder="名前"
value={guestName}
onChange={(e) => setGuestName(e.target.value)}
className="min-w-0 flex-1 rounded-lg border border-slate-300 px-3 py-2 text-base transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:px-4 sm:py-2.5"
<div className="flex flex-col gap-2">
<textarea
placeholder="コメント(任意)"
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={2}
maxLength={500}
className="w-full resize-none rounded-lg border border-slate-300 px-3 py-2 text-base transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:px-4"
/>
{!!myGuestId && (
<div className="flex items-center gap-2">
<input
type="text"
placeholder="名前"
value={guestName}
onChange={(e) => setGuestName(e.target.value)}
maxLength={50}
className="min-w-0 flex-1 rounded-lg border border-slate-300 px-3 py-2 text-base transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 sm:px-4 sm:py-2.5"
/>
{!!myGuestId && (
<button
type="button"
className="btn btn-outline shrink-0"
disabled={loading}
onClick={async () => {
if (confirm("更新をキャンセルします。よろしいですか?")) {
setEditingSlots([]);
setGuestName(meAsGuest?.name ?? "");
setComment(meAsGuest?.comment ?? "");
setEditMode(false);
}
}}
>
<span>キャンセル</span>
</button>
)}
<button
type="button"
className="btn btn-outline shrink-0"
disabled={loading}
onClick={async () => {
if (confirm("更新をキャンセルします。よろしいですか?")) {
setEditingSlots([]);
setEditMode(false);
}
className="btn btn-primary inline-flex shrink-0 gap-1.5 sm:gap-2"
disabled={loading || !guestName}
onClick={() => {
if (!guestName) return;
postSubmissions(
editingSlots.map((slot) => ({
start: slot.from.toDate(),
end: slot.to.toDate(),
participationOptionId: slot.participationOptionId,
})),
myGuestId ?? "",
);
}}
>
<span>キャンセル</span>
<LuSend className="sm:h-5 sm:w-5" />
<span>{meAsGuest ? "更新" : "提出"}</span>
</button>
)}
<button
type="button"
className="btn btn-primary inline-flex shrink-0 gap-1.5 sm:gap-2"
disabled={loading || !guestName}
onClick={() => {
if (!guestName) return;
postSubmissions(
editingSlots.map((slot) => ({
start: slot.from.toDate(),
end: slot.to.toDate(),
participationOptionId: slot.participationOptionId,
})),
myGuestId ?? "",
);
}}
>
<LuSend className="sm:h-5 sm:w-5" />
<span>{meAsGuest ? "更新" : "提出"}</span>
</button>
</div>
</div>
) : (
<div className="flex items-center justify-between">
Expand Down
2 changes: 2 additions & 0 deletions client/src/revivers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export function projectReviver(project: ISOStringProject): Project {
hosts: project.hosts.map((host) => ({ ...host })),
guests: project.guests.map((guest) => ({
...guest,
comment: guest.comment ?? undefined,
slots: guest.slots.map((slot) => ({
...slot,
from: dayjs.utc(slot.from).tz(),
Expand All @@ -31,6 +32,7 @@ export function projectReviver(project: ISOStringProject): Project {
meAsGuest: project.meAsGuest
? {
...project.meAsGuest,
comment: project.meAsGuest.comment ?? undefined,
slots: project.meAsGuest.slots.map((slot) => ({
...slot,
from: dayjs.utc(slot.from).tz(),
Expand Down
3 changes: 3 additions & 0 deletions client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type Guest = {
id: string;
name: string;
projectId: string;
comment?: string;
slots: Slot[];
};

Expand Down Expand Up @@ -73,6 +74,7 @@ export type ISOStringProject = {
id: string;
name: string;
projectId: string;
comment: string | null;
slots: {
id: string;
projectId: string;
Expand All @@ -87,6 +89,7 @@ export type ISOStringProject = {
id: string;
name: string;
projectId: string;
comment: string | null;
slots: {
id: string;
projectId: string;
Expand Down
7 changes: 4 additions & 3 deletions common/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ export const participationOptionCreateSchema = z.object({
});

export const submitReqSchema = z.object({
name: z.string(),
name: z.string().min(1, "名前を入力してください").max(50, "名前は50文字以内で入力してください"),
projectId: z.string().length(21),
comment: z.string().max(500, "コメントは500文字以内で入力してください").nullable().optional(),
slots: z.array(
z.object({
start: isoStrToDate,
Expand All @@ -36,8 +37,8 @@ const isQuarterHour = (time: string): boolean => {
};

const baseProjectReqSchema = z.object({
name: z.string().min(1, "イベント名を入力してください"),
description: z.string(),
name: z.string().min(1, "イベント名を入力してください").max(100, "イベント名は100文字以内で入力してください"),
description: z.string().max(1000, "説明は1000文字以内で入力してください"),
startDate: z.string().min(1, "開始日を入力してください"),
// TODO: 新規作成時のみ、過去日付を制限する必要
// .refine(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Guest" ADD COLUMN "comment" TEXT;
1 change: 1 addition & 0 deletions server/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ model Host {
model Guest {
id String @id @default(uuid())
name String
comment String?
browserId String @default(uuid())
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
Expand Down
6 changes: 4 additions & 2 deletions server/src/routes/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ const router = new Hono<{ Variables: AppVariables }>()
async (c) => {
const browserId = c.get("browserId");
const { projectId } = c.req.valid("param");
const { name, slots } = c.req.valid("json");
const { name, comment, slots } = c.req.valid("json");

const existingGuest = await prisma.guest.findUnique({
where: {
Expand All @@ -271,6 +271,7 @@ const router = new Hono<{ Variables: AppVariables }>()
await prisma.guest.create({
data: {
name,
comment: comment?.trim() || null,
browserId,
project: { connect: { id: projectId } },
slots: {
Expand All @@ -296,7 +297,7 @@ const router = new Hono<{ Variables: AppVariables }>()
async (c) => {
const browserId = c.get("browserId");
const { projectId } = c.req.valid("param");
const { name, slots } = c.req.valid("json");
const { name, comment, slots } = c.req.valid("json");

const existingGuest = await prisma.guest.findUnique({
where: { browserId_projectId: { browserId, projectId } },
Expand All @@ -320,6 +321,7 @@ const router = new Hono<{ Variables: AppVariables }>()
data: {
slots: { create: slotData },
name,
comment: comment?.trim() || null,
},
include: { slots: true },
});
Expand Down
Loading