Skip to content
Draft
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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,28 @@ signed ID tokens, checks private-key assertions, PKCE, nonce, signature failure,
refresh and revocation. It requires no production credentials. The optional compiled HTTP tests are documented in
[`docs/idp-phase4c-handoff.md`](docs/idp-phase4c-handoff.md).

## Freshman content

Web › Freshman contains Resources, Telegram Hub, FAQs and Guide tabs. Resources and Telegram Hub read the
fixed website slots through `web.freshman.getContent`; FAQs select five questions from the existing FAQ catalog.
Edits and adjacent moves use authenticated server functions with `web:content:write`, forwarding the signed-in
user's token through the existing BFF. After mutations the dashboard reloads its loaders before closing dialogs.
A reload failure after a successful write reports that the change was saved, without offering to repeat it.
There is no browser storage, demo catalog or local restore action.

The Guide tab keeps the existing PDF edition management and loads editions only when selected. The former
`/dashboard/web/guides` URL redirects to `/dashboard/web/freshman?tab=guide`. The selected tab stays in the URL;
the full FAQ catalog loads only on the FAQs tab. Page titles, introductory text and slot counts remain fixed.

### Pending backend package release

The source integration targets the backend Freshman contract on `bia/freshmans` (migration
`0026_freshman_content`). The currently pinned `@polinetwork/backend@1.4.0` does not yet export `web.freshman`.
Once a version containing these procedures is published, update this dependency and `pnpm-lock.yaml`, then
rerun `pnpm check`, `pnpm typecheck`, `pnpm test` and `pnpm build`. Until that update, the normal typecheck is
expected to report the missing Freshman contract. No declaration patches or type-error suppression directives are used.
The running backend must also include the Freshman router and its migration before using this page.

## Production and cutover

