create profile editor and viewer

This commit is contained in:
2025-04-26 22:29:32 +02:00
parent 8e51cc6e15
commit 79ce1dae04
10 changed files with 304 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
<script lang="ts">
import { onMount } from 'svelte';
import { type NodeMenu } from '$lib/graph/model';
import {
add_relationship,
remove,
create_relationship_and_person,
add_administrator
} from '$lib/paraglide/messages';
let props: NodeMenu = $props();
let contextMenu: HTMLDivElement;
onMount(() => {
if (props.top) {
contextMenu.style.top = `${props.top}px`;
}
if (props.left) {
contextMenu.style.left = `${props.left}px`;
}
if (props.right) {
contextMenu.style.right = `${props.right}px`;
}
if (props.bottom) {
contextMenu.style.bottom = `${props.bottom}px`;
}
});
</script>
<div
role="menu"
tabindex="-1"
bind:this={contextMenu}
class="context-menu bg-primary-100 rounded-lg shadow-lg"
onclick={props.onClick}
onkeydown={(e) => {
if (e.key === 'Esc' || e.key === ' ' || e.key === 'Escape') {
props.onClick();
}
}}
>
<button onclick={props.createRelationshipAndNode} class="btn"
>{create_relationship_and_person()}</button
>
<button onclick={props.addRelationship} class="btn">{add_relationship()}</button>
<button onclick={props.addAdmin} class="btn">{add_administrator()}</button>
<button onclick={props.deleteNode} class="btn">{remove()}</button>
</div>
<style>
.context-menu {
border-style: solid;
box-shadow: 10px 19px 20px rgba(0, 0, 0, 10%);
position: absolute;
z-index: 10;
}
.context-menu button {
border: none;
display: block;
padding: 0.5em;
text-align: left;
width: 100%;
}
</style>

View File

@@ -0,0 +1,12 @@
export interface NodeMenu {
onClick: () => void;
deleteNode: () => void;
createRelationshipAndNode: () => void;
addRelationship: () => void;
addRecipe: (() => void) | undefined;
addAdmin: (() => void) | undefined;
top: number | undefined;
left: number | undefined;
right: number | undefined;
bottom: number | undefined;
}

View File

