implement menus and person panels

This commit is contained in:
2025-04-27 23:57:39 +02:00
parent 2e8b049f7a
commit 1b52a4acd7
32 changed files with 744 additions and 206 deletions

View File

@@ -6,15 +6,15 @@ const config: StorybookConfig = {
'@storybook/addon-svelte-csf',
'@storybook/addon-essentials',
'@chromatic-com/storybook',
'@storybook/addon-interactions',
'@storybook/addon-interactions'
],
framework: {
name: '@storybook/sveltekit',
options: {
builder: {
viteConfigPath: '../vite.config.ts',
},
viteConfigPath: '../vite.config.ts'
}
}
},
}
};
export default config;

View File

@@ -1,12 +1,12 @@
{
"recommendations": [
"inlang.vs-code-extension",
"42Crunch.vscode-openapi",
"bruno-api-client.bruno",
"svelte.svelte-vscode",
"github.vscode-github-actions",
"GitHub.copilot",
"pixl-garden.BongoCat",
"golang.go"
]
}
"recommendations": [
"inlang.vs-code-extension",
"42Crunch.vscode-openapi",
"bruno-api-client.bruno",
"svelte.svelte-vscode",
"github.vscode-github-actions",
"GitHub.copilot",
"pixl-garden.BongoCat",
"golang.go"
]
}

View File

@@ -24,6 +24,7 @@
"cancel": "Cancel",
"city": "City",
"child": "Child",
"change_profile_picture": "Change Profile Picture",
"citizenship": "Citizenship",
"close": "Close",
"coffee": "Coffee",
@@ -43,7 +44,9 @@
"deny": "Deny",
"description": "Description",
"details": "Details",
"died": "Died",
"directions": "Directions",
"disclaimer": "Disclaimer",
"document": "Document",
"download": "Download",
"edit": "Edit",

View File

@@ -24,6 +24,7 @@
"cancel": "Mégse",
"city": "Város",
"child": "Gyermek",
"change_profile_picture": "Profilkép megváltoztatása",
"citizenship": "Állampolgárság",
"close": "Bezár",
"coffee": "Kávé",
@@ -43,7 +44,9 @@
"deny": "Elutasítás",
"description": "Leírás",
"details": "Részletek",
"died": "Elhunyt",
"directions": "Útvonalak",
"disclaimer": "Felelősségkizárás",
"document": "Dokumentum",
"download": "Letöltés",
"edit": "Szerkesztés",
@@ -151,4 +154,4 @@
"welcome": "Üdvözöljük a Generációk Öröksége oldalán",
"yes": "Igen",
"zip_code": "Irányítószám"
}
}

View File

@@ -7,6 +7,8 @@
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" style="width: 100vw; height: 100vh" class="bg-base-200">
<div style="display: contents;width: 100vw; height: 100vh" class="bg-base-200">%sveltekit.body%</div>
<div style="display: contents; width: 100vw; height: 100vh" class="bg-base-200">
%sveltekit.body%
</div>
</body>
</html>

View File

@@ -28,7 +28,6 @@ const authHandle: Handle = async ({ event, resolve }) => {
setSessionTokenCookie(event, token, session.expiresAt);
} else {
console.log('Session token is invalid');
console.log(session, token);
deleteSessionTokenCookie(event);
}

View File

