From dcbf402e6c3117f6a5d0619185a613deebc79035 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 16:45:49 +0000 Subject: [PATCH 1/5] Align case-study page design with issue/solution pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move the parent "Case study of" callout below the title and badges, matching how issues render (id → title → badges → parent). - Hide the owner/collaborators panel behind a dedicated Contributors page and the change log behind a History page, reached via quiet meta links at the bottom of the overview — the same pattern issues and solutions use. Both are now nested routes under /case-study/[id]. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012MFHDAHHvfjqmUf7TycaJJ --- app/pages/case-study/[id].vue | 386 +++------------------ app/pages/case-study/[id]/contributors.vue | 23 ++ app/pages/case-study/[id]/history.vue | 53 +++ app/pages/case-study/[id]/index.vue | 300 ++++++++++++++++ 4 files changed, 429 insertions(+), 333 deletions(-) create mode 100644 app/pages/case-study/[id]/contributors.vue create mode 100644 app/pages/case-study/[id]/history.vue create mode 100644 app/pages/case-study/[id]/index.vue diff --git a/app/pages/case-study/[id].vue b/app/pages/case-study/[id].vue index 74e275b..f82bb05 100644 --- a/app/pages/case-study/[id].vue +++ b/app/pages/case-study/[id].vue @@ -41,60 +41,24 @@ const scaleLabel: Record = { global: 'Global', } -function formatDay(s?: string | null): string | null { - if (!s) return null - const d = new Date(s) - if (Number.isNaN(d.getTime())) return s - return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) -} - -const dateRange = computed(() => { - const start = formatDay(study.value?.startDate) - const end = formatDay(study.value?.endDate) - if (!start && !end) return null - if (start && end) return start === end ? start : `${start} – ${end}` - if (start) return `Since ${start}` - return `Until ${end}` -}) - -const costDisplay = computed(() => { - const cost = study.value?.cost - if (cost == null) return null - const num = Number(cost) - if (!Number.isFinite(num)) return String(cost) - const currency = study.value?.currency?.trim() - if (currency && /^[A-Za-z]{3}$/.test(currency)) { - try { - return new Intl.NumberFormat('en', { - style: 'currency', - currency: currency.toUpperCase(), - maximumFractionDigits: 0, - }).format(num) - } catch { - /* fall through */ - } - } - const formatted = new Intl.NumberFormat('en').format(num) - return currency ? `${formatted} ${currency}` : formatted -}) - -const mapVisible = ref(false) -onMounted(() => { - mapVisible.value = true -}) +// Make the loaded row available to nested route children (index renders the +// cards, contributors/history are quiet meta pages reached from the Overview). +provide('caseStudy', study) +provide('caseStudyParent', parent) // Edit / Suggest-edit + collaborative-revision history. Owner/admin edit // directly; other logged-in users propose a change; logged-out users go to // /login. Approved revisions are public history; pending/rejected ones are only // returned to owner/admin/proposer (the endpoint filters). const { track } = useUmami() -const { user, loggedIn } = useUserSession() +const { loggedIn } = useUserSession() const { isAdmin } = usePendingRevisions() // Ownership is resolved server-side from node_members and returned on the study. const isOwner = computed(() => !!study.value?.viewerIsOwner) const canApply = computed(() => isOwner.value || isAdmin.value) const editLabel = computed(() => (canApply.value ? 'Edit' : 'Propose changes')) +provide('caseStudyCanApply', canApply) const editOpen = ref(false) function openEdit() { @@ -106,23 +70,24 @@ function openEdit() { editOpen.value = true } -const { data: revisions, refresh: refreshRevisions } = await useFetch( +// Cheap pending-proposal count for the owner/admin banner. Only fetched for +// people who could act on it; the History page loads the full timeline itself. +const { data: revisionRows, refresh: refreshPendingCount } = await useFetch( () => `/api/case-study/${id.value}/revisions`, - { default: () => [] }, + { key: 'case-study-pending-banner', default: () => [], immediate: false }, ) +watchEffect(() => { + if (canApply.value && study.value) refreshPendingCount() +}) const pendingCount = computed(() => - canApply.value ? (revisions.value ?? []).filter((r) => r.status === 'pending').length : 0, + canApply.value ? (revisionRows.value ?? []).filter((r) => r.status === 'pending').length : 0, ) +const onHistoryTab = computed(() => route.path.endsWith('/history')) async function onEdited() { - await Promise.all([refreshStudy(), refreshRevisions()]) -} - -const historyRef = ref(null) -function scrollToHistory() { - track('Pending proposals banner click', { count: pendingCount.value }) - historyRef.value?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + await Promise.all([refreshStudy(), refreshPendingCount()]) } +provide('caseStudyRefresh', onEdited) if (study.value) { const s = study.value @@ -183,46 +148,30 @@ if (study.value) {