`pnpm build` writes `.output/`; `pnpm start` runs it. Main-branch CI publishes `ghcr.io/polinetworkorg/admin:latest`.
Expand Down
19 changes: 7 additions & 12 deletions src/components/shell/nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export type PageMatch =
| { kind: "m365-members" }
| { kind: "web-projects" }
| { kind: "web-associations" }
| { kind: "web-guides" }
| { kind: "web-freshman" }
| { kind: "web-faqs" }
| { kind: "web-labels" }
| { kind: "web-categories" }
Expand Down Expand Up @@ -209,13 +209,7 @@ const web: Service = {
path: "/dashboard/web/associations",
searchPlaceholder: "Search associations…",
},
{
id: "guides",
title: "Freshman guide",
icon: BookOpen,
path: "/dashboard/web/guides",
searchPlaceholder: "Search by version…",
},
{ id: "freshman", title: "Freshman", icon: BookOpen, path: "/dashboard/web/freshman" },
{
id: "faqs",
title: "FAQs",
Expand Down Expand Up @@ -352,7 +346,8 @@ export function matchPath(pathname: string): PageMatch {
if (third === undefined) {
if (second === "projects") return { kind: "web-projects" }
if (second === "associations") return { kind: "web-associations" }
if (second === "guides") return { kind: "web-guides" }
if (second === "freshman") return { kind: "web-freshman" }
if (second === "guides") return { kind: "web-freshman" }
if (second === "faqs") return { kind: "web-faqs" }
if (second === "group-labels") return { kind: "web-labels" }
if (second === "groups-by-label") return { kind: "web-categories" }
Expand Down Expand Up @@ -401,7 +396,7 @@ export function serviceFor(match: PageMatch): Service {
return m365
case "web-projects":
case "web-associations":
case "web-guides":
case "web-freshman":
case "web-faqs":
case "web-labels":
case "web-categories":
Expand Down Expand Up @@ -446,8 +441,8 @@ export function sectionFor(match: PageMatch): Section | null {
return section(web, "projects")
case "web-associations":
return section(web, "associations")
case "web-guides":
return section(web, "guides")
case "web-freshman":
return section(web, "freshman")
case "web-faqs":
return section(web, "faqs")
case "web-labels":
Expand Down
13 changes: 11 additions & 2 deletions src/features/faqs/faqs-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { appToast, Count, PageBar, PageContent, SearchField, useCan } from "@/co
import { Accordion } from "@/components/ui/accordion"
import { Button } from "@/components/ui/button"
import type { FAQItem, FAQs } from "@/lib/api/types"
import { errorHasCode } from "@/lib/errors"
import { formatNumber, pluralize } from "@/lib/format"
import { cn } from "@/lib/utils"

Expand Down Expand Up @@ -301,7 +302,11 @@ export function FAQsPage({ categories }: { categories: FAQs }) {
await deleteCategoryFn({ data: { id: category.categoryId } })
} catch (error) {
console.error(error)
throw new Error("Couldn't delete the category.")
throw new Error(
errorHasCode(error, "FEATURED_FAQ")
? "A question in this category is featured on the freshman page. Replace it in Web › Freshman first."
: "Couldn't delete the category."
)
}
edit.stop()
editDirty.current = false
Expand All @@ -324,7 +329,11 @@ export function FAQsPage({ categories }: { categories: FAQs }) {
await deleteFAQFn({ data: { id: deletingFaq.faqId } })
} catch (error) {
console.error(error)
throw new Error("Couldn't delete the FAQ.")
throw new Error(
errorHasCode(error, "FEATURED_FAQ")
? "This question is featured on the freshman page. Replace it in Web › Freshman first."
: "Couldn't delete the FAQ."
)
}
await router.invalidate({ sync: true })
setOpenIds((current) => current.filter((id) => id !== deletingFaq.faqId))
Expand Down
119 changes: 119 additions & 0 deletions src/features/freshman/freshman-card-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { useServerFn } from "@tanstack/react-start"
import { useId, useState } from "react"

import { fieldControl, fieldHintId, FormDialog, FormField } from "@/components/primitives"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"

import { FreshmanIconPicker } from "./freshman-icon"
import { updateFreshmanCard } from "./freshman.functions"
import { updateFreshmanCardInput } from "./freshman.validation"
import type { FreshmanCard, FreshmanCardInput, FreshmanCardErrors } from "./types"

export function FreshmanCardDialog({
card,
section,
open,
onClose,
onSaved,
}: {
card: FreshmanCard
open: boolean
section: "resources" | "telegram"
onClose: () => void
onSaved: () => Promise<void>
}) {
const updateFreshmanCardFn = useServerFn(updateFreshmanCard)
const fieldPrefix = useId()
const initial: FreshmanCardInput = {
title: card.title,
description: card.description,
href: card.href,
icon: card.icon,
}
const [values, setValues] = useState(initial)
const [errors, setErrors] = useState<FreshmanCardErrors>({})
const dirty =
values.title !== initial.title ||
values.description !== initial.description ||
values.href !== initial.href ||
values.icon !== initial.icon

async function submit() {
const parsed = updateFreshmanCardInput.safeParse({ id: card.id, ...values })
const nextErrors: FreshmanCardErrors = {}
if (!parsed.success)
for (const issue of parsed.error.issues) {
const field = issue.path[0]
if (field === "title" || field === "description" || field === "href" || field === "icon")
nextErrors[field] = issue.message
}
setErrors(nextErrors)
if (!parsed.success) {
const firstInvalid = Object.keys(nextErrors)[0]
if (firstInvalid) document.getElementById(`${fieldPrefix}-${firstInvalid}`)?.focus()
return
}
await updateFreshmanCardFn({ data: parsed.data })
await onSaved()
}

return (
<FormDialog
open={open}
onOpenChange={(open) => {
if (!open) onClose()
}}
title={`Edit ${card.title}`}
description="Update this fixed position on the freshman page."
noun="card"
dirty={dirty}
submitLabel="Save changes"
canSubmit={dirty}
onSubmit={submit}
>
<FormField label="Title" htmlFor={`${fieldPrefix}-title`} error={errors.title}>
<Input
id={`${fieldPrefix}-title`}
value={values.title}
maxLength={120}
aria-invalid={!!errors.title}
aria-describedby={fieldHintId(`${fieldPrefix}-title`)}
className={fieldControl}
onChange={(event) => setValues({ ...values, title: event.target.value })}
/>
</FormField>
{section === "resources" && (
<FormField label="Description" htmlFor={`${fieldPrefix}-description`} optional error={errors.description}>
<Textarea
id={`${fieldPrefix}-description`}
value={values.description}
maxLength={1000}
aria-invalid={!!errors.description}
aria-describedby={fieldHintId(`${fieldPrefix}-description`)}
className={fieldControl}
onChange={(event) => setValues({ ...values, description: event.target.value })}
/>
</FormField>
)}
<FormField
label="Destination"
htmlFor={`${fieldPrefix}-href`}
optional={section === "resources"}
error={errors.href}
hint="A site path such as /materials, or a full https:// URL. An empty resource has no link."
>
<Input
id={`${fieldPrefix}-href`}
value={values.href}
maxLength={2048}
aria-invalid={!!errors.href}
aria-describedby={fieldHintId(`${fieldPrefix}-href`)}
className={fieldControl}
onChange={(event) => setValues({ ...values, href: event.target.value })}
/>
</FormField>
<FreshmanIconPicker value={values.icon} onValueChange={(icon) => setValues({ ...values, icon })} />
</FormDialog>
)
}
49 changes: 49 additions & 0 deletions src/features/freshman/freshman-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { ArrowDown, ArrowUp, Pencil } from "lucide-react"

import { IconButton } from "@/components/primitives"

import { FRESHMAN_ICONS } from "./freshman-icon"
import type { FreshmanCard } from "./types"

type FreshmanCardRowProps = {
card: FreshmanCard
canWrite: boolean
pending: boolean
first: boolean
last: boolean
onMove: (direction: -1 | 1) => void
onEdit: () => void
}

export function FreshmanCardRow({ card, canWrite, pending, first, last, onMove, onEdit }: FreshmanCardRowProps) {
const Icon = FRESHMAN_ICONS[card.icon]

Check failure on line 19 in src/features/freshman/freshman-card.tsx

View workflow job for this annotation

GitHub Actions / Typecheck and Lint

Element implicitly has an 'any' type because expression of type 'any' can't be used to index type '{ chart: ForwardRefExoticComponent<Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>>; ... 12 more ...; home: ForwardRefExoticComponent<...>; }'.
return (
<div className="flex items-start gap-3 border-b border-(--pn-line) p-4 last:border-0">
<Icon aria-hidden className="mt-1 size-5 shrink-0 text-(--pn-fg-muted)" />
<div className="min-w-0 flex-1">
<h3 className="text-sm font-medium break-words text-(--pn-fg)">{card.title}</h3>
{card.description && <p className="mt-1 text-[13px] text-(--pn-fg-muted)">{card.description}</p>}
<p className="mt-1 break-all text-xs text-(--pn-fg-muted)">{card.href || "No destination set"}</p>
</div>
{canWrite && (
<div className="flex shrink-0 flex-wrap justify-end gap-0.5 max-shell:max-w-18">
<IconButton
label="Move up"
ariaLabel={`Move ${card.title} up`}
icon={ArrowUp}
disabled={pending || first}
onClick={() => onMove(-1)}
/>
<IconButton
label="Move down"
ariaLabel={`Move ${card.title} down`}
icon={ArrowDown}
disabled={pending || last}
onClick={() => onMove(1)}
/>
<IconButton label="Edit" ariaLabel={`Edit ${card.title}`} icon={Pencil} disabled={pending} onClick={onEdit} />
</div>
)}
</div>
)
}
79 changes: 79 additions & 0 deletions src/features/freshman/freshman-faq-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { useServerFn } from "@tanstack/react-start"
import { useId, useState } from "react"

import {
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
FormDialog,
FormField,
} from "@/components/primitives"

import { selectFreshmanFaq } from "./freshman.functions"
import type { FreshmanFaq } from "./types"

export function FreshmanFaqDialog({
index,
open,
catalog,
initialId,
onClose,
onSaved,
}: {
index: number
open: boolean
catalog: FreshmanFaq[]
initialId: number
onClose: () => void
onSaved: () => Promise<void>
}) {
const selectFreshmanFaqFn = useServerFn(selectFreshmanFaq)
const inputId = useId()
const [selected, setSelected] = useState(() => catalog.find((faq) => faq.faqId === initialId) ?? null)
return (
<FormDialog
open={open}
onOpenChange={(open) => {
if (!open) onClose()
}}
title={`FAQ position ${index + 1}`}
description="Choose a question from the FAQ catalog. Selecting a featured question swaps its position."
noun="FAQ selection"
dirty={selected !== null && selected.faqId !== initialId}
submitLabel="Save selection"
canSubmit={selected !== null && selected.faqId !== initialId}
submitOnEnter={false}
onSubmit={async () => {
if (!selected) return
await selectFreshmanFaqFn({ data: { position: index, faqId: selected.faqId } })
await onSaved()
}}
>
<FormField label="Question" htmlFor={inputId}>
<Combobox
items={catalog}
value={selected}
onValueChange={setSelected}
itemToStringLabel={(faq) => faq.titleIt}
itemToStringValue={(faq) => String(faq.faqId)}
>
<ComboboxInput id={inputId} listLabel="questions" placeholder="Search questions…" />
<ComboboxContent>
<ComboboxEmpty>No questions match.</ComboboxEmpty>
<ComboboxList>
{(faq: FreshmanFaq) => (
<ComboboxItem key={faq.faqId} value={faq}>
{faq.titleIt}
</ComboboxItem>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</FormField>
{selected && <p className="text-[13px] text-(--pn-fg-muted)">{selected.descriptionIt}</p>}
</FormDialog>
)
}
Loading
Loading