@@ -41,7 +41,7 @@
<select
bind:value={current_theme}
data-choose-theme
class="btn btn-ghost btn-xs min-h-0 h-8 px-4 py-0 text-sm"
class="btn btn-ghost btn-xs h-8 min-h-0 px-4 py-0 text-sm"
onchange={set_theme}
>
<option value="" disabled={current_theme !== ''}>

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/svelte';
import PersonMenu from './PersonMenu.svelte';
const meta = {
title: 'graph/PersonMenu',
title: 'lib/graph/PersonMenu',
component: PersonMenu,
tags: ['autodocs'],
argTypes: {
@@ -14,8 +14,8 @@ const meta = {
deleteNode: { action: 'deleteNode clicked' },
createRelationshipAndNode: { action: 'createRelationshipAndNode clicked' },
addRelationship: { action: 'addRelationship clicked' },
addAdmin: { action: 'addAdmin clicked' },
},
addAdmin: { action: 'addAdmin clicked' }
}
} satisfies Meta<PersonMenu>;
export default meta;
@@ -32,7 +32,7 @@ export const Default: Story = {
deleteNode: () => console.log('delete node'),
createRelationshipAndNode: () => console.log('create relationship and node'),
addRelationship: () => console.log('add relationship'),
addAdmin: () => console.log('add admin'),
addAdmin: () => console.log('add admin')
}
};
@@ -49,7 +49,7 @@ export const TopLeftPosition: Story = {
deleteNode: () => console.log('delete node'),
createRelationshipAndNode: () => console.log('create relationship and node'),
addRelationship: () => console.log('add relationship'),
addAdmin: () => console.log('add admin'),
addAdmin: () => console.log('add admin')
}
};
@@ -66,7 +66,7 @@ export const BottomRightPosition: Story = {
deleteNode: () => console.log('delete node'),
createRelationshipAndNode: () => console.log('create relationship and node'),
addRelationship: () => console.log('add relationship'),
addAdmin: () => console.log('add admin'),
addAdmin: () => console.log('add admin')
}
};
@@ -83,6 +83,6 @@ export const MiddleRightPosition: Story = {
deleteNode: () => console.log('delete node'),
createRelationshipAndNode: () => console.log('create relationship and node'),
addRelationship: () => console.log('add relationship'),
addAdmin: () => console.log('add admin'),
addAdmin: () => console.log('add admin')
}
};

View File

