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
13 changes: 13 additions & 0 deletions docs/releases/v1.3.100.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# v1.3.100 - Background editor and display options

- Combine Journey and Experience into one Background record list with category filters.
- Collapse identical legacy copies and preserve differing revisions and hidden/archive states.
- Save records and visibility flags in one database transaction; keep existing URLs working.
- Choose Cards or Timeline as the default display in Admin; visitors can switch views.
- Constrain mobile form widths, explain optional dates, and group detailed entry fields.
- Keep organization icons and compact work thumbnails separate.
- Improve responsive cards and accessible detail controls; avoid nested links inside buttons.
- Allow independent work without an organization name and omit legacy dash-only dates.
- Feed the combined public records into the resume.

Validation: TypeScript, ESLint and unit tests. Local visual smoke could not run because the browser download was unavailable and no local PostgreSQL service was running; GitHub CI provides those services.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "necrotixlab",
"version": "1.3.99",
"version": "1.3.100",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
2 changes: 1 addition & 1 deletion public/pwa-sw.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/* Opt-in NecrotixLab service worker. Registered only from NativePwaLayer
when Admin → PWA App enables it. Never caches /admin or /api.
APP_VERSION must match package.json so a release always produces a new worker. */
const APP_VERSION = '1.3.99';
const APP_VERSION = '1.3.100';
const VERSION = `necrotix-pwa-v${APP_VERSION}`;
const OFFLINE_URL = '/offline.html';
const enableOffline = new URL(self.location.href).searchParams.get('offline') === '1';
Expand Down
40 changes: 2 additions & 38 deletions src/app/admin/(protected)/experience/actions-v2.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,8 @@
'use server';

import { Prisma } from '@prisma/client';
import { revalidatePath } from 'next/cache';
import { auth } from '@/auth';
import { prisma } from '@/lib/prisma';
import { normalizeJourneyEntryState } from '@/lib/journey-entry-state';
import { updateExperiencePage, type ExperienceSaveResult } from './actions';

const ENTRY_STATE_SLUG = '__journey-entry-state';

// Content and visibility flags are persisted together by the authenticated action.
export async function updateJourneyManager(form: FormData): Promise<ExperienceSaveResult> {
const session = await auth();
if (!session?.user || !['OWNER', 'ADMIN'].includes(session.user.role)) {
return { ok: false, error: 'You do not have permission to edit the Journey page.' };
}

const result = await updateExperiencePage(form);
if (!result.ok) return result;

try {
const raw = String(form.get('entryStatesJson') ?? '').trim();
const state = normalizeJourneyEntryState(raw ? JSON.parse(raw) : {});
await prisma.page.upsert({
where: { slug: ENTRY_STATE_SLUG },
create: {
slug: ENTRY_STATE_SLUG,
title: 'Journey entry state',
status: 'PUBLISHED',
content: state as unknown as Prisma.InputJsonValue,
},
update: {
status: 'PUBLISHED',
content: state as unknown as Prisma.InputJsonValue,
},
});
revalidatePath('/journey');
revalidatePath('/admin/experience');
return result;
} catch (error) {
console.error('Failed to save Journey entry state', error);
return { ok: false, error: 'The Journey content was saved, but its bulk visibility state could not be saved. Please retry.' };
}
return updateExperiencePage(form);
}
11 changes: 10 additions & 1 deletion src/app/admin/(protected)/experience/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { auth } from '@/auth';
import { prisma } from '@/lib/prisma';
import { defaultExperienceContent, normalizeExperienceContent, type ExperienceContent, type ExperienceTabId, type PartnerLogo } from '@/lib/experience-content';

import { normalizeJourneyEntryState } from '@/lib/journey-entry-state';

const CONFIG_SLUG = '__experience-config';
const LEGACY_PAGE_TITLE = 'Experience page configuration';
const DEFAULT_PAGE_NAME = 'Journey';
Expand Down Expand Up @@ -72,7 +74,7 @@ function validateExperienceEntries(raw: unknown, label: string): ExperienceSaveR
const id = requiredValue(record, 'id');
if (!id || seenIds.has(id)) return { ok: false, error: `${label} record ${index + 1} needs a unique record ID.` };
seenIds.add(id);
if (!requiredValue(record, 'company') || !requiredValue(record, 'position')) {
if ((!requiredValue(record, 'company') && !['freelance', 'self-employed'].includes(String(record.type))) || !requiredValue(record, 'position')) {
return { ok: false, error: `${label} record ${index + 1} needs a company and position before it can be saved.` };
}
}
Expand Down Expand Up @@ -159,6 +161,7 @@ export async function updateExperiencePage(form: FormData): Promise<ExperienceSa

