mirror of
https://github.com/vcscsvcscs/GenerationsHeritage.git
synced 2025-08-13 22:39:06 +02:00
create profile editor and viewer
This commit is contained in:
67
apps/app/src/lib/graph/PersonMenu.svelte
Normal file
67
apps/app/src/lib/graph/PersonMenu.svelte
Normal 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>
|
12
apps/app/src/lib/graph/model.ts
Normal file
12
apps/app/src/lib/graph/model.ts
Normal 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;
|
||||
}
|
14
apps/app/src/lib/graph/node_click.ts
Normal file
14
apps/app/src/lib/graph/node_click.ts
Normal 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});
|
||||
}
|
||||
}
|
26
apps/app/src/lib/profile/LifeEventsTimeline.svelte
Normal file
26
apps/app/src/lib/profile/LifeEventsTimeline.svelte
Normal 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}
|
22
apps/app/src/lib/profile/MediaGallery.svelte
Normal file
22
apps/app/src/lib/profile/MediaGallery.svelte
Normal 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}
|
50
apps/app/src/lib/profile/Modal.svelte
Normal file
50
apps/app/src/lib/profile/Modal.svelte
Normal 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}
|
20
apps/app/src/lib/profile/ModalButtons.svelte
Normal file
20
apps/app/src/lib/profile/ModalButtons.svelte
Normal 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>
|
40
apps/app/src/lib/profile/OtherDetails.svelte
Normal file
40
apps/app/src/lib/profile/OtherDetails.svelte
Normal 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>
|
31
apps/app/src/lib/profile/ProfileHeader.svelte
Normal file
31
apps/app/src/lib/profile/ProfileHeader.svelte
Normal 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>
|
||||
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user