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
33 changes: 26 additions & 7 deletions src/features/form/components/AdminFormDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { SEO_CONFIG } from "@/seo/seo.config";
import { useSeo } from "@/seo/useSeo";
import { Button, ErrorMessage, LoadingSpinner, useToast } from "@/shared/components";
import { useIsMutating } from "@tanstack/react-query";
import { Check, LoaderCircle } from "lucide-react";
import { Check, Link, LoaderCircle } from "lucide-react";
import { useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import styles from "./AdminFormDetailPage.module.css";
Expand Down Expand Up @@ -58,6 +58,17 @@ export const AdminFormDetailPage = () => {
window.open(`/forms/${formid}`, "_blank", "noopener,noreferrer");
};

const handleCopyFormLink = async () => {
if (!formid) return;
const formUrl = `${window.location.origin}/forms/${formid}`;
try {
await navigator.clipboard.writeText(formUrl);
pushToast({ title: "已複製填寫連結", variant: "success" });
} catch {
pushToast({ title: "複製失敗", variant: "error" });
}
};

if (formQuery.isLoading) {
return (
<AdminLayout>
Expand Down Expand Up @@ -94,12 +105,20 @@ export const AdminFormDetailPage = () => {
<Button onClick={handlePublish} disabled={publishFormMutation.isPending || formQuery.data.status !== "DRAFT"}>
{formQuery.data.status === "DRAFT" ? "立即發佈表單" : "已發布"}
</Button>
<Button variant="secondary" onClick={() => window.open(`/orgs/${orgSlug}/forms/${formid}/preview`, "_blank", "noopener,noreferrer")}>
預覽表單(beta)
</Button>
<Button variant="secondary" onClick={handleViewForm} disabled={formQuery.data.status === "DRAFT"}>
檢視表單
</Button>
{formQuery.data.status === "DRAFT" ? (
<Button variant="secondary" onClick={() => window.open(`/orgs/${orgSlug}/forms/${formid}/preview`, "_blank", "noopener,noreferrer")}>
預覽表單(beta)
</Button>
) : (
<>
<Button variant="secondary" onClick={handleViewForm}>
檢視表單
</Button>
<Button variant="secondary" onClick={handleCopyFormLink} title="點按以複製連結">
<Link size={16} />
</Button>
</>
)}
</div>
</div>

Expand Down
69 changes: 8 additions & 61 deletions src/features/form/components/AdminFormPreviewPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import type { FormsSection } from "@nycu-sdc/core-system-sdk";
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import { useParams } from "react-router-dom";
import styles from "./AdminFormPreviewPage.module.css";
import formStyles from "./FormDetailPage.module.css";
import { FormHeader } from "./FormDetail/components/FormHeader/FormHeader";
import { FormPreviewSection } from "./FormDetail/components/FormPreviewSection/FormPreviewSection";
import { FormStructure } from "./FormDetail/components/FormStructure/FormStructure";
import formStyles from "./FormFilloutPage.module.css";
import { FormQuestionRenderer } from "./FormQuestionRenderer";

const ensureEmfontStylesheet = (fontId: string) => {
Expand Down Expand Up @@ -60,6 +63,7 @@ export const AdminFormPreviewPage = () => {
}, [sections]);

const safeCurrentStep = sections.length > 0 ? Math.min(currentStep, sections.length - 1) : currentStep;
const currentSection = sections[safeCurrentStep];
const isFirstStep = safeCurrentStep === 0;
const isLastStep = sections.length === 0 || safeCurrentStep === sections.length - 1;
const isOnPreviewStep = isLastStep && sections[safeCurrentStep]?.id === "preview";
Expand Down Expand Up @@ -128,73 +132,16 @@ export const AdminFormPreviewPage = () => {
<div className={styles.content}>
{coverImageUrl && <img src={coverImageUrl} className={formStyles.cover} alt="表單封面" onError={e => (e.currentTarget.style.display = "none")} />}
<div className={formStyles.container} style={themedContainerStyle}>
<div className={formStyles.header}>
<h1 className={formStyles.title}>{form.title}</h1>
{safeCurrentStep === 0 ? <p className={formStyles.description}>{form.description}</p> : <h2 className={formStyles.sectionHeader}>{sections[safeCurrentStep]?.title}</h2>}
</div>
<FormHeader title={form.title} formDescription={form.description} currentStep={safeCurrentStep} currentSection={currentSection} />

<div className={formStyles.structure}>
<div className={formStyles.structureTitle}>
<h2>表單結構</h2>
</div>
<div className={formStyles.workflow}>
{sections.map((section, index) => (
<button key={section.id} type="button" className={`${formStyles.workflowButton} ${index === safeCurrentStep ? formStyles.active : ""}`} onClick={() => goToStep(index)}>
{section.title}
</button>
))}
</div>
</div>
<FormStructure sections={sections} currentStep={safeCurrentStep} onSectionClick={goToStep} />

<div className={formStyles.form}>
{sections[safeCurrentStep] && (
<div className={formStyles.section}>
<div className={formStyles.fields}>
{isOnPreviewStep ? (
<div className={formStyles.previewSection}>
{sections
.filter(s => s.id !== "preview")
.map(section => (
<div key={section.id} className={formStyles.previewBlock}>
<div className={formStyles.previewHeader}>
<h3 className={formStyles.previewSectionTitle}>{section.title}</h3>
<Button
type="button"
variant="secondary"
onClick={() => {
const idx = sections.findIndex(s => s.id === section.id);
if (idx >= 0) goToStep(idx);
}}
>
修改
</Button>
</div>
<ul className={formStyles.previewList}>
{section.questions?.map((q, qi) => {
const raw = answers[q.id] ?? "";
const displayValue = raw
? q.choices
? raw
.split(",")
.map(id => q.choices?.find(c => c.id === id)?.name ?? id)
.join("、")
: raw
: "";
return (
<li key={qi}>
<span className={formStyles.previewAnswerLabel}>
{q.title}
{q.required && <span className={formStyles.requiredAsterisk}> *</span>}
</span>
<span>:</span>
<span className={!displayValue && q.required ? formStyles.previewAnswerEmpty : ""}>{displayValue || "未填寫"}</span>
</li>
);
})}
</ul>
</div>
))}
</div>
<FormPreviewSection mode="local" localSections={sections} localAnswers={answers} sections={sections} onSectionClick={goToStep} />
) : (
<>
{sections[safeCurrentStep].questions?.map(question => (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
.header {
display: flex;
flex-direction: column;
gap: 0.75rem;
}

.topBar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}

.saveStatus {
display: inline-flex;
align-items: center;
gap: 0.375rem;
color: var(--comment);
font-size: 0.9rem;
}

.spinningIcon {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

有 LoadingSpinner component 喔

animation: spin 1s linear infinite;
}

@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

.title {
margin-bottom: 1.5rem;
text-align: center;
font-family: var(--form-header-font, inherit);
}

.description {
color: var(--color-caption);
font-size: 1rem;
margin-bottom: 1rem;
}

.sectionHeader {
font-size: 1.6rem;
margin: 0;
color: var(--color-heading);
text-align: left;
font-family: var(--form-header-font, inherit);
}

.sectionDescription {
color: var(--color-caption);
font-size: 1rem;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { Button } from "@/shared/components";
import { AlertCircle, Check, ChevronLeft, LoaderCircle } from "lucide-react";
import styles from "./FormHeader.module.css";

interface FormHeaderProps {
title: string;
formDescription?: string | null;
currentStep: number;
currentSection?: { title?: string; description?: string } | null;
onBack?: () => void;
saveStatus?: "saving" | "error" | "saved";
}

export const FormHeader = ({ title, formDescription, currentStep, currentSection, onBack, saveStatus }: FormHeaderProps) => {
return (
<div className={styles.header}>
{(onBack || saveStatus) && (
<div className={styles.topBar}>
{onBack && (
<Button type="button" onClick={onBack} themeColor="var(--foreground)">
<ChevronLeft size={16} />
返回表單列表
</Button>
)}
{saveStatus && (
<div className={styles.saveStatus} aria-live="polite">
{saveStatus === "saving" ? (
<>
<LoaderCircle size={16} className={styles.spinningIcon} />
<span>儲存中</span>
</>
) : saveStatus === "error" ? (
<>
<AlertCircle size={16} />
<span>有問題</span>
</>
) : (
<>
<Check size={16} />
<span>已儲存</span>
</>
)}
</div>
)}
</div>
)}
<h1 className={styles.title}>{title}</h1>
{currentStep === 0 && formDescription && <div className={styles.description} dangerouslySetInnerHTML={{ __html: formDescription }} />}
<h2 className={styles.sectionHeader}>{currentSection?.title}</h2>
{currentSection?.description && <div className={styles.sectionDescription} dangerouslySetInnerHTML={{ __html: currentSection.description }} />}
Comment on lines +47 to +50
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FormHeader renders formDescription/currentSection.description via dangerouslySetInnerHTML without any sanitization. In this PR it’s now used by AdminFormPreviewPage too (previously plain text), which increases stored-XSS risk if those fields can contain user-controlled HTML. Consider sanitizing before rendering or rendering as plain text/Markdown with a sanitizer.

Copilot uses AI. Check for mistakes.
</div>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
.previewSection {
display: flex;
flex-direction: column;
gap: 4rem;
}

.previewBlock {
display: flex;
flex-direction: column;
gap: 1rem;
}

.previewHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 0.75rem;
}

.previewTitle {
font-size: 1.6rem;
font-weight: 520;
color: var(--color-heading);
margin: 0;
}

.previewList {
list-style: disc;
padding-left: 1.5rem;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}

.previewList li {
font-size: 1rem;
color: var(--color-body);
line-height: 1.5;
}

.previewList li strong {
color: var(--color-heading);
font-weight: 600;
margin-right: 0.5rem;
}

.previewSectionTitle {
margin: 0;
}

.previewAnswerLabel {
font-weight: 500;
}

.previewAnswerEmpty {
color: var(--red);
}

.requiredAsterisk {
color: var(--red);
}

.caption {
color: var(--color-caption);
}
Loading