fixup admin platform

This commit is contained in:
2025-05-01 00:58:22 +02:00
parent 37c80d523e
commit 7f32896db0
14 changed files with 529 additions and 105 deletions

View File

@@ -3213,6 +3213,12 @@
"id": {
"type": "integer"
},
"label":{
"type": "array",
"items": {
"type": "string"
}
},
"first_name": {
"type": "string"
},
@@ -3221,20 +3227,6 @@
},
"last_name": {
"type": "string"
},
"EndId": {
"type": "integer"
},
"EndElementId": {
"type": "string"
},
"Props": {
"type": "object",
"properties": {
"added": {
"type": "integer"
}
}
}
}
},

View File

@@ -42,6 +42,7 @@
"dark": "Dark",
"date": "Date",
"death": "Death",
"delete_profile":"Delete profile",
"deceased": "Deceased",
"deny": "Deny",
"description": "Description",
@@ -67,6 +68,8 @@
"from_time": "From",
"fruit": "Fruit",
"hair_colour": "Hair Colour",
"hard_delete": "Delete permanently",
"have_invite_code": "I have invite code!",
"hello_world": "Hello, {name} from en!",
"height": "Height",
"hobby": "Hobby",

View File

@@ -43,6 +43,7 @@
"date": "Dátum",
"death": "Halál",
"deceased": "Elhunyt",
"delete_profile":"Delete profile",
"deny": "Elutasítás",
"description": "Leírás",
"details": "Részletek",
@@ -67,6 +68,8 @@
"from_time": "Tól",
"fruit": "Gyümölcs",
"hair_colour": "Hajszín",
"hard_delete": "Végleges Törlés",
"have_invite_code": "Rendelkezem meghívó kóddal!",
"hello_world": "Helló, {name} innen: hu!",
"height": "Magasság",
"hobby": "Hobbi",

View File