const candidate: ExperienceContent = normalizeExperienceContent({
...existing,
displayStyle: form.has('displayStyle') ? (form.get('displayStyle') === 'timeline' ? 'timeline' : 'cards') : existing.displayStyle,
pageEnabled: editingGeneral ? readBoolean(form, 'pageEnabled') : existing.pageEnabled,
showHero: editingGeneral ? readBoolean(form, 'showHero') : existing.showHero,
showDecorations: editingGeneral ? readBoolean(form, 'showDecorations') : existing.showDecorations,
Expand Down Expand Up @@ -228,8 +231,14 @@ export async function updateExperiencePage(form: FormData): Promise<ExperienceSa
partnerLogos,
});

const rawState = form.has('entryStatesJson') ? normalizeJourneyEntryState(JSON.parse(String(form.get('entryStatesJson') || '{}'))) : null;
const jsonContent = candidate as unknown as Prisma.InputJsonValue;
await prisma.$transaction([
...(rawState ? [prisma.page.upsert({
where: { slug: '__journey-entry-state' },
create: { slug: '__journey-entry-state', title: 'Background entry state', status: 'PUBLISHED', content: rawState as unknown as Prisma.InputJsonValue },
update: { content: rawState as unknown as Prisma.InputJsonValue },
})] : []),
prisma.page.upsert({
where: { slug: CONFIG_SLUG },
create: { slug: CONFIG_SLUG, title: pageName, status: 'PUBLISHED', content: jsonContent },
Expand Down
4 changes: 2 additions & 2 deletions src/app/admin/(protected)/experience/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ export default async function ExperienceAdminPage() {
]);
const content = normalizeExperienceContent(page?.content);
const entryStates = normalizeJourneyEntryState(entryStatePage?.content);
const pageName = page?.title && page.title !== LEGACY_PAGE_TITLE ? page.title : 'Journey';
const pageName = page?.title && ![LEGACY_PAGE_TITLE, 'Journey', 'Experience'].includes(page.title) ? page.title : 'Background';

return (
<div className="mx-auto max-w-6xl">
<div className="mx-auto w-full min-w-0 max-w-6xl">
<div className="mb-7 flex flex-col gap-5 md:mb-9 md:flex-row md:items-end md:justify-between">
<div className="min-w-0">
<p className="text-xs uppercase tracking-[0.3em] text-white/35">Protected visual editor</p>
Expand Down
6 changes: 4 additions & 2 deletions src/app/journey/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { mergeBackgroundEntries } from '@/lib/background-entries';
import type { Metadata } from 'next';
import { prisma } from '@/lib/prisma';
import { normalizeExperienceContent } from '@/lib/experience-content';
Expand All @@ -11,7 +12,7 @@ const LEGACY_PAGE_TITLE = 'Experience page configuration';
export const revalidate = 60;

function pageName(title?: string | null) {
return title && title !== LEGACY_PAGE_TITLE ? title : 'Journey';
return title && ![LEGACY_PAGE_TITLE, 'Journey', 'Experience'].includes(title) ? title : 'Background';
}

export async function generateMetadata(): Promise<Metadata> {
Expand All @@ -34,11 +35,12 @@ export default async function JourneyPage() {

const content = normalizeExperienceContent(configPage?.content);
const entryState = normalizeJourneyEntryState(entryStatePage?.content);
const background = mergeBackgroundEntries(content.experienceEntries, content.journeyEntries, entryState);
const publicContent = {
...content,
educationEntries: content.educationEntries.filter((item) => entryIsPublic(entryState, 'education', item.id)),
journeyEntries: content.journeyEntries.filter((item) => entryIsPublic(entryState, 'journey', item.id)),
experienceEntries: content.experienceEntries.filter((item) => entryIsPublic(entryState, 'experience', item.id)),
experienceEntries: background.entries.filter((item) => entryIsPublic(background.state, 'experience', item.id)),
};

return <ExperiencePageClient content={publicContent} />;
Expand Down
6 changes: 4 additions & 2 deletions src/app/resume/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { mergeBackgroundEntries } from '@/lib/background-entries';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { CareerDossierPage } from '@/components/resume/CareerDossierPage';
Expand Down Expand Up @@ -41,11 +42,12 @@ export default async function ResumePage() {

const content = normalizeExperienceContent(configPage?.content);
const entryState = normalizeJourneyEntryState(entryStatePage?.content);
const background = mergeBackgroundEntries(content.experienceEntries, content.journeyEntries, entryState);
const experience = {
...content,
educationEntries: content.educationEntries.filter((item) => entryIsPublic(entryState, 'education', item.id)),
journeyEntries: content.journeyEntries.filter((item) => entryIsPublic(entryState, 'journey', item.id)),
experienceEntries: content.experienceEntries.filter((item) => entryIsPublic(entryState, 'experience', item.id)),
journeyEntries: background.entries.filter((item) => entryIsPublic(background.state, 'experience', item.id)),
experienceEntries: background.entries.filter((item) => entryIsPublic(background.state, 'experience', item.id)),
};
const homepage = normalizeHomepageContent(settings?.homepageContent);
const identity = buildPublicIdentity(settings, homepage.profileImage);
Expand Down
Loading
Loading