@@ -0,0 +1,14 @@
import type { components } from '$lib/api/api.gen';
import type { Node } from '@xyflow/svelte';
export function handleNodeClick(set_panel_options:(person:components['schemas']['PersonProperties']&{id:number})=>void): ({
detail: { event, node }
}: {
detail: { event: MouseEvent; node: Node };
}) => void {
return ({ detail: { event, node } }) => {
event.preventDefault();
node.data.id = Number(node.id);
set_panel_options(node.data as components['schemas']['PersonProperties']&{id:number});
}
}

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { life_events, unknown, until } from '$lib/paraglide/messages';
export let draftPerson: any;
</script>
{#if draftPerson.life_events?.length}
<div class="divider">{life_events()}</div>
<ul class="timeline timeline-snap-start timeline-vertical">
{#each draftPerson.life_events as event}
<li>
<div class="timeline-start">{event.from ?? unknown()}</div>
<div class="timeline-middle">
<div class="badge badge-primary"></div>
</div>
<div class="timeline-end">
<p>{event.description}</p>
{#if event.to}
<p class="text-sm opacity-50">{until()} {event.to}</p>
{/if}
</div>
<hr />
</li>
{/each}
</ul>
{/if}

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import type { components } from "$lib/api/api.gen";
import { video, photos } from "$lib/paraglide/messages";
export let draftPerson: components['schemas']['PersonProperties'];
</script>
{#if draftPerson.photos?.length || draftPerson.videos?.length}
<div class="divider">{photos()} & {video()}</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
{#each draftPerson.photos ?? [] as picture}
<img src={picture.url} alt={picture.description ?? photos()} class="rounded-lg shadow-md object-cover w-full h-32" />
{/each}
{#each draftPerson.videos ?? [] as video}
<video src={video.url} controls class="rounded-lg shadow-md w-full h-32">
<track kind="captions" src={video.description} srcLang="en" default />
<track kind="descriptions" src={video.description} srcLang="en" default />
</video>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import ModalButtons from './ModalButtons.svelte';
import ProfileHeader from './ProfileHeader.svelte';
import MediaGallery from './MediaGallery.svelte';
import LifeEventsTimeline from './LifeEventsTimeline.svelte';
import OtherDetails from './OtherDetails.svelte';
import type { components } from '$lib/api/api.gen.js';
let {
open = false,
person = {}
}: { open: boolean; person: components['schemas']['PersonProperties'] } = $props();
let editorMode = $state(false);
let draftPerson = $state({});
$effect(() => {
if (open) {
draftPerson = structuredClone(person);
editorMode = false;
}
});
function close() {
open = false;
editorMode = false;
draftPerson = {};
}
function toggleEdit() {
editorMode = !editorMode;
}
</script>
{#if open}
<div class="modal modal-open" transition:fade>
<div class="modal-box max-h-screen w-full max-w-5xl overflow-y-auto">
<div class="bg-base-100 sticky top-0 z-10">
<ModalButtons {editorMode} onClose={close} onToggleEdit={toggleEdit} />
<div class="divider"></div>
</div>
<ProfileHeader {draftPerson} {editorMode} />
<MediaGallery {draftPerson} />
<LifeEventsTimeline {draftPerson} />
<OtherDetails {draftPerson} {editorMode} />
</div>
</div>
{/if}

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { back, biography, close, edit } from '$lib/paraglide/messages';
export let editorMode = false;
export let onClose: () => void;
export let onToggleEdit: () => void;
</script>
<div class="flex items-center justify-between p-2">
<h3 class="text-lg font-bold">{biography()}</h3>
<div class="space-x-2">
<button class="btn btn-outline btn-error btn-sm" on:click={onClose}>
{close()}
</button>
<button class="btn btn-outline btn-primary btn-sm" on:click={onToggleEdit}>
{editorMode ? back() : edit}
</button>
</div>
</div>

View File

@@ -0,0 +1,40 @@
<script lang="ts">
import * as msg from '$lib/paraglide/messages';
export let draftPerson: any;
export let editorMode = false;
const skipFields = [
'first_name',
'last_name',
'born',
'biological_sex',
'email',
'limit',
'mothers_first_name',
'mothers_last_name',
'profile_picture',
'photos',
'videos',
'life_events'
];
</script>
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
{#each Object.entries(draftPerson) as [key, value]}
{#if !skipFields.includes(key)}
<div>
<p>
<strong>{(msg as any)[key]() ?? key}:</strong>
{#if editorMode}
<textarea
bind:value={draftPerson[key]}
class="textarea textarea-bordered textarea-sm w-full"
></textarea>
{:else}
{JSON.stringify(value) ?? '-'}
{/if}
</p>
</div>
{/if}
{/each}
</div>

View File

@@ -0,0 +1,31 @@
<script lang="ts">
import type { components } from '$lib/api/api.gen';
import { biological_sex, born, email, first_name, last_name, middle_name, mothers_first_name, mothers_last_name, profile_picture } from '$lib/paraglide/messages';
export let draftPerson: components['schemas']['PersonProperties'];
export let editorMode = false;
</script>
<div class="flex flex-col md:flex-row gap-6">
{#if draftPerson.profile_picture}
<div class="flex-shrink-0">
<img src={draftPerson.profile_picture} alt={profile_picture()} class="rounded-lg shadow-md w-48 h-48 object-cover" />
</div>
{/if}
<div class="flex-1 grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<p><strong>{first_name()}:</strong> {#if editorMode}<input bind:value={draftPerson.first_name} class="input input-sm input-bordered w-full" />{:else}{draftPerson.first_name ?? '-'}{/if}</p>
<p><strong>{last_name()}:</strong> {#if editorMode}<input bind:value={draftPerson.last_name} class="input input-sm input-bordered w-full" />{:else}{draftPerson.last_name ?? '-'}{/if}</p>
<p><strong>{middle_name()}:</strong> {#if editorMode}<input bind:value={draftPerson.middle_name} class="input input-sm input-bordered w-full" />{:else}{draftPerson.middle_name ?? '-'}{/if}</p>
<p><strong>{born()}:</strong> {#if editorMode}<input bind:value={draftPerson.born} class="input input-sm input-bordered w-full" />{:else}{draftPerson.born ?? '-'}{/if}</p>
<p><strong>{biological_sex()}:</strong> {#if editorMode}<input bind:value={draftPerson.biological_sex} class="input input-sm input-bordered w-full" />{:else}{draftPerson.biological_sex ?? '-'}{/if}</p>
</div>
<div>
<p><strong>{email()}:</strong> {#if editorMode}<input bind:value={draftPerson.email} class="input input-sm input-bordered w-full" />{:else}{draftPerson.email ?? '-'}{/if}</p>
<p><strong>Limit:</strong> {#if editorMode}<input bind:value={draftPerson.limit} class="input input-sm input-bordered w-full" />{:else}{draftPerson.limit ?? '-'}{/if}</p>
<p><strong>{mothers_first_name()}:</strong> {#if editorMode}<input bind:value={draftPerson.mothers_first_name} class="input input-sm input-bordered w-full" />{:else}{draftPerson.mothers_first_name ?? '-'}{/if}</p>
<p><strong>{mothers_last_name()}:</strong> {#if editorMode}<input bind:value={draftPerson.mothers_last_name} class="input input-sm input-bordered w-full" />{:else}{draftPerson.mothers_last_name ?? '-'}{/if}</p>
</div>
</div>
</div>

View File

@@ -5,9 +5,18 @@
import '@xyflow/svelte/dist/style.css';
import type { Node, Edge, NodeTypes, NodeProps } from '@xyflow/svelte';
import PersonNode from '$lib/graph/PersonNode.svelte';
import PersonModal from '$lib/profile/Modal.svelte';
import type { components } from '$lib/api/api.gen';
import { handleNodeClick } from '$lib/graph/node_click';
import PersonMenu from '$lib/graph/PersonMenu.svelte';
import type { NodeMenu } from '$lib/graph/model';
let { data, form }: PageProps = $props();
const nodeTypes: NodeTypes = { personNode: PersonNode };
let selectedPerson: components['schemas']['PersonProperties'] & {id:number|null} = $state({id: null});
let openPersonPanel = $state(false);
let openPersonMenu: NodeMenu = $state({});
let ppl = data.people;
if (ppl === undefined) {
ppl = [];
@@ -25,6 +34,17 @@
}
]);
let edges = $state.raw<Edge[]>([]);
handleNodeClick(
(
person: components['schemas']['PersonProperties'] & {
id: number;
}
) => {
openPersonPanel = true;
selectedPerson = person;
}
);
</script>
<svelte:head>
@@ -43,6 +63,8 @@
>
<MiniMap class="!bg-base-300" />
<Controls class="!bg-base-300" />
<PersonModal person={selectedPerson} open={openPersonPanel} />
<PersonMenu />
</SvelteFlow>
</SvelteFlowProvider>
</div>