@@ -0,0 +1,168 @@
<script lang="ts">
import {
hard_delete,
managed_profiles,
delete_profile,
edit,
from_time,
admin,
create_relationship_and_person,
add_relationship
} from '$lib/paraglide/messages';
import ModalButtons from './ModalButtons.svelte';
import type { components, operations } from '$lib/api/api.gen';
let {
closeModal,
editProfile = () => {},
onChange = () => {},
addRelationship = () => {},
createProfile = ()=> {},
createRelationshipAndProfile = () => {},
} = $props<{
closeModal: () => void;
onChange?: () => void;
addRelationship?: (id: number) => void;
createRelationshipAndProfile?: (id: number) => void;
editProfile?: (id: number) => void;
createProfile?: () => void;
}>();
let managed_profiles_list: components['schemas']['Admin'][] = $state([]);
function fetchManagedProfiles(){
fetch(`/api/managed_profiles`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
.then((response) => {
if (!response.ok) {
console.log('Cannot get managed profiles, status: ' + response.status);
return;
}
return response.json();
})
.then((data) => {
if (data) {
managed_profiles_list = [...(data as components['schemas']['Admin'][])];
}
})
.catch((error) => {
console.error('Error fetching managed profiles:', error);
});
}
fetchManagedProfiles();
async function deleteProfile(id: number) {
fetch('/api/person/' + id, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
})
.then((response) => {
if (response.ok) {
onChange();
managed_profiles_list = managed_profiles_list.filter((profile) => profile.id !== id);
return;
} else {
alert('Error deleting person');
}
})
.catch((error) => {
console.info('Error:', error);
});
}
async function hardDeleteProfile(id: number) {
fetch('/api/person/' + id + '/hard-delete', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
})
.then((response) => {
if (response.ok) {
onChange();
managed_profiles_list = managed_profiles_list.filter((profile) => profile.id !== id);
return;
} else {
alert('Error deleting person');
}
})
.catch((error) => {
console.error('Error:', error);
});
}
</script>
<div class="modal modal-open z-8">
<div class="modal-box w-full max-w-xl gap-4">
<div class="bg-base-100 sticky top-0 z-5">
<ModalButtons onClose={closeModal} {createProfile} />
<div class="divider"></div>
</div>
<ul class="list bg-base-100 rounded-box shadow-md">
<li class="p-4 pb-2 text-xs tracking-wide opacity-60">{managed_profiles()}</li>
{#each managed_profiles_list as profile}
<li class="list-row">
<div>
<div>{profile.first_name + ' ' + profile.last_name}</div>
<div class="text-xs font-semibold uppercase opacity-60">{profile.id}</div>
</div>
<div>
<div class="text-xs font-semibold uppercase opacity-60">
{admin() + ' ' + from_time().toLowerCase() + ': ' + profile.adminSince}
</div>
<div class="text-xs font-semibold uppercase opacity-60">{profile.label![0]}</div>
</div>
<button
class="btn btn-success btn-soft"
onclick={() => {
addRelationship(profile.id!);
}}>
{add_relationship()}
</button>
<button
class="btn btn-success btn-soft"
onclick={() => {
createRelationshipAndProfile(profile.id!);
}}>
{create_relationship_and_person()}
</button>
<button
class="btn btn-secondary"
onclick={() => {
editProfile(profile.id!);
}}>
{edit()}
</button>
{#if profile.label?.includes('DeletedPerson')}
<button
class="btn btn-error"
onclick={() => {
hardDeleteProfile(profile.id!);
}}
>
{hard_delete()}
</button>
{:else}
<button
class="btn btn-error"
onclick={() => {
deleteProfile(profile.id!);
}}
>
{delete_profile()}
</button>
{/if}
</li>
{/each}
</ul>
</div>
</div>

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import {
add_relationship,
back,
biography,
close,
create,
create_person,
edit,
relation,
save
} from '$lib/paraglide/messages';
export let createProfile: () => void;
export let onClose: () => void;
</script>
<div class="flex items-center justify-between p-2">
<h3 class="text-lg font-bold">{relation()}</h3>
<div class="space-x-2">
<button class="btn btn-success btn-sm" on:click={createProfile}>
{'+ ' + create_person()}
</button>
<button class="btn btn-error btn-sm" on:click={onClose}>
{close()}
</button>
</div>
</div>

View File

@@ -532,14 +532,10 @@ export interface components {
};
Admin: {
id?: number;
label?: string[];
first_name?: string;
adminSince?: number;
last_name?: string;
EndId?: number;
EndElementId?: string;
Props?: {
added?: number;
};
};
AdminRelationship: {
Id?: number;

View File

@@ -0,0 +1,91 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
add_relationship,
remove,
create_relationship_and_person,
add_administrator
} from '$lib/paraglide/messages';
import type { Edge } from '@xyflow/svelte';
export let edge: Edge;
export let XUserId: string;
export let top: number | undefined;
export let left: number | undefined;
export let right: number | undefined;
export let bottom: number | undefined;
export let onClick: () => void;
export let deleteEdge: () => void;
let contextMenu: HTMLDivElement;
let isAdmin: boolean = false;
onMount(() => {
if (top) {
contextMenu.style.top = `${top}px`;
}
if (left) {
contextMenu.style.left = `${left}px`;
}
if (right) {
contextMenu.style.right = `${right}px`;
}
if (bottom) {
contextMenu.style.bottom = `${bottom}px`;
}
fetch(`/api/admin/${edge.source}/${XUserId}`)
.then((response) => {
if (response.status === 200) {
isAdmin = true;
} else {
isAdmin = false;
}
})
.catch((error) => {
console.error('Error fetching admin status:', error);
});
fetch(`/api/admin/${edge.target}/${XUserId}`)
.then((response) => {
if (response.status === 200) {
isAdmin = true;
}
})
.catch((error) => {
console.error('Error fetching admin status:', error);
});
});
</script>
<div
role="menu"
tabindex="-1"
bind:this={contextMenu}
class="context-menu bg-primary-100 rounded-lg shadow-lg"
onclick={onClick}
onkeydown={(e) => {
if (e.key === 'Esc' || e.key === ' ' || e.key === 'Escape') {
onClick();
}
}}
>
{#if isAdmin}
<button onclick={deleteEdge} class="btn">{remove()}</button>
{/if}
</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

@@ -1,5 +1,16 @@
<script lang="ts">
import { child, date, description, edit, file, from_time, media_title, notes, parent, relation_type, sibling, spouse, title, until, upload } from '$lib/paraglide/messages';
import {
child,
from_time,
id,
notes,
parent,
relation,
relation_type,
sibling,
spouse,
until,
} from '$lib/paraglide/messages';
import type { Edge } from '@xyflow/svelte';
import ModalButtons from '$lib/relationship/ModalButtons.svelte';
import type { components, operations } from '$lib/api/api.gen';
@@ -29,9 +40,8 @@
});
let relationshiptype: 'sibling' | 'child' | 'parent' | 'spouse' | undefined = $state('sibling');
async function getRelationships(startId: string, endId :string) {
if (startId === undefined || endId === undefined) {
alert('');
async function getRelationships(startId: string, endId: string) {
if (startId === undefined || endId === undefined || startId === '' || endId === '' || startId === endId) {
return;
}
@@ -50,11 +60,21 @@
relationships.push((await response.json()) as components['schemas']['dbtypeRelationship']);
}
getRelationships(startNode,endNode);
getRelationships(startNode,endNode);
if (!createRelationship){
getRelationships(startNode, endNode);
getRelationships(endNode, startNode);
}
async function save() {
for (const r of relationships) {
if (!r.Props) {
console.log('No properties found for relationship', r);
continue;
}
if (r.Props.verified === undefined) {
r.Props.verified = false;
}
console.log('Saving relationship', r.StartId, r.EndId, r.Props);
const patchBody: components['schemas']['FamilyRelationship'] = r.Props ?? {};
const response = await fetch(`/api/relationship/${r.StartId}/${r.EndId}`, {
@@ -66,6 +86,13 @@
if (!response.ok) {
console.log(`Failed to save relationship ${r.StartId}${r.EndId}`);
}
if (response.status === 200) {
console.log(`Relationship ${r.StartId}${r.EndId} saved successfully`);
} else {
console.log(`Failed to save relationship ${r.StartId}${r.EndId}`);
}
}
closeModal();
@@ -85,12 +112,13 @@
}
let body: operations['createRelationship']['requestBody']['content']['application/json'] = {
id1: startNode,
id2: endNode,
id1: Number(startNode),
id2: Number(endNode),
type: relationshiptype,
relationship: newRelationship
};
console.log('Creating relationship', body);
const response = await fetch(`/api/relationship`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -98,11 +126,11 @@
});
if (!response.ok) {
console.log('Cannot create relationship');
console.log('Cannot create relationship'+', status: ' + response.status);
return;
}
const created = await response.json() as components['schemas']['dbtypeRelationship'][];
const created = (await response.json()) as components['schemas']['dbtypeRelationship'][];
relationships.push(...created);
let newEdges: Edge[] = [];
@@ -112,19 +140,19 @@
source: r.StartElementId!,
target: r.EndElementId!,
type: 'relationship',
data: {...r.Props, type: r.Type},
data: { ...r.Props, type: r.Type }
});
}
onCreation(newEdges);
}
</script>
<div class="modal modal-open z-8">
<div class="modal-box w-full max-w-xl">
<div class="modal-box w-full max-w-xl gap-4">
<div class="bg-base-100 sticky top-0 z-7">
<ModalButtons
{editorMode}
createMode={createRelationship}
onCreate={createNewRelationship}
onClose={closeModal}
onSave={save}
@@ -145,72 +173,69 @@
<option value="spouse">{spouse()}</option>
</select>
</div>
<div class="form-control mt-1">
<p><strong>{id().toLowerCase()}:</strong>{startNode}</p>
</div>
<div class="form-control mt-1">
<label for="endNode" class="label">{relation()+' '+id().toLowerCase()}:</label>
<input id="endNode" type="text" bind:value={endNode} class="input input-bordered w-full"/>
</div>
{/if}
{#if !createRelationship}
<!-- Editor mode: show all existing relationships -->
{#each relationships as r, index}
<div class="border-base-300 mt-4 rounded border p-4">
<div class="form-control">
{#if editorMode}
<label for={`relationshiptype-${index}`} class="label">{relation_type()}</label>
<select id={`relationshiptype-${index}`} bind:value={r.Type} class="select select-bordered">
<option value="sibling">{sibling()}</option>
<option value="child">{child()}</option>
<option value="parent">{parent()}</option>
<option value="spouse">{spouse()}</option>
</select>
{:else}
<p><strong>{relation_type()}:</strong> {r.Type}</p>
{/if}
<p><strong>{relation_type()}:</strong> {r.Type}</p>
</div>
<div class="form-control mt-2">
{#if editorMode}
<label for={`verified-${index}`} class="label">Verified</label>
<input
id={`verified-${index}`}
type="checkbox"
bind:value={r.Props!.verified}
class="checkbox"
/>
<label for={`verified-${index}`} class="label">Verified</label>
<input
id={`verified-${index}`}
type="checkbox"
bind:checked={r.Props!.verified}
class="checkbox"
/>
{:else}
<p><strong>Verified:</strong>{r.Props?.verified}</p>
{/if}
</div>
<div class="form-control mt-2">ú
<div class="form-control mt-2">
{#if editorMode}
<label for={`notes-${index}`} class="label">{notes()}</label>
<textarea
id={`notes-${index}`}
bind:value={r.Props!.notes}
class="textarea textarea-bordered w-full"
>
</textarea>
<label for={`notes-${index}`} class="label">{notes()}</label>
<textarea
id={`notes-${index}`}
bind:value={r.Props!.notes}
class="textarea textarea-bordered w-full"
>
</textarea>
{:else}
<p><strong>{notes()}:</strong> {r.Props?.notes}</p>
{/if}
</div>
<div class="form-control mt-2">
{#if editorMode}
<label for={`from-${index}`} class="label">{from_time()}</label>
<input
id={`from-${index}`}
type="date"
bind:value={r.Props!.from}
class="input input-bordered w-full"
/>
<label for={`from-${index}`} class="label">{from_time()}</label>
<input
id={`from-${index}`}
type="date"
bind:value={r.Props!.from}
class="input input-bordered w-full"
/>
{:else}
<p><strong>{from_time()}:</strong> {r.Props?.from}</p>
{/if}
</div>
<div class="form-control mt-2">
{#if editorMode}
<label for={`to-${index}`} class="label">{until()}</label>
<input
id={`to-${index}`}
type="date"
bind:value={r.Props!.to}
class="input input-bordered w-full"
/>
<label for={`to-${index}`} class="label">{until()}</label>
<input
id={`to-${index}`}
type="date"
bind:value={r.Props!.to}
class="input input-bordered w-full"
/>
{:else}
<p><strong>{until()}:</strong> {r.Props?.to}</p>
{/if}

View File

@@ -1,5 +1,14 @@
<script lang="ts">
import { add_relationship, back, biography, close, create, edit, save } from '$lib/paraglide/messages';
import {
add_relationship,
back,
biography,
close,
create,
edit,
relation,
save
} from '$lib/paraglide/messages';
export let editorMode = false;
export let createMode = false;
@@ -11,23 +20,21 @@
</script>
<div class="flex items-center justify-between p-2">
<h3 class="text-lg font-bold">{biography()}</h3>
<h3 class="text-lg font-bold">{relation()}</h3>
<div class="space-x-2">
{#if !createMode}
<button class="btn btn-secondary btn-sm" on:click={onToggleEdit}>
{editorMode ? back() : edit()}
</button>
{/if}
{#if editorMode}
{#if createMode}
<button class="btn btn-accent btn-sm" on:click={onCreate}>
{add_relationship()}
</button>
{:else}
<button class="btn btn-accent btn-sm" on:click={onSave}>
{save()}
</button>
{/if}
{#if createMode}
<button class="btn btn-accent btn-sm" on:click={onCreate}>
{add_relationship()}
</button>
{:else 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()}

View File

@@ -0,0 +1,12 @@
import type { Edge } from '@xyflow/svelte';
export interface RelationshipMenu {
edge: Edge;
XUserId: string;
top: number | undefined;
left: number | undefined;
right: number | undefined;
bottom: number | undefined;
onClick: () => void;
deleteEdge: () => void;
}

View File

@@ -1,8 +1,11 @@
<script lang="ts">
import { edit } from './../lib/paraglide/messages/en.js';
import CreateRelationship from '$lib/relationship/Modal.svelte';
import { onMount } from 'svelte';
import { nodeTypes, edgeTypes } from '$lib/graph/model';
import { title, family_tree } from '$lib/paraglide/messages.js';
import { title, family_tree, select } from '$lib/paraglide/messages.js';
import type { RelationshipMenu } from '$lib/relationship/model.ts';
import AdminMenu from '$lib/admin/Modal.svelte';
import { SvelteFlowProvider, SvelteFlow, Controls, MiniMap } from '@xyflow/svelte';
import '@xyflow/svelte/dist/style.css';
@@ -33,6 +36,7 @@
let openPersonMenu: NodeMenu | undefined = $state(undefined);
let with_out_spouse = $state(false);
let createRelationship = $state(false);
let adminMenu = $state(false);
let familyTreeDAG = new FamilyTree();
let layout = familyTreeDAG.getLayoutedElements(
@@ -46,6 +50,7 @@
let edges = $state.raw<Edge[]>([] as Edge[]);
let relationshipStart: number | null = $state(null);
let relationshipMenu = $state(undefined as RelationshipMenu | undefined);
let createPerson = $state(false);
let clientWidth: number | undefined = $state();
@@ -104,6 +109,12 @@
},
addRelationship: () => {
relationshipStart = Number(node.data.id);
createRelationship = true;
selectedRelationship = {
id: 'relationship' + node.data.id,
source: String(relationshipStart),
target: String(node.data.id)
};
openPersonMenu = undefined;
},
addAdmin: () => {
@@ -181,7 +192,7 @@
};
const handleConnectEnd: OnConnectEnd = (event, connectionState) => {
event.preventDefault();
const sourceNodeId = connectionState.fromNode?.data.id;
if (sourceNodeId === undefined) return;
relationshipStart = Number(sourceNodeId);
@@ -190,7 +201,7 @@
selectedRelationship = {
id: 'relationship' + connectionState.toNode?.data.id,
source: String(relationshipStart),
target: String(connectionState.toNode?.data.id),
target: String(connectionState.toNode?.data.id)
};
return;
}
@@ -212,20 +223,42 @@
bind:nodes
bind:edges
onconnectend={handleConnectEnd}
onedgeclick={({ edge, event }: {
edge: Edge;
event: MouseEvent;
})=> {
onedgeclick={({ edge, event }: { edge: Edge; event: MouseEvent }) => {
selectedRelationship = edge;
selectedRelationship.source = String(edge.source.replace('person', ''));
selectedRelationship.target = String(edge.target.replace('person', ''));
}}
onnodeclick={handleNodeClickFunc}
onnodecontextmenu={handleContextMenu}
onedgecontextmenu={({ edge, event }: { edge: Edge; event: MouseEvent }) => {
selectedRelationship = edge;
selectedRelationship.source = String(edge.source.replace('person', ''));
selectedRelationship.target = String(edge.target.replace('person', ''));
if (clientHeight === undefined || clientWidth === undefined) {
clientHeight = window.innerHeight;
clientWidth = window.innerWidth;
}
relationshipMenu = {
XUserId: data.id,
edge: selectedRelationship,
onClick: () => {
relationshipMenu = undefined;
},
deleteEdge: () => {
edges = edges.filter((e) => e.id !== edge.id);
relationshipMenu = 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
};
}}
onpaneclick={handlePaneClick}
class="!bg-base-200"
{nodeTypes}
{edgeTypes}
fitView
onlyRenderVisibleElements={false}
fitView={true}
>
<MiniMap class="!bg-base-300" />
<Controls class="!bg-base-300" />
@@ -260,18 +293,55 @@
createRelationship = false;
selectedRelationship = undefined;
relationshipStart = null;
layout = familyTreeDAG.getLayoutedElements(
nodes,
edges,
tailwindClassToPixels('w-40') || 160,
tailwindClassToPixels('h-40') || 160,
'TB'
);
edges = [...layout.Edges];
nodes = [...layout.Nodes];
}}
startNode={String(relationshipStart)}
startNode={String(selectedRelationship.source)}
endNode={String(selectedRelationship.target)}
/>
{/if}
{#if openPersonMenu !== undefined}
<PersonMenu {...openPersonMenu!} />
{/if}
{#if adminMenu}
<AdminMenu
createProfile={() => {
createPerson = true;
relationshipStart = null;
}}
createRelationshipAndProfile={(id: number) => {
createPerson = true;
relationshipStart = id;
}}
addRelationship={(id: number) => {
createRelationship = true;
selectedRelationship = {
id: 'relationship' + id,
source: String(id),
target: String(id)
};
}}
closeModal={() => {
adminMenu = false;
}}
editProfile={(id: number) => {
openPersonPanel = true;
selectedPerson = { id: String(id) };
}}
onChange={() => {}}
/>
{/if}
</SvelteFlow>
</SvelteFlowProvider>
</div>
<div class="absolute top-2 left-2 flex flex-row items-center gap-2">
<HamburgerIcon />
<HamburgerIcon open_admin_panel={()=>{adminMenu=!adminMenu}}/>
</div>

View File

@@ -0,0 +1,25 @@
import { client } from '$lib/api/client';
import { redirect } from '@sveltejs/kit';
import type { RequestEvent } from './$types';
export async function GET(event: RequestEvent): Promise<Response> {
if (event.locals.session === null) {
return redirect(302, '/login');
}
const response = await client.GET('/managed_profiles', {
params: {
header: { 'X-User-ID': event.locals.session.userId }
}
});
if (response.response.ok) {
return new Response(null, {
status: response.response.status
});
} else {
return new Response(JSON.stringify(response.error), {
status: response.response.status
});
}
}

View File

@@ -1,3 +1,12 @@
MATCH (a)-[r1:Admin]->(b)
WHERE id(a) = $id
RETURN collect({id: id(b), first_name: b.first_name, last_name: b.last_name, adminSince: r1.added}) as managed;
RETURN
collect(
{
id: id(b),
label: labels(b),
first_name: b.first_name,
last_name: b.last_name,
adminSince: r1.added
}
) AS managed;

View File

@@ -58,15 +58,11 @@ const (
// Admin defines model for Admin.
type Admin struct {
EndElementId *string `json:"EndElementId,omitempty"`
EndId *int `json:"EndId,omitempty"`
Props *struct {
Added *int `json:"added,omitempty"`
} `json:"Props,omitempty"`
AdminSince *int `json:"adminSince,omitempty"`
FirstName *string `json:"first_name,omitempty"`
Id *int `json:"id,omitempty"`
LastName *string `json:"last_name,omitempty"`
AdminSince *int `json:"adminSince,omitempty"`
FirstName *string `json:"first_name,omitempty"`
Id *int `json:"id,omitempty"`
Label *[]string `json:"label,omitempty"`
LastName *string `json:"last_name,omitempty"`
}
// AdminRelationship defines model for AdminRelationship.