@@ -1,15 +1,14 @@
<!-- <svelte:options immutable /> -->
<script lang="ts">
import { death } from './../paraglide/messages.js';
import { Handle, Position, useConnection, type NodeProps } from '@xyflow/svelte';
import type { components } from '$lib/api/api.gen';
import { isValidConnection } from './connection.js';
type $$Props = NodeProps;
export let id: NodeProps['id'];
export let id: NodeProps['id'];
export let data: NodeProps['data'] & components['schemas']['PersonProperties'];
console.log('data', data);
const connection = useConnection();
const connection = useConnection();
let isConnecting = false;
let isTarget = false;
@@ -17,27 +16,48 @@
$: isTarget = connection.current.toHandle?.id !== id;
</script>
<div class="card card-compact bg-primary-content text-primary rounded-full w-40 h-40 flex flex-col items-center justify-center shadow-lg">
<div
class="card card-compact bg-primary-content text-primary flex h-40 w-40 flex-col items-center justify-center rounded-full shadow-lg"
>
{#if !isConnecting}
<Handle class="customHandle" position={Position.Right} type="source" style="z-index: 1;" />
{/if}
<Handle class="customHandle" position={Position.Left} type="target" isConnectableStart={false} />
<div class="avatar mb-2">
{#if isConnecting && isTarget}
<Handle position={Position.Left} type="target" isConnectableStart={false} style="z-index: 1;" />
{/if}
<div class="w-24 rounded-full ring ring-accent ring-offset-accent ring-offset-1 border-0 bg-accent">
{#if isConnecting && isTarget}
<Handle position={Position.Left} type="target" isConnectableStart={false} style="z-index: 1;" />
{/if}
<img src={data.profile_picture||'https://cdn-icons-png.flaticon.com/512/10628/10628885.png'} alt="Picture of {data.last_name} {data.first_name}" />
{#if isConnecting && isTarget}
<Handle
isValidConnection={isValidConnection}
position={Position.Left}
type="target"
isConnectableStart={false}
style="z-index: 1;"
/>
{/if}
<div
class="ring-accent ring-offset-accent bg-accent w-24 rounded-full border-0 ring ring-offset-1"
>
{#if isConnecting && isTarget}
<Handle
isValidConnection={isValidConnection}
position={Position.Left}
type="target"
isConnectableStart={false}
style="z-index: 1;"
/>
{/if}
<img
src={data.profile_picture || 'https://cdn-icons-png.flaticon.com/512/10628/10628885.png'}
alt="Picture of {data.last_name} {data.first_name}"
/>
</div>
</div>
<div class="text-center px-2">
<h2 class="font-semibold text-sm leading-tight">
{data.first_name} {data.middle_name ? data.middle_name : ''} {data.last_name}
<div class="px-2 text-center">
<h2 class="text-sm leading-tight font-semibold">
{data.first_name}
{data.middle_name ? data.middle_name : ''}
{data.last_name}
</h2>
<h3 class="text-xs opacity-70">
{data.born}{data.death ? ' - ' + data.death : ''}
@@ -46,16 +66,16 @@
</div>
<style>
:global(div.customHandle) {
width: 100%;
height: 100%;
background: blue;
position: absolute;
top: 0;
left: 0;
border-radius: 0;
transform: none;
border: none;
opacity: 0;
}
</style>
:global(div.customHandle) {
width: 100%;
height: 100%;
background: blue;
position: absolute;
top: 0;
left: 0;
border-radius: 0;
transform: none;
border: none;
opacity: 0;
}
</style>

View File

@@ -0,0 +1,10 @@
import type { Connection} from '@xyflow/svelte';
import type { EdgeBase } from '@xyflow/system';
export function isValidConnection(edge: EdgeBase | Connection) {
if (edge.source !== edge.target) {
return true;
}
return false;
}

View File

@@ -0,0 +1,22 @@
import type {Layout} from '$lib/graph/model';
export async function fetchFamilyTree(with_out_spouse: boolean): Promise<Layout> {
const url = with_out_spouse
? '/api/family_tree?with_out_spouse=true'
: '/api/family_tree?with_out_spouse=false';
const response = await fetch(url, {
method: 'GET'
})
if (response.status !== 200){
throw new Error(await response.text());
}
let layout: Layout = {
Nodes: [],
Edges: []
}
return layout;
}

View File

@@ -1,12 +1,19 @@
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;
import type { Node, Edge } from '@xyflow/svelte';
export type 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;
};
export type Layout = {
Nodes: Array<Node>;
Edges: Array<Edge>;
}

View File

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

View File

@@ -2,3 +2,16 @@ import * as runtime from '$lib/paraglide/runtime';
import { createI18n } from '@inlang/paraglide-sveltekit';
export const i18n = createI18n(runtime);
import * as messages from '$lib/paraglide/messages';
export type MessageKeys = keyof typeof messages;
export function callMessageFunction(name: MessageKeys): string {
const fn = messages[name];
if (typeof fn === 'function') {
return fn({ thing: '', field: '', page: '', name: '' });
} else {
throw new Error(`Function ${name} is not callable`);
}
}

View File

@@ -4,5 +4,5 @@
</script>
{#if show}
<a class="btn btn-error btn-xs min-h-0 h-8 px-4 py-0 text-sm" href="/logout">{logout()}</a>
{/if}
<a class="btn btn-error btn-xs h-8 min-h-0 px-4 py-0 text-sm" href="/logout">{logout()}</a>
{/if}

View File

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

View File

@@ -8,43 +8,43 @@
import OtherDetails from './OtherDetails.svelte';
import type { components } from '$lib/api/api.gen.js';
let {
open = false,
closeModal = () => {},
person = {}
}: { open: boolean; person: components['schemas']['PersonProperties'] } = $props();
}: { closeModal: () => void; person: components['schemas']['PersonProperties'] } = $props();
let editorMode = $state(false);
let draftPerson = $state({});
$effect(() => {
if (open) {
draftPerson = structuredClone(person);
editorMode = false;
}
});
draftPerson = person;
editorMode = false;
function close() {
open = false;
editorMode = false;
draftPerson = {};
closeModal();
editorMode = false;
draftPerson = {};
}
function toggleEdit() {
editorMode = !editorMode;
}
function save() {
// Save logic here
editorMode = false;
}
</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 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} onSave={save} onToggleEdit={toggleEdit} />
<div class="divider"></div>
</div>
<ProfileHeader {draftPerson} {editorMode} />
<MediaGallery {draftPerson} />
<LifeEventsTimeline {draftPerson} />
<OtherDetails {draftPerson} {editorMode} />
</div>
{/if}
</div>

View File

@@ -1,20 +1,26 @@
<script lang="ts">
import { back, biography, close, edit } from '$lib/paraglide/messages';
import { back, biography, close, edit, save } from '$lib/paraglide/messages';
export let editorMode = false;
export let onClose: () => void;
export let onToggleEdit: () => void;
export let onSave: () => 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 class="btn btn-secondary btn-sm" on:click={onToggleEdit}>
{editorMode ? back() : edit()}
</button>
<button class="btn btn-outline btn-primary btn-sm" on:click={onToggleEdit}>
{editorMode ? back() : edit}
{#if editorMode}
<button class="btn btn-accent btn-sm" on:click={onSave}>
{save()}
</button>
{/if}
<button class="btn btn-error btn-sm" on:click={onClose}>
{close()}
</button>
</div>
</div>

View File

@@ -1,40 +1,42 @@
<script lang="ts">
import * as msg from '$lib/paraglide/messages';
export let draftPerson: any;
export let editorMode = false;
import { callMessageFunction } from '$lib/i18n';
import type { MessageKeys } from '$lib/i18n';
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'
];
const skipFields = [
'id',
'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}
{#each Object.entries(draftPerson) as [key, value]}
{#if !skipFields.includes(key) && value !== undefined && value !== null}
<div>
<p>
<strong>{callMessageFunction(key as MessageKeys) || 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

@@ -1,30 +1,103 @@
<script lang="ts">
import { death } from './../paraglide/messages/en.js';
import { onMount } from 'svelte';
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'];
import { male,female,intersex,other,change_profile_picture, biological_sex, born,died, email, first_name, id, last_name, middle_name, mothers_first_name, mothers_last_name, profile_picture } from '$lib/paraglide/messages';
import { callMessageFunction } from '$lib/i18n';
import type { MessageKeys } from '$lib/i18n';
export let draftPerson: components['schemas']['PersonProperties'] & {
id?: string,
};
export let editorMode = false;
let birth_date: HTMLInputElement;
let death_date: HTMLInputElement;
onMount(() => {
if (birth_date) {
import('pikaday').then(({ default: Pikaday }) => {
const picker = new Pikaday({
format: 'YYYY-MM-DD',
minDate: new Date(1900, 0, 1),
field: birth_date,
onSelect: function (date) {
birth_date.value = date.toISOString();
}
});
// Clean up when component unmounts
return () => picker.destroy();
});
}
if (death_date) {
import('pikaday').then(({ default: Pikaday }) => {
const picker = new Pikaday({
format: 'YYYY-MM-DD',
minDate: new Date(1900, 0, 1),
field: death_date,
onSelect: function (date) {
death_date.value = date.toISOString();
}
});
// Clean up when component unmounts
return () => picker.destroy();
});
}
});
</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-shrink-0 flex flex-col items-center gap-2">
<img src={draftPerson.profile_picture||'https://cdn-icons-png.flaticon.com/512/10628/10628885.png'} alt={profile_picture()} class="rounded-lg shadow-md w-48 h-48 object-cover" />
{#if editorMode}
<button class="btn bg-neutral text-neutral-content btn-xs" on:click={() => {}}>
{change_profile_picture()}
</button>
{/if}
</div>
<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>
<p><strong>{born()}:</strong>
<input
type="text"
class="w-full pika-single"
id="birth_date"
bind:this={birth_date}
bind:value={draftPerson.born}/>
</p>
<p><strong>{died()}:</strong>
<input
type="text"
class="w-full pika-single"
id="death_date"
placeholder={died()}
bind:this={death_date}
bind:value={draftPerson.died}
/>
</p>
<p><strong>{biological_sex()}:</strong>
{#if editorMode}
<select
name="biological_sex"
class="select select-bordered w-full select-sm"
id="biological_sex"
bind:value={draftPerson.biological_sex}
placeholder={biological_sex()}
>
<option value="male">{male()} </option>
<option value="female">{female()} </option>
<option value="intersex">{intersex()} </option>
<option value="other">{other()} </option>
</select>
{:else}{callMessageFunction(draftPerson.biological_sex as MessageKeys) ?? '-'}{/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>
<p><strong> {id()}:</strong>{draftPerson.id ?? '-'}</p>
<p><strong> Limit:</strong>{draftPerson.limit ?? '-'}</p>
</div>
</div>
</div>

View File

@@ -0,0 +1,175 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import {
register,
close,
born,
mothers_first_name,
mothers_last_name,
last_name,
first_name,
email,
biological_sex,
male,
female,
other,
intersex
} from '$lib/paraglide/messages';
import { onMount } from 'svelte';
import type { components } from '$lib/api/api.gen.js';
import { validatePersonRegistration, validateFamilyRelationship } from './validate_fields';
let {
closeModal = () => {},
relationship = null,
}: { closeModal : ()=>void,relationship: number | null } = $props();
let birth_date: HTMLInputElement;
let draftRelationship: components['schemas']['FamilyRelationship'] & {type: string} | null = {} as components['schemas']['FamilyRelationship'] & {type: string} | null;
let draftPerson :components['schemas']['PersonRegistration'] = {} as components['schemas']['PersonRegistration'];
let error: string | undefined | null = $state();
function onClose() {
closeModal();
}
async function create(event: SubmitEvent) {
event.preventDefault();
error = validatePersonRegistration(draftPerson);
if (error) {
return;
}
if (relationship !== null && draftRelationship !== null) {
error = validateFamilyRelationship(draftRelationship);
if (error) {
return;
}
}
const url = `/api/person`;
const response = await fetch(url);
result = await response.text();
closeModal();
}
onMount(() => {
if (birth_date) {
import('pikaday').then(({ default: Pikaday }) => {
const picker = new Pikaday({
format: 'YYYY-MM-DD',
minDate: new Date(1900, 0, 1),
field: birth_date,
onOpen: function () {
birth_date.placeholder = '';
},
onSelect: function (date) {
birth_date.value = date.toISOString();
}
});
// Clean up when component unmounts
return () => picker.destroy();
});
}
});
</script>
<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">
<button class="btn btn-error btn-sm" onclick={onClose}>
{close()}
</button>
<div class="divider"></div>
</div>
<form onsubmit={create}>
<fieldset class="fieldset">
{#if error}
<div role="alert" class="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{error}</span>
</div>
{/if}
{#if relationship !== undefined}
<input type="hidden" name="relationship" value={relationship} />
{/if}
<label class="fieldset-label" for="email">{email()}</label>
<input
type="email"
name="email"
class="input"
placeholder={email()}
bind:value={draftPerson.email}
/>
<label class="fieldset-label" for="first_name">{first_name()}</label>
<input
type="text"
class="input"
name="first_name"
id="first_name"
placeholder={first_name()}
/>
<label class="fieldset-label" for="last_name">{last_name()}</label>
<input
type="text"
class="input"
name="last_name"
id="last_name"
placeholder={last_name()}
/>
<label class="fieldset-label" for="birth_date">{born()}</label>
<input
type="text"
class="input pika-single"
id="birth_date"
placeholder={born()}
bind:value={draftPerson.born}
bind:this={birth_date}
/>
<label class="fieldset-label" for="biological_sex">{biological_sex()}</label>
<select
name="biological_sex"
class="select select-bordered w-full max-w-xs"
id="biological_sex"
placeholder={biological_sex()}
bind:value={draftPerson.biological_sex}
>
<option value="male">{male()} </option>
<option value="female">{female()} </option>
<option value="intersex">{intersex()} </option>
<option value="other">{other()} </option>
</select>
<label class="fieldset-label" for="mothers_last_name">{mothers_last_name()}</label>
<input
type="text"
class="input"
name="mothers_last_name"
id="mothers_last_name"
placeholder={mothers_last_name()}
bind:value={draftPerson.mothers_last_name}
/>
<label class="fieldset-label" for="mothers_first_name">{mothers_first_name()}</label>
<input
type="text"
class="input"
name="mothers_first_name"
id="mothers_first_name"
placeholder={mothers_first_name()}
bind:value={draftPerson.mothers_first_name}
/>
<button class="btn btn-neutral mt-4">{register()}</button>
</fieldset>
</form>
</div>
</div>

View File

@@ -0,0 +1,56 @@
import type { components } from '$lib/api/api.gen.js';
export function validatePersonRegistration(data: components['schemas']['PersonRegistration']): string | null {
if (!data.first_name || data.first_name.trim() === "") {
return "First name is required.";
}
if (!data.last_name || data.last_name.trim() === "") {
return "Last name is required.";
}
if (data.email !== undefined && data.email !== null && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)) {
return "Invalid email format.";
}
if (!data.born || isNaN(Date.parse(data.born))) {
return "Valid birth date is required.";
}
if (!data.biological_sex || !['male', 'female', 'intersex', 'unknown', 'other'].includes(data.biological_sex.toString())){
return 'Invalid value for biological sex. Must be male female, intersex, unknown, or other.';
}
if (!data.mothers_first_name || data.mothers_first_name.trim() === "") {
return "Mother's first name is required.";
}
if (!data.mothers_last_name || data.mothers_last_name.trim() === "") {
return "Mother's last name is required.";
}
return null; // No errors
}
export function validateFamilyRelationship(relationship: components['schemas']['FamilyRelationship'] & {type:string}): string | null {
const validRelationships = [
"child",
"parent",
"spouse",
"sibling"
];
if (!validRelationships.includes(relationship.type)) {
return `Invalid family relationship. Must be one of ${validRelationships.join(', ')}.`;
}
if (relationship.from !== undefined && relationship.from !== null && isNaN(Date.parse(relationship.from))) {
return "Valid date is required for 'from' field.";
}
if (relationship.to !== undefined && relationship.to !== null && isNaN(Date.parse(relationship.to))) {
return "Valid date is required for 'to' field.";
}
return null; // No errors
}

View File

@@ -34,7 +34,11 @@ export async function invalidateUserSessions(userId: number, sessions: KVNamespa
}
}
export function setSessionTokenCookie(event: RequestEvent, token: string, expiresAt: EpochTimeStamp): void {
export function setSessionTokenCookie(
event: RequestEvent,
token: string,
expiresAt: EpochTimeStamp
): void {
event.cookies.set('session', token, {
httpOnly: true,
path: '/',

View File

@@ -10,8 +10,8 @@
<ParaglideJS {i18n}>
{@render children()}
<div class="flex flex-row absolute top-2 right-2 items-center gap-2">
<div class="absolute top-2 right-2 flex flex-row items-center gap-2">
<ThemeButton />
<Logout show={!page.url.pathname.includes("login")}/>
<Logout show={!page.url.pathname.includes('login')} />
</div>
</ParaglideJS>

View File

@@ -1,23 +1,28 @@
<script lang="ts">
import { title, family_tree, people } from '$lib/paraglide/messages.js';
import type { PageProps } from './$types';
import { title, family_tree } from '$lib/paraglide/messages.js';
import { SvelteFlowProvider, SvelteFlow, Controls, MiniMap } from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
import type { Node, Edge, NodeTypes, NodeProps } from '@xyflow/svelte';
import type { Node, Edge, NodeTypes, NodeEventWithPointer } 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 CreatePerson from '$lib/profile/create/Modal.svelte';
import type { components } from '$lib/api/api.gen';
import type { NodeMenu } from '$lib/graph/model';
import { handleNodeClick } from '$lib/graph/node_click';
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 | undefined = undefined;
let openPersonMenu: NodeMenu | undefined = $state(undefined);
let ppl = data.people;
if (ppl === undefined) {
@@ -37,16 +42,61 @@
]);
let edges = $state.raw<Edge[]>([]);
handleNodeClick(
let relationshipStart: number | null ;
let createPerson = $state(false);
let clientWidth: number | undefined = $state();
let clientHeight: number | undefined = $state();
const handleContextMenu: NodeEventWithPointer<MouseEvent> = ({ event, node }) => {
event.preventDefault();
if (clientHeight === undefined || clientWidth === undefined) {
clientHeight = window.innerHeight;
clientWidth = window.innerWidth;
}
openPersonMenu = {
onClick: () => {
openPersonMenu = undefined;
},
deleteNode: () => {
openPersonMenu = undefined;
},
createRelationshipAndNode: () => {
openPersonMenu = undefined;
},
addRelationship: () => {
openPersonMenu = undefined;
},
addAdmin: () => {
openPersonMenu = undefined;
},
addRecipe: () => {
openPersonMenu = undefined;
},
top: event.clientY < clientHeight - 200 ? event.clientY : undefined,
left: event.clientX < clientWidth - 200 ? event.clientX : undefined,
right: event.clientX >= clientWidth - 200 ? clientWidth - event.clientX : undefined,
bottom: event.clientY >= clientHeight - 200 ? clientHeight - event.clientY : undefined
};
};
let handleNodeClickFunc = handleNodeClick(
(
person: components['schemas']['PersonProperties'] & {
id: number;
}
) => {
openPersonPanel = true;
console.log('person', person);
selectedPerson = person;
}
);
let handlePaneClick = ({ event }: { event: MouseEvent }) => {
openPersonPanel = false;
openPersonMenu = undefined;
};
</script>
<svelte:head>
@@ -58,6 +108,9 @@
<SvelteFlow
bind:nodes
bind:edges
onnodeclick={handleNodeClickFunc}
onnodecontextmenu={handleContextMenu}
onpaneclick={handlePaneClick}
class="!bg-base-200"
{nodeTypes}
fitView
@@ -65,7 +118,17 @@
>
<MiniMap class="!bg-base-300" />
<Controls class="!bg-base-300" />
<PersonModal person={selectedPerson} open={openPersonPanel} />
{#if openPersonPanel}
<PersonModal
person={selectedPerson}
closeModal={() => {
openPersonPanel = false;
}}
/>
{/if}
{#if createPerson}
<CreatePerson relationship={relationshipStart}></CreatePerson>
{/if}
{#if openPersonMenu !== undefined}
<PersonMenu {...openPersonMenu!} />
{/if}

View File

@@ -0,0 +1,44 @@
import { error, redirect } from '@sveltejs/kit';
import { client } from '$lib/api/client';
import type { RequestEvent } from './$types';
import type { components } from '$lib/api/api.gen';
async function GET(event: RequestEvent): Promise<Response> {
if (event.locals.session === null /*|| event.locals.familytree === nul*/) {
return redirect(302, '/login');
}
const response = await client.GET(event.url.searchParams.get('with_out_spouse') === 'true' ? '/family-tree-with-spouses':'/family-tree', {
params: {
header: { 'X-User-ID': event.locals.session.userId }
}
});
if (response.response.status !== 200) {
return error(500, {
message: response.error?.msg || 'Failed to fetch family tree'
});
}
if (response.data === null || response.data?.people === null || response.data?.people === undefined || response.data?.people.length === 0) {
return error(500, {
message: 'Family tree is empty'
});
}
var peopleToReturn : Array<components['schemas']['OptimizedPersonNode']> = [];
for (const person of response.data.people) {
let newPerson = person
if (newPerson.profile_picture!== null && newPerson.profile_picture !== undefined) {
}
peopleToReturn.push(newPerson);
}
return new Response(JSON.stringify(peopleToReturn), {
status: 200,
});
}

View File

@@ -71,7 +71,11 @@ export const load: PageServerLoad = async (event: RequestEvent) => {
}
const sessionToken = generateSessionToken(String(response.data.Id));
const session = await createSession(sessionToken, response.data.Id, event.platform.env.GH_SESSIONS)
const session = await createSession(
sessionToken,
response.data.Id,
event.platform.env.GH_SESSIONS
);
if (session === null) {
return error(500, {
message: 'Failed to create session'
@@ -84,8 +88,6 @@ export const load: PageServerLoad = async (event: RequestEvent) => {
}
}
let personP: PersonProperties = {
google_id: sub,
first_name: first_name,
@@ -163,7 +165,9 @@ async function register(event: RequestEvent) {
field: biological_sex()
})
});
} else if (!['male', 'female', 'intersex', 'unknown', 'other'].includes(bbiological_sex.toString())) {
} else if (
!['male', 'female', 'intersex', 'unknown', 'other'].includes(bbiological_sex.toString())
) {
return fail(400, {
message: `Invalid value for biological_sex. Must be one of "male", "female", "intersex", "unknown", or "other".`
});
@@ -194,17 +198,17 @@ async function register(event: RequestEvent) {
born: parsed_date.toISOString().split('T')[0],
mothers_first_name: mothers_first_name_f as string,
mothers_last_name: mothers_last_name_f as string,
biological_sex: bbiological_sex as components['schemas']['PersonRegistration']['biological_sex'],
biological_sex:
bbiological_sex as components['schemas']['PersonRegistration']['biological_sex'],
limit: StorageLimit
};
let response = await client
.POST('/person/google/{google_id}', {
params: {
path: { google_id: google_id.toString() }
},
body: personP
})
let response = await client.POST('/person/google/{google_id}', {
params: {
path: { google_id: google_id.toString() }
},
body: personP
});
if (response.response.status !== 200) {
return fail(400, {
@@ -212,9 +216,13 @@ async function register(event: RequestEvent) {
});
}
if (response.data === undefined) {
return fail(400, {
message: failed_to_create_user() + 'No user data returned'
});
}
if (!response.data?.Id) {
console.log(response.data)
if (response.data.Id === undefined) {
return fail(400, {
message: failed_to_create_user() + 'No user ID returned'
});
@@ -231,7 +239,7 @@ async function register(event: RequestEvent) {
sessionToken,
response.data.Id,
event.platform.env.GH_SESSIONS
)
);
if (session === null) {
return fail(500, {
message: failed_to_create_user() + 'Failed to create session'

View File

@@ -15,7 +15,8 @@
biological_sex,
male,
female,
other
other,
intersex
} from '$lib/paraglide/messages';
import { onMount } from 'svelte';
import { enhance } from '$app/forms';
@@ -129,7 +130,7 @@
type="text"
class="input pika-single"
id="birth_date"
value={born()}
placeholder={born()}
bind:this={birth_date}
/>
<input type="text" class="hidden" name="birth_date" bind:this={birth_date_value} />
@@ -142,6 +143,7 @@
>
<option value="male">{male()} </option>
<option value="female">{female()} </option>
<option value="intersex">{intersex()} </option>
<option value="other">{other()} </option>
</select>
<label class="fieldset-label" for="mothers_last_name">{mothers_last_name()}</label>

View File

@@ -0,0 +1,20 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import SignInPage from './+page.svelte';
const meta = {
title: 'login/+page',
component: SignInPage,
tags: ['autodocs']
} satisfies Meta<typeof SignInPage>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => {
return {
Component: SignInPage
};
}
};

View File

@@ -17,4 +17,4 @@ export async function GET(event: RequestEvent): Promise<Response> {
deleteSessionTokenCookie(event);
return redirect(302, '/login');
}
}

View File

@@ -5,9 +5,7 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "generations-heritage",
"compatibility_flags": [
"nodejs_compat"
],
"compatibility_flags": ["nodejs_compat"],
"compatibility_date": "2025-02-14",
"pages_build_output_dir": ".svelte-kit/cloudflare",
"observability": {
@@ -99,4 +97,4 @@
* https://developers.cloudflare.com/workers/wrangler/configuration/#service-bindings
*/
// "services": [{ "binding": "MY_SERVICE", "service": "my-service" }]
}
}