336 Commits

Author SHA1 Message Date
f801e8893a close modal after relationship creation 2025-06-26 22:51:52 +02:00
82dc8d8e08 add verified to dict 2025-06-26 22:24:50 +02:00
57ac9c068a fix missing await in body 2025-06-26 22:21:48 +02:00
a77f0f434e add support for notes, fix notes support 2025-06-26 21:46:14 +02:00
3ed3c037ab close admin modal on profile create open 2025-06-26 19:37:19 +02:00
46a612e31d fix only person creation window title 2025-06-26 19:04:58 +02:00
8358a38f4d remove person from graph when removed on admin panel 2025-06-26 18:55:46 +02:00
d393959c0d fix life events mechanisms 2025-06-25 17:15:03 +02:00
4f67a973a2 add invite code to field skip 2025-06-25 15:14:45 +02:00
40e557f8c7 do not close edit when generating invite code 2025-06-25 15:14:30 +02:00
7397ba0ccc fix fetch error on creation 2025-06-25 12:52:20 +02:00
58bb20e608 remove unused import 2025-06-25 10:38:42 +02:00
Vargha Csongor
c706785e51 Merge pull request #12 from vcscsvcscs/feature/migrate-100-percent-to-svelte
Migrate 100 percent to svelte
2025-05-01 23:13:56 +02:00
f396ad612e fix relationship update and save on edit 2025-05-01 22:56:45 +02:00
6343065c4d fix rerendering on creation 2025-05-01 22:33:22 +02:00
f1e26f467d add phone as a basic field to it 2025-05-01 16:59:24 +02:00
a8222892f7 fix text area tabulars 2025-05-01 16:23:53 +02:00
3a84989a9e remove unused old frontend 2025-05-01 16:22:46 +02:00
d3fddf34d9 fix logout and modal size 2025-05-01 16:21:17 +02:00
f2250134aa add managed profiles.bru 2025-05-01 16:03:27 +02:00
6ab6dbd5bc fixup person create 2025-05-01 16:03:17 +02:00
6d49d128ba log error, don't throw error here 2025-05-01 16:03:00 +02:00
59159512b1 fix only person create 2025-05-01 16:02:44 +02:00
66c49b4c9a disable not implemented buttons 2025-05-01 16:02:34 +02:00
4ab380f107 fix node image design 2025-05-01 16:01:11 +02:00
3b4677614c fix uncallables 2025-05-01 16:00:54 +02:00
fe6ad219e0 fix modal name 2025-05-01 16:00:43 +02:00
28c11dc1d1 fix up managed profiles fetch 2025-05-01 16:00:24 +02:00
4e682d7003 remove kustomize 2025-05-01 14:00:50 +02:00
aa8004ebf0 remove unused story 2025-05-01 03:31:50 +02:00
c9e308d578 run format 2025-05-01 01:00:21 +02:00
7f32896db0 fixup admin platform 2025-05-01 00:58:22 +02:00
37c80d523e implement invite code generation 2025-05-01 00:58:03 +02:00
6062f7616d relationship modal and some profile edit fix 2025-04-30 20:08:16 +02:00
aadb4098cc upload media modal 2025-04-30 20:07:56 +02:00
9b3930dcb6 fix family tree 2025-04-30 20:06:35 +02:00
473d624973 add date translation 2025-04-30 20:05:46 +02:00
5cdaa03636 use a helper string as anode id and use data.id where it is required 2025-04-30 12:53:27 +02:00
37d54f3873 remove sidebar 2025-04-30 11:29:28 +02:00
9814c76643 format 2025-04-30 11:29:10 +02:00
56665ccc19 new handles 2025-04-30 11:29:02 +02:00
f42b327566 add target handle in case of child 2025-04-30 11:28:33 +02:00
6de063a06d add edge cosmetics 2025-04-30 11:28:14 +02:00
9c08b39800 update svelteflow next 2025-04-30 11:27:38 +02:00
bd7d28ce0e fixup render on creation 2025-04-30 00:27:54 +02:00
68c73ca859 fix sidebar and some other design issues 2025-04-29 23:29:31 +02:00
69c43d6d79 fix descendant queries 2025-04-29 22:09:43 +02:00
466ea365de small fixes on profile and nodes 2025-04-29 22:09:31 +02:00
3ffc12012f create person and relation ship on edge drop 2025-04-29 21:37:50 +02:00
Vargha Csongor
5b2fd594f2 +1 en for mprof 2025-04-29 17:10:15 +02:00
Vargha Csongor
9802513d8e lint things, add sidebar 2025-04-29 17:10:06 +02:00
Vargha Csongor
e5061d6b4f format 2025-04-29 08:52:34 +00:00
Vargha Csongor
0d998f648c add cfgen types 2025-04-29 08:29:11 +00:00
Vargha Csongor
2cf59b8313 rm unused story 2025-04-29 08:29:02 +00:00
Vargha Csongor
39431de08a remove unused id 2025-04-29 08:28:30 +00:00
Vargha Csongor
ef4961bdb0 fix wrangler configs 2025-04-29 08:12:41 +00:00
Vargha Csongor
fd00471bbc fix logout file name 2025-04-29 08:10:06 +00:00
Vargha Csongor
d65de3d3c4 update open api generated type in go 2025-04-29 09:16:12 +02:00
4a4117acb9 deep copy new edges 2025-04-28 23:41:47 +02:00
1df2af9179 restructure profile edit 2025-04-28 23:28:09 +02:00
cda8d63a3f fixup endpoints 2025-04-28 23:27:58 +02:00
8660e29ff9 fixup family tree layout 2025-04-28 23:27:41 +02:00
b3b42bdabf update family tree return type 2025-04-28 22:43:34 +02:00
9507e0e74a update openapi 2025-04-28 22:41:41 +02:00
ba3d77f0cb update dict 2025-04-28 21:41:04 +02:00
c323e80b24 implement api proxy 2025-04-28 20:30:59 +02:00
4c521f8009 layout tree 2025-04-28 20:30:22 +02:00
a71ca26b2a fetch family tree 2025-04-28 20:29:45 +02:00
37bc34651a connection validator 2025-04-28 20:28:48 +02:00
3695eb084d tailwind px helper 2025-04-28 20:28:38 +02:00
d88138ecce openapi update 2025-04-28 20:28:23 +02:00
d1ce977fbc extend paraglide dict 2025-04-28 20:28:12 +02:00
f46712a445 add dagree 2025-04-28 20:27:58 +02:00
6c8d4bde47 rm request body from get 2025-04-28 20:27:47 +02:00
1b52a4acd7 implement menus and person panels 2025-04-27 23:57:39 +02:00
2e8b049f7a fixup storybook 2025-04-27 10:00:53 +02:00
79ce1dae04 create profile editor and viewer 2025-04-26 22:29:32 +02:00
8e51cc6e15 implement person node component 2025-04-26 22:28:54 +02:00
001732bee6 extend translation with admin stuff 2025-04-26 22:25:22 +02:00
50971c8dde add biological sex 2025-04-26 22:25:06 +02:00
73d593450e fix buttons 2025-04-26 16:23:31 +02:00
5cb335774b fix logout 2025-04-26 16:22:21 +02:00
dfb6f31a73 update api 2025-04-26 16:21:27 +02:00
d19e6cd980 extend translations 2025-04-26 16:21:01 +02:00
06eea4f302 add vscode extension recommendations 2025-04-26 16:11:03 +02:00
55d4175dd1 fixup css and start node implementation 2025-04-26 11:14:16 +02:00
d6b9159f1a fixup login and registration 2025-04-25 21:33:25 +02:00
9e02317ab1 use DB-adapter service 2025-04-25 14:33:47 +02:00
6b4b9ce973 remove unused models 2025-04-25 14:31:31 +02:00
626f6da6d2 update packages 2025-04-25 14:31:02 +02:00
ea3faba056 add comments 2025-04-24 22:15:30 +02:00
cd2116622f implement integration test for relationship update and delete 2025-04-24 16:06:20 +02:00
c8d68c5cc7 implement relationship integration tests 2025-04-24 15:08:34 +02:00
b5342a19ca implement relationship integration tests 2025-04-23 19:34:19 +02:00
92c1d29ace implement further integration tests on person and relationship creation 2025-04-22 22:47:37 +02:00
23da2c2186 rename bruno folder 2025-04-22 12:23:21 +02:00
1471ed28f0 add person and relationship tests 2025-04-22 12:23:12 +02:00
b307658c6c fixup port for db adapter 2025-04-22 12:22:48 +02:00
87020e0daf fix path of tests 2025-04-21 19:23:03 +02:00
baed702980 fixup api gen ts + add bruno requests 2025-04-21 09:28:52 +02:00
58ef6ecbd7 implement integration tests for person operations 2025-04-19 22:32:27 +02:00
07bd4d95a6 fix integration test to work, init integration tests 2025-04-19 17:21:30 +02:00
8194bc9dea fixup compose files 2025-04-19 16:48:45 +02:00
a1b907024e fix templated parameter type, convert struct to map 2025-04-19 16:47:20 +02:00
cc9a863311 fix db connection and timeout 2025-04-19 16:46:08 +02:00
dce8873b63 add bruno testing 2025-04-19 16:44:51 +02:00
c51ec07e51 add db adapter debug 2025-04-19 16:44:28 +02:00
dc45e5af00 update api 2025-04-19 16:43:55 +02:00
65e06b25ff init writing first integration test 2025-04-18 20:31:24 +02:00
db6bc33e9d remove printenv 2025-04-18 19:46:38 +02:00
07a7f7fbb8 remove dead code 2025-04-18 19:38:45 +02:00
6fcc5eb646 fix linter issues 2025-04-18 19:38:12 +02:00
719952dd18 init integration test 2025-04-18 19:32:57 +02:00
81389a2dea add gofmt precommit hook 2025-04-18 14:49:23 +02:00
8a763465d8 rename build title 2025-04-18 14:16:17 +02:00
a3eef8cf3f rm unused nolint 2025-04-18 14:02:41 +02:00
c1ae4b8960 fix tests 2025-04-18 13:50:03 +02:00
8472085dba update artifact upload trigger 2025-04-18 12:37:15 +02:00
ae7589703f update ci 2025-04-18 12:05:05 +02:00
ab461bb60e update gen 2025-04-18 12:04:54 +02:00
1887454328 fix everysingle lint issue 2025-04-18 11:58:15 +02:00
56fe0f6f30 cypher verify test 2025-04-18 10:15:28 +02:00
443db56df4 implement person_google_tests + close session 2025-04-18 09:51:06 +02:00
de9c38032c fix some lint issues 2025-04-18 09:50:47 +02:00
cb45ef7848 add exception to golangcilint 2025-04-18 09:48:39 +02:00
2097f5ed20 go mod tidy 2025-04-18 09:48:21 +02:00
68ca23821e fix session closure 2025-04-17 18:40:48 +02:00
9db46a759d implement tests 2025-04-17 18:31:43 +02:00
29f8f539c8 implement leftover transaction tests 2025-04-13 20:18:21 +02:00
b97d51a19c implement mocks 2025-04-13 20:17:50 +02:00
5593e36594 test adin auth check 2025-04-13 20:16:46 +02:00
c8888b052e fixup handlers 2025-04-13 20:16:22 +02:00
85a42d2658 implement family tree queries 2025-04-13 20:15:51 +02:00
120e7a9862 rm ssc 2025-04-13 20:13:48 +02:00
7ba69e3fce restructure api 2025-04-13 20:13:32 +02:00
67a7cfd830 updat api.gen.go 2025-04-12 11:42:43 +02:00
d054ec5c10 implement family tree query 2025-04-12 11:32:15 +02:00
e40e306faa update openapi 2025-04-10 21:11:38 +02:00
4a00cac333 test google methods 2025-04-10 21:11:28 +02:00
ab1d156e04 add relationship api 2025-04-10 21:11:11 +02:00
08e60f4af3 rm create schema 2025-04-10 21:10:56 +02:00
7bf31c0975 test admin operations 2025-04-10 15:58:46 +02:00
31bc1c0bb9 implement full mock for neo4j client operations 2025-04-10 15:58:35 +02:00
48a5a7d6e0 add new api gen 2025-04-10 11:20:32 +02:00
d4acdd4d53 start integration test implementation 2025-04-10 11:19:53 +02:00
af77d899e6 add get managed profiles query 2025-04-10 11:19:33 +02:00
be05f2d895 implement admin syste, 2025-04-07 21:54:19 +02:00
600f51ed1f init some functionaliies 2025-04-01 23:20:22 +02:00
4e9301a0e2 Create person and relationship implementation 2025-04-01 23:20:06 +02:00
5ea3d91dab add new queries 2025-04-01 23:19:41 +02:00
9fcbd3b79b close sessions and start authorization implementation 2025-04-01 23:19:29 +02:00
8c50802224 add close session func 2025-04-01 23:18:16 +02:00
2cb604951a rm allow admin property switch to relationship 2025-04-01 23:17:58 +02:00
59b31cb71a implement relationship delete 2025-04-01 23:17:27 +02:00
d3ed3f8b75 format query 2025-04-01 23:08:00 +02:00
3a9a05103b tidy go mod 2025-04-01 20:47:07 +02:00
47c5d868d0 rm unused funcs 2025-04-01 20:46:23 +02:00
d3e4202e06 commit gen.go too to conform with go get 2025-04-01 20:46:16 +02:00
f8f0694a1b implement person apis 2025-03-30 22:04:29 +02:00
f036bff6d6 implement relationship queries 2025-03-30 22:02:33 +02:00
6b41ce5f49 rm old stuff 2025-03-30 21:59:41 +02:00
471ecb8956 upgrade wrangler 2025-03-30 21:58:59 +02:00
5ea66fdd78 add relationship type enum 2025-03-30 21:58:41 +02:00
723c679ad0 implement google queries 2025-03-28 22:15:12 +01:00
2d3f8cfa34 fix person create issues and some httpstatus 2025-03-28 22:14:54 +01:00
391907792c Add todo comment 2025-03-28 22:14:07 +01:00
84b9cdf9f6 add auth func 2025-03-28 22:13:42 +01:00
fb2da46331 fix golang ci exclusion 2025-03-28 21:05:19 +01:00
0445ef4dc1 get person by id 2025-03-28 21:05:02 +01:00
71145858c5 fix param var 2025-03-28 21:04:48 +01:00
40377416da restructure pkg and internal 2025-03-28 12:33:42 +01:00
bb792b41e2 embed queries (in progress) 2025-03-28 00:22:23 +01:00
c10a4a71f7 update golang ci lint 2025-03-27 22:12:04 +01:00
5385244f97 add extra trigger paths 2025-03-27 22:10:28 +01:00
46077870c3 fix folder name for db adapter 2025-03-27 22:09:34 +01:00
a56454110a fix lint issues with prettier 2025-03-27 22:08:01 +01:00
03120d7242 fixup ci cd triggers and node version 2025-03-27 22:01:04 +01:00
166d00f0e6 add deployment 2025-03-27 21:55:34 +01:00
Vargha Csongor
2e9c91415d Merge branch 'main' into feature/migrate-100-percent-to-svelte 2025-03-27 20:49:35 +01:00
cd72097e02 move to new pipeline 2025-03-27 20:46:25 +01:00
2b78629fd0 fixup syntax in pipeline 2025-03-27 20:26:33 +01:00
db93735058 add required field to google id registration 2025-03-27 20:18:46 +01:00
97737375be temp ci fix 2025-03-27 20:18:26 +01:00
267285c356 init api implementations 2025-03-24 21:41:33 +01:00
d9d565e0ee add adapter ci 2025-03-23 18:16:19 +01:00
8d27d31968 update api 2025-03-23 18:16:09 +01:00
c716973525 add code generation 2025-03-22 16:55:33 +01:00
62566b3ec5 fix id type 2025-03-22 12:05:58 +01:00
bbf66ddc74 restructure repo 2025-03-17 21:48:21 +01:00
833c396b9b add generated code to git 2025-03-17 21:48:03 +01:00
44de928ab6 create openapi base 2025-03-17 21:47:50 +01:00
75b2cb2dcc add missing fav.ico 2025-03-15 11:07:07 +01:00
1c7f107c23 add wrangler config 2025-03-15 11:06:58 +01:00
6ba03fc10d fix up login screen 2025-03-15 11:06:16 +01:00
004c0f9c05 remove theme button duplicate 2025-03-15 11:04:58 +01:00
2008e8ba94 format 2025-03-15 11:04:13 +01:00
ced4e1b89c add theme button to layout 2025-03-15 11:04:01 +01:00
f860f25e56 fix up getUserFromGoogleID 2025-03-15 11:03:44 +01:00
34bacf8b93 add a google registration call back site 2025-03-15 11:02:39 +01:00
ccb377e6f6 change userid type to string in session 2025-03-15 11:01:45 +01:00
85cae6f503 switch to using built in object id in memgraph 2025-03-15 11:01:24 +01:00
f5e7292728 switch from Date<Integer> to Date<Number> 2025-03-15 10:59:57 +01:00
3242ad1c8c add pikaday 2025-03-15 10:59:27 +01:00
a6bf548c0c add memgraph docker compose 2025-03-15 10:59:03 +01:00
a70636df9e fix up theme controler style 2025-03-13 18:16:02 +01:00
29ffebc33a update daisyui 2025-03-10 23:27:39 +01:00
7b5c22c10e upgrade pkg lock 2025-03-10 23:18:52 +01:00
086e21e447 add theme change + high res icon 2025-03-10 23:10:46 +01:00
61790ed96c add theme selector 2025-03-10 23:10:12 +01:00
33ca58194a add data-theme 2025-03-10 23:09:31 +01:00
bbbdedec9f add relationship cypher querries. 2025-03-09 23:08:03 +01:00
5e1074571d init playwright 2025-03-05 22:40:11 +01:00
61665cd198 fixup querry interface for family member 2025-03-03 21:44:38 +01:00
57dcd3b3eb change favicon 2025-03-03 21:44:13 +01:00
3e4d8c901d implement get relationship cypher 2025-03-03 21:44:02 +01:00
36482465a4 change model 2025-03-03 13:00:32 +01:00
ef8f8a5118 init cypher querries 2025-03-03 13:00:23 +01:00
a8c4aa9351 init cypher queries 2025-02-24 22:45:51 +01:00
0161931cb4 Fix type 2025-02-24 14:31:06 +01:00
1d0541b1bd add page to stories 2025-02-23 15:31:36 +01:00
436d20301b add user model 2025-02-23 15:30:35 +01:00
b433a38bc7 init oauth and db 2025-02-22 18:53:12 +01:00
8bb54cea37 add birth name to translation files 2025-02-22 18:52:01 +01:00
59b748aa6c add google and memgraph env vars 2025-02-22 18:51:40 +01:00
1d12aac6ec implement session via cloudflare kv 2025-02-22 13:23:08 +01:00
c0e487b320 rm paraglide demo 2025-02-17 11:38:59 +01:00
04e159d40e init svelte flow 2025-02-16 20:26:11 +01:00
cd30fc72f5 set def lang 2025-02-16 19:54:13 +01:00
9e0c4d47a0 Remove non js compliant vars 2025-02-16 19:50:30 +01:00
5af2492ff2 add home page title 2025-02-16 16:55:53 +01:00
d758b2cde2 add language elements 2025-02-16 16:46:08 +01:00
5433d45269 add vite,coverage 2025-02-16 16:45:27 +01:00
efa7d06e0b adjust ci 2025-02-16 13:06:21 +01:00
413fe2bd85 add daisy ui and svelte flow 2025-02-15 17:36:29 +01:00
63783dce67 init new svelte 5 cloudflare project 2025-02-15 17:01:54 +01:00
Vargha Csongor
47dca8ed3d Merge pull request #10 from vcscsvcscs/feature/restructure-repo
Restructure repo to go standard
2024-12-28 14:02:59 +01:00
42b88e14a7 update images 2024-12-28 13:26:38 +01:00
bbddd08d24 fix path and add go get to docker 2024-12-22 22:22:05 +01:00
fa4c886300 update 2024-12-22 22:17:14 +01:00
9da42c62cd name lint and test and add go get path 2024-12-22 22:06:38 +01:00
d0f80818b0 change deploy trigger to pr too 2024-12-22 21:59:55 +01:00
677adc1e22 fixup ci 2024-12-22 21:58:50 +01:00
fa7086cd3c comment unused zitadel auths in backend 2024-12-22 21:41:56 +01:00
3bfc20cb21 move frontend to a cmd too for consistency 2024-12-22 21:40:21 +01:00
ebd82475fd update go in docker image 2024-12-22 21:39:54 +01:00
5658c10b56 move backend to new structure 2024-12-22 21:39:36 +01:00
3c601caf02 move auth to auth 2024-12-22 21:33:46 +01:00
Vargha Csongor
877557febe Merge pull request #8 from vcscsvcscs/feature/Add-frontend
[Feature] Implement frontend
Work was not finished here, but things will be changed in a future pr
2024-09-21 17:30:25 +02:00
10bee29482 fix small picture bugs 2024-09-21 17:18:28 +02:00
4db7a35271 fix api api communications 2024-05-17 14:03:34 +02:00
069d9317a3 fix person creation 2024-05-17 01:52:58 +02:00
ab2fd071e0 make temporary auth solution 2024-05-17 01:10:14 +02:00
0b1ca6338f Create forward auth 2024-05-17 00:03:52 +02:00
c466587bb5 Fixup Family member add panel tailwind 2024-05-15 23:27:15 +02:00
b292f39da0 fixup frontend 2024-05-15 23:21:04 +02:00
8991c30dd2 update traefik server transport 2024-05-15 22:45:40 +02:00
7a26af537a update cors 2024-05-15 22:45:24 +02:00
1163222406 fix cert names 2024-05-15 21:11:56 +02:00
e8962e2915 fix default tls paths 2024-05-15 21:09:24 +02:00
517ef26598 add on mount to solve issues with func call before load 2024-05-15 09:30:50 +02:00
f2d6bd1f8c add node click handle 2024-05-15 08:42:54 +02:00
321136ae4c fix forms daisy 2024-05-15 08:42:37 +02:00
741286f773 fix redirect url 2024-05-14 23:29:58 +02:00
2c6b21fa77 format 2024-05-14 22:00:21 +02:00
4ac9a4d437 fix function call before load bug 2024-05-14 21:59:06 +02:00
78305e7ca2 fix configs 2024-05-14 21:52:37 +02:00
cb6809b2a4 Add skeleton code to auth service for testing 2024-05-13 21:21:52 +02:00
416b4f0302 update docker image platform in cd 2024-05-13 21:11:10 +02:00
f484a271e2 update docker images 2024-05-13 21:10:17 +02:00
8139673405 Create profile 2024-05-13 19:51:14 +02:00
b13c1228af fix typing 2024-05-05 16:24:15 +02:00
b2ef584057 switch db schema to snake case for better frontend/backend/db integration 2024-05-05 16:13:22 +02:00
a7ad330b27 Change add family member structure 2024-05-05 15:56:48 +02:00
4c8d74ae04 format prettier 2024-05-05 15:56:27 +02:00
f461825234 With required fields only 2024-05-05 12:51:19 +02:00
bae91335fe start family member adding ui implementation 2024-05-05 12:45:52 +02:00
c10a1857a3 Add person panel 2024-05-05 12:21:37 +02:00
6bdbee1271 Add person menu 2024-05-05 12:21:27 +02:00
8ec95c4de1 Add relationship 2024-05-05 12:20:29 +02:00
8176277fcb add edges 2024-05-04 13:58:05 +02:00
d0af8f0250 Add login logic 2024-05-01 13:38:58 +02:00
Vargha Csongor
935b3ac5cf Merge pull request #7 from vcscsvcscs/feature/update-zitadel
update zitadel version
2024-04-29 13:01:55 +02:00
10c9ad393d update version 2024-04-29 13:01:18 +02:00
9f21a36406 Attempt to make things look nice 2024-04-29 12:58:43 +02:00
a28a6fa675 Create person node 2024-04-28 21:05:37 +02:00
2f795d6901 Return whole family tree 2024-04-28 21:05:19 +02:00
Vargha Csongor
5a958840b4 Merge pull request #6 from vcscsvcscs/feature/update-zitadel
Feature/update zitadel
2024-04-22 16:50:44 +02:00
c1df052462 ++ version zitadel 2024-04-22 16:49:11 +02:00
64b9361651 Add daisy ui and tailwind 2024-04-22 15:47:54 +02:00
Vargha Csongor
e5425b1cc8 Merge pull request #5 from vcscsvcscs/feature/add-basic-backend-functionalities
[Feature] Add backend functionalities
2024-04-22 08:39:16 +02:00
5a7e62a183 update ci to only trigger once 2024-04-22 08:38:33 +02:00
5e871cb272 fix some miss conventions 2024-04-22 08:37:21 +02:00
22ca38ad86 fix bidirectional relationships 2024-04-22 08:29:04 +02:00
3ade387d7d view family tree 2024-04-22 08:27:44 +02:00
77042ffdc5 Add verified to Person 2024-04-18 23:10:16 +02:00
5b9b6c53a6 Add ' to query 2024-04-18 23:10:00 +02:00
0b0b138c16 change errors to contain original 2024-04-18 23:09:42 +02:00
304552c2a5 Add ' to query 2024-04-18 23:09:23 +02:00
cf4b79c593 uuid to memgraph compatible 2024-04-18 23:08:52 +02:00
bc7cf7f1a6 create memgraph compatible uuid 2024-04-18 23:08:35 +02:00
e49aba7c58 fix api 2024-04-18 23:07:12 +02:00
01c6e4b0c9 Fix update person 2024-04-18 23:07:03 +02:00
564ef322e3 set uuid in a function after verify 2024-04-18 22:36:06 +02:00
d85d37eb2d Change responses 2024-04-18 22:35:48 +02:00
913042d441 add error logging and modify return 2024-04-18 22:35:32 +02:00
56607b31e5 correct typo 2024-04-18 22:35:02 +02:00
a5822913f6 create Relationship and person 2024-04-18 21:02:04 +02:00
5d03c51097 fix viewPerson query to be secure 2024-04-18 21:01:52 +02:00
5e8cdecca7 change bolt+s protocol to bolt+ssc in default for memgraph 2024-04-18 21:01:33 +02:00
40a70ecc93 Remove wrong driver close 2024-04-18 21:01:01 +02:00
12bb08d6ce fix typo and add handlers to router 2024-04-16 23:16:52 +02:00
eadfcd7afc update person 2024-04-16 23:13:16 +02:00
5d19dad30f change status code 2024-04-16 23:13:08 +02:00
7358ef5db1 delete person 2024-04-16 23:12:54 +02:00
68bd7dec11 update person to string 2024-04-16 23:00:06 +02:00
f10d8a87db verify relationship 2024-04-16 22:59:35 +02:00
72f81214be use snake case file naming convention 2024-04-16 22:45:07 +02:00
47b52d8a33 fix multi direction relationships 2024-04-16 22:27:11 +02:00
2e4cd879b2 delete relationship 2024-04-16 22:26:55 +02:00
ca67dead2b add createRelationship as a handler 2024-04-15 23:56:52 +02:00
162fe47051 create handler files 2024-04-15 23:47:25 +02:00
d49601b871 fix logging in createPerson 2024-04-15 23:46:32 +02:00
f5e95095c7 create relationship 2024-04-15 23:46:11 +02:00
c54b142b70 verify structs 2024-04-15 23:45:51 +02:00
65345e0e76 cypher injection prevention 2024-04-15 23:45:01 +02:00
a34a934bf4 move utilities to remote instead local 2024-04-15 21:13:19 +02:00
73627c7c59 Add utilities to docker files 2024-04-15 21:05:32 +02:00
33aa4945af go mod tidy 2024-04-15 20:50:46 +02:00
3b12f4798c Add Person model 2024-04-14 23:49:33 +02:00
cb6628a83c implement Get person endpoint 2024-04-14 19:21:20 +02:00
a4c1bc56f8 Fix release pipeline tagging 2024-04-14 17:13:59 +02:00
79256f2f10 Merge branch 'main' into feature/add-basic-backend-functionalities 2024-04-14 11:47:32 +02:00
cdea69736d Add init database.go 2024-04-14 11:46:46 +02:00
Vargha Csongor
ffde94d457 Merge pull request #4 from vcscsvcscs/feature/server-and-logger-setup-utilities
Move server and logger setup to utilities module
2024-04-14 11:18:27 +02:00
35f478e24c create memgraph db connection 2024-04-14 11:15:01 +02:00
a6718b2487 update docker build action 2024-04-14 01:25:56 +02:00
308 changed files with 40688 additions and 5087 deletions

View File

@@ -1,51 +0,0 @@
name: Release Auth service to Docker Hub and Deploy to Kubernetes
on:
push:
paths:
- "auth-service/**"
- "deployment/auth-service/**"
- ".github/workflows/auth-service-cd.yml"
jobs:
docker:
name: Build and Push Auth-service image to Docker Hub
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v6
id: create_image_tag
with:
script: |
if (context.issue.number) {
return "pr" + context.issue.number;
} else if(github.ref == 'refs/heads/main') {
return 'latest';
} else {
return "pr" + (
await github.rest.repos.listPullRequestsAssociatedWithCommit({
commit_sha: context.sha,
owner: context.repo.owner,
repo: context.repo.repo,
})
).data[0].number;
}
result-encoding: string
- name: Image tag
run: echo '${{steps.create_image_tag.outputs.result}}'
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v5
with:
push: true
context: "{{defaultContext}}:auth-service"
tags: vcscsvcscs/gheritage-auth-service:${{steps.create_image_tag.outputs.result}}
platforms: linux/arm64/v8

View File

@@ -1,24 +0,0 @@
name: Authentication service Continuous Integration
on:
push:
paths:
- "auth-service/**"
pull_request:
paths:
- "auth-service/**"
jobs:
lint:
uses: ./.github/workflows/go_lint.yml
with:
working-directory: 'auth-service'
build:
needs: lint
uses: ./.github/workflows/go_build.yml
with:
working-directory: 'auth-service'
test:
needs: build
uses: ./.github/workflows/go_test.yml
with:
working-directory: 'auth-service'

View File

@@ -1,23 +0,0 @@
name: Backend Continuous Integration
on:
push:
paths:
- "backend/**"
pull_request:
paths:
- "backend/**"
jobs:
lint:
uses: ./.github/workflows/go_lint.yml
with:
working-directory: 'backend'
build:
needs: lint
uses: ./.github/workflows/go_build.yml
with:
working-directory: 'backend'
test:
needs: build
uses: ./.github/workflows/go_test.yml
with:
working-directory: 'backend'

53
.github/workflows/cloudflare_cd.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: Deploy Generation Heritage Svelte Kit App to Cloudflare
on:
workflow_call:
pull_request:
types: [closed]
paths:
- "apps/app**"
jobs:
ci:
uses: ./.github/workflows/svelte_ci.yml
deploy-to-production:
runs-on: ubuntu-latest
timeout-minutes: 60
needs: ci
if: github.actor_id == 'vcscsvcscs' || ( github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.merged == true )
steps:
- uses: actions/checkout@v4
- name: Build & Deploy Worker to Production
uses: cloudflare/wrangler-action@v3
with:
environment: production
workingDirectory: 'apps/app'
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
secrets: |
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET
env:
GOOGLE_CLIENT_ID: ${{ secrets.PROD_GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.PROD_GOOGLE_CLIENT_SECRET }}
deploy-to-stage:
runs-on: ubuntu-latest
timeout-minutes: 60
needs: ci
if: github.actor_id == 'vcscsvcscs' || ( github.ref == 'refs/heads/stage' && github.event_name == 'pull_request' && github.event.action == 'closed' && github.event.pull_request.merged == true )
steps:
- uses: actions/checkout@v4
- name: Build & Deploy Worker to Stage
uses: cloudflare/wrangler-action@v3
with:
environment: staging
workingDirectory: 'apps/app'
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
secrets: |
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET
env:
GOOGLE_CLIENT_ID: ${{ secrets.STAGING_GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.STAGING_GOOGLE_CLIENT_SECRET }}

40
.github/workflows/db_adapter_ci.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Database Adapter Continues Integration
on:
workflow_call:
pull_request:
types: [opened, synchronize, reopened]
paths:
- "apps/db-adapter**"
- ".github/workflows/db_adapter_ci.yml"
- ".github/workflows/go_test.yml"
- ".github/workflows/docker_build.yml"
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.24'
cache: false
- name: golangci-lint
uses: golangci/golangci-lint-action@v7
with:
version: latest
working-directory: 'apps/db-adapter'
test:
uses: ./.github/workflows/go_test.yml
needs: golangci
with:
working-directory: 'apps/db-adapter'
build:
needs: test
uses: ./.github/workflows/docker_build.yml
with:
working-directory: 'apps/db-adapter'
service-name: 'gheritage-db-adapter'

View File

@@ -1,25 +1,28 @@
name: Release Backend service to Docker Hub and Deploy to Kubernetes
name: Release to Docker Hub
on:
push:
paths:
- "backend/**"
- "deployment/backend/**"
- ".github/workflows/backend-cd.yml"
workflow_call:
inputs:
service-name:
required: true
type: string
working-directory:
required: true
type: string
jobs:
docker:
name: Build and Push Backend image to Docker Hub
name: Build and Push ${{inputs.service-name}} image to Docker Hub
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@v6
id: create_image_tag
with:
script: |
if (context.issue.number) {
return "pr" + context.issue.number;
} else if(github.ref == 'refs/heads/main') {
if(github.ref == 'refs/heads/main') {
return 'latest';
} else if(context.issue.number) {
return "pr" + context.issue.number;
} else {
return "pr" + (
await github.rest.repos.listPullRequestsAssociatedWithCommit({
@@ -46,6 +49,6 @@ jobs:
uses: docker/build-push-action@v5
with:
push: true
context: "{{defaultContext}}:backend"
tags: vcscsvcscs/gheritage-backend-service:${{steps.create_image_tag.outputs.result}}
platforms: linux/arm64/v8
context: "{{defaultContext}}:${{ inputs.working-directory }}"
tags: vcscsvcscs/${{ inputs.service-name }}:${{steps.create_image_tag.outputs.result}}
platforms: linux/amd64, linux/arm64

View File

@@ -1,19 +0,0 @@
name: Frontend Continuous Integration
on:
push:
paths:
- "frontend/**"
pull_request:
paths:
- "frontend/**"
jobs:
lint:
uses: ./.github/workflows/svelte_lint.yml
with:
working-directory: 'frontend'
build:
needs: lint
uses: ./.github/workflows/svelte_build.yml
with:
working-directory: 'frontend'

View File

@@ -1,29 +0,0 @@
on:
workflow_call:
inputs:
working-directory:
required: true
type: string
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Go 1.22.x'
uses: actions/setup-go@v5
with:
go-version: '1.22.x'
- name: Display Go version
run: go version
- name: Install dependencies
run: |
cd ${{ inputs.working-directory }}
go get
- name: Build
run: |
cd ${{ inputs.working-directory }}
go build .

View File

@@ -1,22 +0,0 @@
on:
workflow_call:
inputs:
working-directory:
required: true
type: string
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
cache: false
- name: golangci-lint
uses: golangci/golangci-lint-action@v4
with:
version: latest
working-directory: ${{ inputs.working-directory }}

View File

@@ -1,19 +1,23 @@
name: Go Test
on:
workflow_call:
inputs:
working-directory:
required: true
type: string
workflow_call:
inputs:
working-directory:
required: true
type: string
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Go 1.22.x'
- name: Setup Go 1.24.x'
uses: actions/setup-go@v5
with:
go-version: '1.22.x'
cache-dependency-path: |
${{ inputs.working-directory }}/go.sum
go-version: '1.24.1'
- name: Display Go version
run: go version
@@ -21,9 +25,16 @@ jobs:
- name: Install dependencies
run: |
cd ${{ inputs.working-directory }}
go get
go get ./...
- name: Run tests
run: |
cd ${{ inputs.working-directory }}
go test ./...
go test ./... -json > ../../TestResults.json
- name: Upload Go test results
if: always()
uses: actions/upload-artifact@v4
with:
name: Go-Test-results
path: TestResults.json

View File

@@ -1,24 +0,0 @@
on:
workflow_call:
inputs:
working-directory:
required: true
type: string
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '21.x'
- name: Install dependencies
run: |
cd ${{ inputs.working-directory }}
npm ci
- name: Build
run: |
cd ${{ inputs.working-directory }}
npm run build

23
.github/workflows/svelte_ci.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: Frontend Continuous Integration
on:
workflow_call:
pull_request:
types: [opened, synchronize, reopened]
paths:
- "apps/app**"
- ".github/workflows/svelte_ci.yml"
- ".github/workflows/svelte_test.yml"
- ".github/workflows/svelte_lint.yml"
jobs:
lint:
uses: ./.github/workflows/svelte_lint.yml
with:
working-directory: 'apps/app'
build:
needs: lint
uses: ./.github/workflows/svelte_test.yml
with:
working-directory: 'apps/app'

View File

@@ -13,7 +13,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '21.x'
node-version: '22.x'
- name: Install dependencies
run: |
cd ${{ inputs.working-directory }}

36
.github/workflows/svelte_test.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
on:
workflow_call:
inputs:
working-directory:
required: true
type: string
jobs:
test:
runs-on: ubuntu-latest
permissions:
# Required to checkout the code
contents: read
# Required to put a comment into the pull-request
pull-requests: write
steps:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22.x'
- name: Install dependencies
run: |
cd ${{ inputs.working-directory }}
npm ci
- name: 'Test'
run: |
cd ${{ inputs.working-directory }}
npx vitest --coverage.enabled true
- name: 'Report Coverage'
# Set if: always() to also generate the report if tests are failing
# Only works if you set `reportOnFailure: true` in your vite config as specified above
if: always()
uses: davelosert/vitest-coverage-report-action@v2
with:
working-directory: ${{ inputs.working-directory }}

View File

@@ -1,132 +0,0 @@
linters-settings:
depguard:
rules:
logger:
deny:
# logging is allowed only by logutils.Log,
# logrus is allowed to use only in logutils package.
- pkg: "github.com/sirupsen/logrus"
desc: logging is allowed only by logutils.Log.
- pkg: "github.com/pkg/errors"
desc: Should be replaced by standard lib errors package.
- pkg: "github.com/instana/testify"
desc: It's a fork of github.com/stretchr/testify.
dupl:
threshold: 100
funlen:
lines: -1 # the number of lines (code + empty lines) is not a right metric and leads to code without empty line or one-liner.
statements: 50
goconst:
min-len: 2
min-occurrences: 3
gocritic:
enabled-tags:
- diagnostic
- experimental
- opinionated
- performance
- style
disabled-checks:
- dupImport # https://github.com/go-critic/go-critic/issues/845
- ifElseChain
- octalLiteral
- whyNoLint
gocyclo:
min-complexity: 15
gofmt:
rewrite-rules:
- pattern: 'interface{}'
replacement: 'any'
gomnd:
# don't include the "operation" and "assign"
checks:
- argument
- case
- condition
- return
ignored-numbers:
- '0'
- '1'
- '2'
- '3'
ignored-functions:
- strings.SplitN
govet:
settings:
printf:
funcs:
- (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof
- (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf
- (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf
- (github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf
enable:
- nilness
- shadow
errorlint:
asserts: false
lll:
line-length: 140
misspell:
locale: US
nolintlint:
allow-unused: false # report any unused nolint directives
require-explanation: false # don't require an explanation for nolint directives
require-specific: false # don't require nolint directives to be specific about which linter is being skipped
revive:
rules:
- name: unexported-return
disabled: true
- name: unused-parameter
linters:
disable-all: true
enable:
- bodyclose
- depguard
- dogsled
- dupl
- errcheck
- errorlint
- exportloopref
- funlen
- gocheckcompilerdirectives
- gochecknoinits
- goconst
- gocritic
- gocyclo
- gofmt
- goimports
- gomnd
- goprintffuncname
- gosec
- gosimple
- govet
- ineffassign
- lll
- misspell
- nakedret
- noctx
- nolintlint
- revive
- staticcheck
- stylecheck
- typecheck
- unconvert
- unparam
- unused
- whitespace
# don't enable:
# - asciicheck
# - gochecknoglobals
# - gocognit
# - godot
# - godox
# - goerr113
# - nestif
# - prealloc
# - testpackage
# - wsl
run:
timeout: 5m

21
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,21 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch db adapter",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}/apps/db-adapter/main.go",
"env": {
"HTTP_PORT": ":5237",
"MEMGRAPH_URI": "bolt://127.0.0.1:7687",
"MEMGRAPH_USER": "memgraph",
"MEMGRAPH_PASSWORD": "memgraph"
}
}
]
}

3328
api/openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,27 @@
name: Playwright Tests
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30

36
apps/app/.gitignore vendored Normal file
View File

@@ -0,0 +1,36 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Paraglide
src/lib/paraglide
*storybook.log
.dev.vars
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/

4
apps/app/.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock

15
apps/app/.prettierrc Normal file
View File

@@ -0,0 +1,15 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

View File

@@ -0,0 +1,20 @@
import type { StorybookConfig } from '@storybook/sveltekit';
const config: StorybookConfig = {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|ts|svelte)'],
addons: [
'@storybook/addon-svelte-csf',
'@storybook/addon-essentials',
'@chromatic-com/storybook',
'@storybook/addon-interactions'
],
framework: {
name: '@storybook/sveltekit',
options: {
builder: {
viteConfigPath: '../vite.config.ts'
}
}
}
};
export default config;

View File

@@ -0,0 +1,16 @@
import type { Preview } from '@storybook/svelte';
import '../src/app.css';
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i
}
}
}
};
export default preview;

12
apps/app/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +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"
]
}

5
apps/app/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"files.associations": {
"wrangler.json": "jsonc"
}
}

View File

@@ -1,6 +1,6 @@
# create-svelte
# sv
Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/main/packages/create-svelte).
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
@@ -8,10 +8,10 @@ If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npm create svelte@latest
npx sv create
# create a new project in my-app
npm create svelte@latest my-app
npx sv create my-app
```
## Developing
@@ -35,4 +35,4 @@ npm run build
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

View File

@@ -0,0 +1,18 @@
import { test, expect } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('https://playwright.dev/');
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/Playwright/);
});
test('get started link', async ({ page }) => {
await page.goto('https://playwright.dev/');
// Click the get started link.
await page.getByRole('link', { name: 'Get started' }).click();
// Expects page to have a heading with the name of Installation.
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
});

9
apps/app/env.example Normal file
View File

@@ -0,0 +1,9 @@
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
GOOGLE_CALLBACK_URI="http://localhost:3000/login/google/callback"
DB_ADAPTER="http://localhost:5237"
CF_ACCESS_CLIENT_SECRET=""
CF_ACCESS_CLIENT_ID=""
NODE_ENV="development"
PORT="3000"
HOST="0.0.0.0"

34
apps/app/eslint.config.js Normal file
View File

@@ -0,0 +1,34 @@
import prettier from 'eslint-config-prettier';
import js from '@eslint/js';
import { includeIgnoreFile } from '@eslint/compat';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default ts.config(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
},
{
files: ['**/*.svelte'],
languageOptions: {
parserOptions: {
parser: ts.parser
}
}
}
);

174
apps/app/messages/en.json Normal file
View File

@@ -0,0 +1,174 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"about": "About",
"accept": "Accept",
"add": "Add",
"add_administrator": "Add administrator",
"add_note": "Add Note",
"add_relationship": "Add Relationship",
"address": "Address",
"admin": "Admin",
"alive": "Alive",
"allergies": "Allergies",
"allow_family_tree_admin_access": "Allow Family Tree Admin Access",
"animal": "Animal",
"audio": "Audio",
"back": "Back",
"baptized": "Baptized",
"belief": "Belief",
"biological_sex": "Biological Sex",
"biography": "Biography",
"birth_name": "Birth Name",
"blood_pressure": "Blood Pressure",
"blood_type": "Blood Type",
"born": "Born",
"cancel": "Cancel",
"city": "City",
"child": "Child",
"change_profile_picture": "Change Profile Picture",
"citizenship": "Citizenship",
"close": "Close",
"coffee": "Coffee",
"colour": "Colour",
"connection": "Connection",
"connection_type": "Connection Type",
"contact": "Contact",
"cookie_disclaimer": "This website uses cookies to ensure you get the best experience, including storing the theme and handling user sessions.",
"cookie_policy": "Cookie Policy",
"country": "Country",
"create": "Create",
"create_invite_code": "Create invite code",
"create_person": "Create person",
"create_relationship_and_person": "Create relationship and person",
"dark": "Dark",
"date": "Date",
"death": "Death",
"delete_profile": "Delete profile",
"deceased": "Deceased",
"deny": "Deny",
"description": "Description",
"details": "Details",
"died": "Died",
"directions": "Directions",
"disclaimer": "Disclaimer",
"document": "Document",
"download": "Download",
"edit": "Edit",
"email": "Email",
"export_something": "Export{thing}",
"extra_names": "Extra Names",
"faith": "Faith",
"failed_to_create_user": "Failed to create user",
"family_tree": "Family Tree",
"favourite": "Favourite",
"favourite_recipes": "Favourite Recipes",
"female": "Female",
"file": "File",
"first_name": "First Name",
"flower": "Flower",
"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",
"home": "Home",
"id": "ID",
"ideology": "Ideology",
"illness": "Illness",
"image": "Image",
"ingridients": "Ingridients",
"interest": "Interest",
"intersex": "Intersex",
"invite_code": "Invite Code",
"language": "Language",
"last_name": "Last Name",
"life_events": "Life Events",
"light": "Light",
"loading": "Loading",
"login": "Login",
"logout": "Logout",
"male": "Male",
"media_title": "Media Title",
"medication": "Medication",
"message_for_future_generations": "Message for Future Generations",
"middle_name": "Middle Name",
"missing_field": "{field} is missing",
"mothers_first_name": "Mother's First Name",
"mothers_last_name": "Mother's Last Name",
"nation": "Nation",
"no": "No",
"no_data": "No Data",
"notes": "Notes",
"occupation": "Occupation",
"occupation_to_display": "Occupation to Display",
"optional_field": "Optional Field",
"other": "Other",
"others_said": "Others Said",
"parent": "Parent",
"people": "People",
"person": "Person",
"pet": "Pet",
"philosophy": "Philosophy",
"photos": "Photos",
"phone": "Phone",
"place_of_birth": "Place of Birth",
"place_of_death": "Place of Death",
"plant": "Plant",
"politics": "Politics",
"profile_id": "Profile ID",
"profiel_id_registration": "If someone already created a profile for you, ask them for your profiles ID and enter it here.",
"profile_picture": "Profile Picture",
"privacy_policy": "Privacy Policy",
"recipe": "Recipe",
"recipes": "Recipes",
"register": "Register",
"relation": "Relation",
"relation_type": "Relation Type",
"relationship": "Relationship",
"relationship_type": "Relationship Type",
"religion": "Religion",
"remove": "Remove",
"residence": "Residence",
"save": "Save",
"search": "Search",
"select": "Select",
"select_all": "Select All",
"settings": "Settings",
"sibling": "Sibling",
"sign_in": "Sign In",
"sign_out": "Sign Out",
"site_intro": "Welcome to Generations Heritage, a place to record your family tree and share your family history. Create a digital intellectual legacy for your descendants.",
"skill": "Skill",
"skin_colour": "Skin Colour",
"source": "Source",
"source_url": "Source URL",
"spouse": "Spouse",
"street": "Street",
"suffixes": "Suffixes",
"system": "System",
"talent": "Talent",
"terms_and_conditions": "Terms and Conditions",
"theme": "Theme",
"title": "Generations Heritage {page}",
"titles": "Titles",
"tree": "Tree",
"unselect_all": "Unselect All",
"unknown": "Unknown",
"upload": "Upload",
"until": "Until",
"vaccination": "Vaccination",
"vegetable": "Vegetable",
"verified": "Verified",
"video": "Video",
"website": "Website",
"weight": "Weight",
"welcome": "Welcome to Generations Heritage",
"yes": "Yes",
"zip_code": "Zip Code",
"add_life_event": "Add life-event",
"deleted_profiles": "Deleted profiles",
"managed_profiles": "Managed profiles"
}

172
apps/app/messages/hu.json Normal file
View File

@@ -0,0 +1,172 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"about": "Rólunk",
"accept": "Elfogadás",
"add": "Hozzáadás",
"add_administrator": "Adminisztrátor hozzáadása",
"add_note": "Jegyzet hozzáadása",
"add_relationship": "Kapcsolat hozzáadása",
"address": "Cím",
"admin": "Adminisztrátor",
"alive": "Élő",
"allergies": "Allergiák",
"allow_family_tree_admin_access": "Családfa adminisztrátor hozzáférésének engedélyezése",
"animal": "Állat",
"audio": "Hang",
"back": "Vissza",
"baptized": "Megkeresztelve",
"belief": "Hit",
"biological_sex": "Biológiai nem",
"biography": "Életrajz",
"birth_name": "Születési név",
"blood_pressure": "Vérnyomás",
"blood_type": "Vércsoport",
"born": "Született",
"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é",
"colour": "Szín",
"connection": "Kapcsolat",
"connection_type": "Kapcsolat típusa",
"contact": "Kapcsolat",
"cookie_disclaimer": "Ez a weboldal sütiket használ annak érdekében, hogy a lehető legjobb élményt nyújtsa, beleértve a téma tárolását és a felhasználói munkamenetek kezelését.",
"cookie_policy": "Süti szabályzat",
"country": "Ország",
"create": "Létrehozás",
"create_invite_code": "Meghívó kód létrehozása",
"create_person": "Személy létrehozása",
"create_relationship_and_person": "Kapcsolat és személy létrehozása",
"dark": "Sötét",
"date": "Dátum",
"death": "Halál",
"delete_profile": "Delete profile",
"deceased": "Elhunyt",
"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",
"email": "Email",
"export_something": "{thing}Exportálás",
"extra_names": "Extra nevek",
"faith": "Vallás",
"failed_to_create_user": "Felhasználó létrehozása sikertelen",
"family_tree": "Családfa",
"favourite": "Kedvenc",
"favourite_recipes": "Kedvenc receptek",
"female": "Nő",
"file": "Fájl",
"first_name": "Keresztnév",
"flower": "Virág",
"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",
"home": "Otthon",
"id": "Azonosító",
"ideology": "Ideológia",
"illness": "Betegség",
"image": "Kép",
"ingridients": "Hozzávalók",
"interest": "Érdeklődés",
"intersex": "Interszex",
"invite_code": "Meghívó kód",
"language": "Nyelv",
"last_name": "Vezetéknév",
"life_events": "Életesemények",
"light": "Világos",
"loading": "Betöltés",
"login": "Bejelentkezés",
"logout": "Kijelentkezés",
"male": "Férfi",
"media_title": "Média cím",
"medication": "Gyógyszer",
"message_for_future_generations": "Üzenet a jövő generációinak",
"middle_name": "Második név",
"missing_field": "{field} mező hiányzik",
"mothers_first_name": "Anyja keresztneve",
"mothers_last_name": "Anyja vezetékneve",
"nation": "Nemzet",
"no": "Nem",
"no_data": "Nincs adat",
"notes": "Jegyzetek",
"occupation": "Foglalkozás",
"occupation_to_display": "Megjelenítendő foglalkozás",
"optional_field": "Opcionális mező",
"other": "Más",
"others_said": "Mások mondták",
"parent": "Szülő",
"people": "Emberek",
"person": "Személy",
"pet": "Háziállat",
"philosophy": "Filozófia",
"photos": "Fotók",
"place_of_birth": "Születési hely",
"place_of_death": "Halálozási hely",
"plant": "Növény",
"politics": "Politika",
"profile_picture": "Profilkép",
"privacy_policy": "Adatvédelmi irányelvek",
"recipe": "Recept",
"recipes": "Receptek",
"register": "Regisztráció",
"relation": "Kapcsolat",
"relation_type": "Kapcsolat típusa",
"relationship": "Kapcsolat",
"relationship_type": "Kapcsolat típusa",
"religion": "Vallás",
"remove": "Eltávolítás",
"residence": "Lakóhely",
"save": "Mentés",
"search": "Keresés",
"select": "Kiválasztás",
"select_all": "Összes kiválasztása",
"settings": "Beállítások",
"sibling": "Testvér",
"sign_in": "Bejelentkezés",
"sign_out": "Kijelentkezés",
"site_intro": "Üdvözöljük a Generációk Öröksége oldalán, ahol rögzítheti családfáját és megoszthatja családtörténetét szereteivel. Hozon létre digitális szellemi hagyatékot leszármazottai számára.",
"skill": "Képesség",
"skin_colour": "Bőrszín",
"source": "Forrás",
"source_url": "Forrás URL",
"spouse": "Házastárs",
"street": "Utca",
"suffixes": "Utótagok",
"system": "Rendszer",
"talent": "Tehetség",
"terms_and_conditions": "Felhasználási feltételek",
"theme": "Téma",
"title": "Generációk Öröksége {page}",
"titles": "Címek",
"tree": "Fa",
"unselect_all": "Összes kiválasztásának megszüntetése",
"unknown": "Ismeretlen",
"upload": "Feltöltés",
"until": "-ig",
"vaccination": "Oltás",
"vegetable": "Zöldség",
"verified": "Igazolt",
"video": "Videó",
"website": "Weboldal",
"weight": "Súly",
"welcome": "Üdvözöljük a Generációk Öröksége oldalán",
"yes": "Igen",
"zip_code": "Irányítószám",
"add_life_event": "Életesemény hozzadása",
"deleted_profiles": "Törölt profilok",
"managed_profiles": "Adminisztrált profilok",
"phone": "Telefon"
}

11369
apps/app/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

73
apps/app/package.json Normal file
View File

@@ -0,0 +1,73 @@
{
"name": "generations-heritage",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "npm run build && wrangler pages dev --port 5173",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"deploy-stage": "npm run build && wrangler pages deploy --env staging",
"deploy-prod": "npm run build && wrangler pages deploy --env production",
"cf-typegen": "wrangler types && mv worker-configuration.d.ts src/"
},
"devDependencies": {
"@chromatic-com/storybook": "^3.2.4",
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@playwright/test": "^1.50.1",
"@storybook/addon-essentials": "^8.5.6",
"@storybook/addon-interactions": "^8.5.6",
"@storybook/addon-svelte-csf": "^5.0.0-next.23",
"@storybook/blocks": "^8.5.6",
"@storybook/svelte": "^8.5.6",
"@storybook/sveltekit": "^8.5.6",
"@storybook/test": "^8.5.6",
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-cloudflare": "^7.0.1",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tailwindcss/postcss": "^4.0.12",
"@types/node": "^22.13.9",
"@vitest/coverage-v8": "^3.0.5",
"daisyui": "^5.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^2.46.1",
"globals": "^15.14.0",
"openapi-typescript": "^7.6.1",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"storybook": "^8.5.6",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"tailwindcss": "^4.0.12",
"typescript": "^5.0.0",
"typescript-eslint": "^8.20.0",
"vite": "^6.0.0",
"vitest": "^3.0.0",
"wrangler": "^4.13.2"
},
"dependencies": {
"@dagrejs/dagre": "^1.1.4",
"@inlang/paraglide-sveltekit": "^0.15.0",
"@pilcrowjs/object-parser": "^0.0.4",
"@types/pikaday": "^1.7.9",
"@xyflow/svelte": "^1.0.0-next.11",
"arctic": "^3.3.0",
"neo4j-driver": "^5.28.1",
"openapi-fetch": "^0.13.5",
"pikaday": "^1.8.2",
"uuid": "^11.1.0"
}
}

View File

@@ -0,0 +1,79 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
// baseURL: 'http://127.0.0.1:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry'
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] }
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] }
}
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
]
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://127.0.0.1:3000',
// reuseExistingServer: !process.env.CI,
// },
});

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {}
}
};

1
apps/app/project.inlang/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
cache

View File

@@ -0,0 +1 @@
3e148103694315c86d552d141b1e0996d919bde0a260527d1d9f4af226be7582

View File

@@ -0,0 +1,17 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-empty-pattern@1/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-identical-pattern@1/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-missing-translation@1/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-without-source@1/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/message-lint-rule-valid-js-identifier@1/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@2/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@0/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{languageTag}.json"
},
"sourceLanguageTag": "hu",
"languageTags": ["en", "hu"]
}

32
apps/app/src/app.css Normal file
View File

@@ -0,0 +1,32 @@
@import 'tailwindcss';
/*
The default border color has changed to `currentColor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentColor);
}
}
@plugin "daisyui" {
themes:
light --default,
dark --prefersdark,
light,
dark,
cyberpunk,
synthwave,
retro,
coffee,
dracula;
}

20
apps/app/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,20 @@
import { KVNamespace } from '@cloudflare/workers-types';
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
interface Locals {
session: Session | null;
}
interface Platform {
env: {
GH_MEDIA: R2Bucket;
GH_SESSIONS: KVNamespace;
};
cf: CfProperties;
ctx: ExecutionContext;
}
}
}
export {};

14
apps/app/src/app.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="%paraglide.lang%" dir="%paraglide.textDirection%" data-theme="" class="bg-base-200">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%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>
</body>
</html>

View File

@@ -0,0 +1,52 @@
import type { Handle } from '@sveltejs/kit';
import { themes } from '$lib/themes';
import { i18n } from '$lib/i18n';
import {
validateSessionToken,
setSessionTokenCookie,
deleteSessionTokenCookie
} from '$lib/server/session';
import { sequence } from '@sveltejs/kit/hooks';
const handleParaglide: Handle = i18n.handle();
const authHandle: Handle = async ({ event, resolve }) => {
const token = event.cookies.get('session') ?? null;
if (token === null) {
event.locals.session = null;
return resolve(event);
}
if (!event.platform || !event.platform.env || !event.platform.env.GH_SESSIONS) {
return new Response('Server configuration error. GH_SESSIONS KeyValue store missing', {
status: 500
});
}
const session = await validateSessionToken(token, event.platform.env.GH_SESSIONS);
if (session !== null) {
setSessionTokenCookie(event, token, session.expiresAt);
} else {
console.error('Session token is invalid');
deleteSessionTokenCookie(event);
}
event.locals.session = session;
return resolve(event);
};
const themeHandler: Handle = async ({ event, resolve }) => {
const theme = event.cookies.get('theme');
if (!theme || !themes.includes(theme)) {
return await resolve(event);
}
return await resolve(event, {
transformPageChunk: ({ html }) => {
return html.replace('data-theme=""', `data-theme="${theme}"`);
}
});
};
export const handle: Handle = sequence(handleParaglide, authHandle, themeHandler);

2
apps/app/src/hooks.ts Normal file
View File

@@ -0,0 +1,2 @@
import { i18n } from '$lib/i18n';
export const reroute = i18n.reroute();

View File

@@ -0,0 +1,27 @@
import type { Meta, StoryObj } from '@storybook/svelte';
import Logout from './Logout.svelte';
const meta = {
title: 'lib/Logout',
component: Logout,
tags: ['autodocs'],
argTypes: {
show: { control: { type: 'boolean' } }
}
} satisfies Meta<Logout>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Visible: Story = {
args: {
show: true
}
};
export const Hidden: Story = {
args: {
show: false
}
};

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import { logout } from '$lib/paraglide/messages.js';
export let show = false;
</script>
{#if show}
<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

@@ -0,0 +1,54 @@
<script lang="ts">
import { themes } from './themes';
import { theme, light, dark, coffee } from '$lib/paraglide/messages.js';
let current_theme = $state('');
const themeMessages = new Map<string, string>([
['light', light()],
['dark', dark()],
['coffee', coffee()],
['cyberpunk', 'Cyberpunk'],
['synthwave', 'Synthwave'],
['retro', 'Retro'],
['dracula', 'Dracula']
]);
$effect(() => {
if (typeof window !== 'undefined') {
const theme = window.localStorage.getItem('theme');
if (theme && themes.includes(theme)) {
document.documentElement.setAttribute('data-theme', theme);
current_theme = theme;
}
}
});
function set_theme(event: Event) {
const select = event.target as HTMLSelectElement;
const theme = select.value;
if (themes.includes(theme)) {
const one_year = 60 * 60 * 24 * 365;
window.localStorage.setItem('theme', theme);
document.cookie = `theme=${theme}; max-age=${one_year}; path=/; SameSite=Lax`;
document.documentElement.setAttribute('data-theme', theme);
current_theme = theme;
}
}
</script>
<div class="dropdown dropdown-end">
<select
bind:value={current_theme}
data-choose-theme
class="btn btn-soft btn-xs h-8 min-h-0 px-4 py-0 text-sm"
onchange={set_theme}
>
<option value="" disabled={current_theme !== ''}>
{theme()}
</option>
{#each themes as theme}
<option value={theme} class="theme-controller capitalize">{themeMessages.get(theme)}</option>
{/each}
</select>
</div>

View File

@@ -0,0 +1,174 @@
<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 = () => {},
removePersonFromGraph = () => {},
addRelationship = () => {},
createProfile = () => {},
createRelationshipAndProfile = () => {}
} = $props<{
closeModal: () => void;
removePersonFromGraph?: (id: any) => void;
addRelationship?: (id: number) => void;
createRelationshipAndProfile?: (id: number) => void;
editProfile?: (id: number) => void;
createProfile?: () => void;
}>();
let managed_profiles_list: components['schemas']['Admin'][] = $state([]);
async function fetchManagedProfiles() {
try {
const response = await fetch(`/api/managed_profiles`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok || response.status !== 200) {
console.log('Cannot get managed profiles, status: ' + response.status);
return;
}
const data = await response.json();
managed_profiles_list = [
...((
data as operations['getManagedProfiles']['responses']['200']['content']['application/json']
).managed ?? [])
];
} 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) {
removePersonFromGraph(id);
managed_profiles_list.forEach((profile) => {
if (profile.id === id) {
profile.label = ['DeletedPerson'];
}
});
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) {
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 z-5 sticky top-0">
<ModalButtons onClose={closeModal} createProfile={()=>{createProfile();closeModal()}} />
<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 class="text-xs font-semibold uppercase opacity-60">{profile.id}</div>
</div>
<div>
<div>{profile.first_name + ' ' + profile.last_name}</div>
</div>
{#if false}
<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>
{/if}
{#if profile.label?.includes('DeletedPerson')}
<button
class="btn btn-error btn-sm"
onclick={() => {
hardDeleteProfile(profile.id!);
}}
>
{hard_delete()}
</button>
{:else}
<button
class="btn btn-secondary btn-sm"
onclick={() => {
editProfile(profile.id!);
}}>
{edit()}
</button>
<button
class="btn btn-error btn-sm"
onclick={() => {
deleteProfile(profile.id!);
}}>
{delete_profile()}
</button>
{/if}
</li>
{/each}
</ul>
</div>
</div>

View File

@@ -0,0 +1,28 @@
<script lang="ts">
import {
add_relationship,
back,
biography,
close,
create,
create_person,
edit,
managed_profiles,
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">{managed_profiles()}</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>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
import createClient from 'openapi-fetch';
import type { paths } from '$lib/api/api.gen'; // generated by openapi-typescript
import { DB_ADAPTER, CF_ACCESS_CLIENT_ID, CF_ACCESS_CLIENT_SECRET } from '$env/static/private';
export const client = createClient<paths>({
baseUrl: DB_ADAPTER || 'http://localhost:5237',
headers: {
'CF-Access-Client-Secret': CF_ACCESS_CLIENT_SECRET || '',
'CF-Access-Client-Id': CF_ACCESS_CLIENT_ID || ''
}
});

View File

@@ -0,0 +1,20 @@
<div role="alert" class="alert alert-vertical sm:alert-horizontal">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-info h-6 w-6 shrink-0"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span></span>
<div>
<button class="btn btn-sm">Deny</button>
<button class="btn btn-sm btn-primary">Accept</button>
</div>
</div>

View File

@@ -0,0 +1,98 @@
<script lang="ts">
import type { components } from '$lib/api/api.gen.ts';
import { child, spouse, parent, sibling } from '$lib/paraglide/messages';
import { getBezierPath, BaseEdge, type EdgeProps, Position } from '@xyflow/svelte';
let {
sourceX,
sourceY,
source,
sourcePosition,
target,
targetX,
targetY,
targetPosition,
markerEnd,
style,
data
}: EdgeProps = $props();
let edgeType = (
data as components['schemas']['FamilyRelationship'] & { type: string }
).type.toLowerCase();
let edgeLabel: string = $state(edgeType);
let edgeColor: string = $state('stroke: gray;');
let srcPos;
let tgtPos;
if (edgeType === 'spouse') {
edgeColor = 'stroke: red;';
edgeLabel = spouse();
if (sourceX < targetX) {
tgtPos = Position.Right;
srcPos = Position.Left;
} else {
tgtPos = Position.Left;
srcPos = Position.Right;
}
} else if (edgeType === 'child') {
edgeColor = 'stroke: blue;';
edgeLabel = child();
if (sourceY < targetY) {
tgtPos = Position.Bottom;
srcPos = Position.Top;
} else {
tgtPos = Position.Bottom;
srcPos = Position.Top;
}
} else if (edgeType === 'parent') {
edgeColor = 'stroke: green;';
edgeLabel = parent();
if (sourceY < targetY) {
tgtPos = Position.Bottom;
srcPos = Position.Top;
} else {
tgtPos = Position.Bottom;
srcPos = Position.Top;
}
} else if (edgeType === 'sibling') {
edgeColor = 'stroke: brown;';
edgeLabel = sibling();
if (sourceX < targetX) {
tgtPos = Position.Right;
srcPos = Position.Left;
} else {
tgtPos = Position.Left;
srcPos = Position.Right;
}
} else {
edgeColor = 'stroke: gray;';
edgeLabel = edgeType;
}
let [path, labelX, labelY] = $derived(
getBezierPath({
sourceX,
sourceY,
sourcePosition: srcPos,
targetX,
targetY,
targetPosition: tgtPos
})
);
edgeColor = edgeColor + 'stroke-opacity:unset; stroke-width=20;' + (style ?? '');
const onEdgeClick = () => {
window.dispatchEvent(
new CustomEvent('edge-click', {
detail: {
start: source,
end: target,
data: data as components['schemas']['FamilyRelationship'] & { type: string }
}
})
);
};
</script>
<BaseEdge {path} {labelX} {labelY} {markerEnd} style={edgeColor} onclick={onEdgeClick} />

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
add_relationship,
remove,
create_relationship_and_person,
add_administrator
} from '$lib/paraglide/messages';
export let id: string;
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 deleteNode: () => void;
export let createRelationshipAndNode: () => void;
export let addRelationship: () => void;
// export let addAdmin: (() => void) | undefined;
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/${id}/${XUserId}`)
.then((response) => {
if (response.status === 200) {
isAdmin = true;
} else {
isAdmin = false;
}
})
.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();
}
}}
>
<button onclick={createRelationshipAndNode} class="btn">
{create_relationship_and_person()}
</button>
<button onclick={addRelationship} class="btn">{add_relationship()}</button>
<!-- <button onclick={addAdmin} class="btn">{add_administrator()}</button> -->
{#if Number(XUserId) !== Number(id) && isAdmin}
<button onclick={deleteNode} 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

@@ -0,0 +1,117 @@
<!-- <svelte:options immutable /> -->
<script lang="ts">
import { Handle, Position, type NodeProps } from '@xyflow/svelte';
import type { components } from '$lib/api/api.gen';
import { isValidConnection } from './connection.js';
type $$Props = NodeProps;
export let data: NodeProps['data'] & components['schemas']['PersonProperties'];
let nodeColor = ' bg-neutral text-neutral-content';
switch (data.biological_sex) {
case 'female':
nodeColor = ' bg-secondary text-secondary-content';
break;
case 'male':
nodeColor = ' bg-primary text-primary-content';
break;
case 'intersex':
nodeColor = ' bg-accent text-accent-content';
break;
}
</script>
<div
class={'card card-compact flex h-40 w-40 flex-col items-center justify-center rounded-full shadow-lg' +
nodeColor}
>
<Handle
class="customHandle"
id="child"
{isValidConnection}
isConnectable={true}
position={Position.Bottom}
type="source"
style="z-index: 1;"
/>
<Handle
class="customHandle"
{isValidConnection}
position={Position.Left}
isConnectable={true}
type="target"
isConnectableStart={false}
/>
<Handle
class="customHandle"
{isValidConnection}
position={Position.Right}
isConnectable={true}
type="target"
isConnectableStart={false}
/>
<Handle
class="customHandle"
{isValidConnection}
position={Position.Left}
isConnectable={true}
type="source"
isConnectableStart={true}
/>
<Handle
class="customHandle"
{isValidConnection}
position={Position.Right}
isConnectable={true}
type="source"
isConnectableStart={true}
/>
<Handle
class="customHandle"
id="parent"
{isValidConnection}
position={Position.Top}
isConnectable={true}
type="target"
isConnectableStart={false}
/>
<div class="avatar mb-2" style="z-index: 2; cursor: pointer;">
<div class={"w-24 rounded-full border-0 ring-offset-1"+nodeColor}>
<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="px-2 text-center" style="z-index: 2; cursor: pointer;">
<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 : ''}
</h3>
</div>
</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>

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 (Number(edge.source) !== Number(edge.target)) {
return true;
}
return false;
}

View File

@@ -0,0 +1,140 @@
import dagre from '@dagrejs/dagre';
import type { Layout } from './model';
import type { Edge, Node } from '@xyflow/svelte';
import { Position } from '@xyflow/svelte';
export class FamilyTree extends dagre.graphlib.Graph {
constructor() {
super();
}
getLayoutedElements(
nodes: Node[],
edges: Edge[],
nodeWidth: number,
nodeHeight: number,
direction = 'TB'
): Layout {
this.setGraph({ rankdir: direction });
this.setDefaultEdgeLabel(() => ({}));
nodes.forEach((node) => {
this.setNode(node.id, { width: nodeWidth, height: nodeHeight });
});
edges.forEach((edge) => {
if (String(edge.data!.type).toLowerCase() === 'child') {
this.setEdge(edge.source, edge.target);
}
});
dagre.layout(this);
let newEdges: Edge[] = [];
edges.forEach((edge) => {
let newEdge = { ...edge };
if (String(edge.data?.type).toLowerCase() === 'child') {
newEdge.sourceHandle = 'child';
newEdge.targetHandle = 'parent';
} else if (String(edge.data?.type).toLowerCase() === 'parent') {
return;
}
const sourceNode = this.node(edge.source);
const targetNode = this.node(edge.target);
if (!sourceNode || !targetNode) {
return;
}
if (String(edge.data?.type).toLowerCase() === 'sibling') {
const padding = 50; // distance between sibling and source
const spouseWidth = nodeWidth;
const existingNodesAtLevel = nodes
.map((n) => ({ id: n.id, pos: this.node(n.id) }))
.filter(({ pos }) => Math.abs(pos.y - sourceNode.y) < nodeHeight / 2); // same horizontal band
// Collect taken x ranges
const takenXRanges = existingNodesAtLevel.map(({ pos }) => ({
from: pos.x - spouseWidth / 2,
to: pos.x + spouseWidth / 2
}));
// Try placing spouse to the right
let desiredX = sourceNode.x + nodeWidth + padding;
// Check for collision
const collides = (x: number) => {
return takenXRanges.some(({ from, to }) => x > from && x < to);
};
// If right side collides, try left
if (collides(desiredX)) {
desiredX = sourceNode.x - (nodeWidth + padding);
}
// If both sides collide, push right until free
while (collides(desiredX)) {
desiredX += nodeWidth + padding;
}
targetNode.x = desiredX;
targetNode.y = sourceNode.y;
}
if (String(edge.data?.type).toLowerCase() === 'spouse') {
const padding = 50; // distance between spouse and source
const spouseWidth = nodeWidth;
const existingNodesAtLevel = nodes
.map((n) => ({ id: n.id, pos: this.node(n.id) }))
.filter(({ pos }) => Math.abs(pos.y - sourceNode.y) < nodeHeight / 2); // same horizontal band
// Collect taken x ranges
const takenXRanges = existingNodesAtLevel.map(({ pos }) => ({
from: pos.x - spouseWidth / 2,
to: pos.x + spouseWidth / 2
}));
// Try placing spouse to the right
let desiredX = sourceNode.x + nodeWidth + padding;
// Check for collision
const collides = (x: number) => {
return takenXRanges.some(({ from, to }) => x > from && x < to);
};
// If right side collides, try left
if (collides(desiredX)) {
desiredX = sourceNode.x - (nodeWidth + padding);
}
// If both sides collide, push right until free
while (collides(desiredX)) {
desiredX += nodeWidth + padding;
}
targetNode.x = desiredX;
targetNode.y = sourceNode.y;
}
newEdge.hidden = false;
newEdge.type = 'familyEdge';
newEdges.push(newEdge);
});
const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = this.node(node.id);
return {
...node,
type: 'personNode',
position: {
x: nodeWithPosition.x - nodeWidth / 2,
y: nodeWithPosition.y - nodeHeight / 2
}
};
});
return { Nodes: layoutedNodes, Edges: newEdges };
}
}

View File

@@ -0,0 +1,28 @@
import type { Node, Edge, NodeTypes, EdgeTypes } from '@xyflow/svelte';
import FamilyEdge from './FamilyEdge.svelte';
import PersonNode from './PersonNode.svelte';
export const nodeTypes: NodeTypes = { personNode: PersonNode };
export const edgeTypes: EdgeTypes = {
familyEdge: FamilyEdge
};
export type NodeMenu = {
onClick: () => void;
deleteNode: () => void;
createRelationshipAndNode: () => void;
addRelationship: () => void;
addRecipe: (() => void) | undefined;
addAdmin: (() => void) | undefined;
id: string;
XUserId: string;
top: number | undefined;
left: number | undefined;
right: number | undefined;
bottom: number | undefined;
};
export type Layout = {
Nodes: Array<Node>;
Edges: Array<Edge>;
};

View File

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

View File

@@ -0,0 +1,43 @@
import type { components } from '$lib/api/api.gen';
import type { Layout } from '$lib/graph/model';
import type { Edge, Node } from '@xyflow/svelte';
export function parseFamilyTree(data: components['schemas']['FamilyTree']): Layout {
if (
data === null ||
data?.people === null ||
data?.people === undefined ||
data?.people.length === 0
) {
throw new Error('Family tree is empty');
}
const nodes: Node[] = data.people.map((person) => {
let newNode = { data: { ...person } } as Node;
if (person.id !== null && person.id !== undefined) {
newNode.id = 'person' + person.id.toString();
}
newNode.position = { x: 0, y: 0 };
newNode.data.id = person.id;
return newNode;
});
let relationships: Edge[] = [];
if (data.relationships) {
relationships = data.relationships.map((relationship) => {
const newEdge = { data: { ...relationship.Props } } as Edge;
newEdge.id = 'person' + relationship.ElementId;
newEdge.data!.type = relationship.Type?.toLowerCase();
if (relationship.StartElementId !== null && relationship.StartElementId !== undefined) {
newEdge.source = 'person' + relationship.StartId!.toString();
}
if (relationship.EndElementId !== null && relationship.EndElementId !== undefined) {
newEdge.target = 'person' + relationship.EndId!.toString();
}
return newEdge;
});
}
return { Nodes: nodes, Edges: relationships };
}

22
apps/app/src/lib/i18n.ts Normal file
View File

@@ -0,0 +1,22 @@
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];
try {
if (typeof fn === 'function') {
return fn({ thing: '', field: '', page: '', name: '' });
} else {
throw new Error(`Function ${name} is not callable`);
}
} catch (error) {
console.error(`Error calling message function ${name}:`, error);
return '';
}
}

View File

@@ -0,0 +1,7 @@
export interface PersonProperties {
google_id: string;
first_name: string;
middle_name?: string;
last_name: string;
email: string;
}

View File

@@ -0,0 +1,113 @@
<script lang="ts">
import {
add_life_event,
description,
life_events,
unknown,
until,
remove
} from '$lib/paraglide/messages';
import type { components } from '$lib/api/api.gen';
export let person_life_events: components['schemas']['PersonProperties']['life_events'];
export let editorMode = false;
export let onChange: (field: keyof components['schemas']['PersonProperties'], value: any) => void;
function updateEvent(index: number, key: 'from' | 'to' | 'description', value: string) {
if (!person_life_events) return;
person_life_events = person_life_events.map((event, i) =>
i === index ? { ...event, [key]: value } : event
);
onChange('life_events', person_life_events);
}
function addEvent() {
const newEvent = { from: '', to: undefined, description: '' };
person_life_events = [...(person_life_events ?? []), newEvent];
onChange('life_events', person_life_events);
}
function removeEvent(index: number) {
if (!person_life_events) return;
person_life_events = person_life_events.filter((_, i) => i !== index);
onChange('life_events', person_life_events);
}
</script>
{#if person_life_events?.length}
<div class="divider">{life_events()}</div>
<ul class="timeline timeline-snap-start timeline-vertical">
{#each person_life_events as event, index}
<li>
<div class="timeline-start flex items-center">
{#if editorMode}
<input
type="date"
class="input input-xs input-bordered"
value={event.from ?? ''}
on:input={(e) => updateEvent(index, 'from', e.currentTarget.value)}
placeholder={unknown().toLowerCase()}
/>
<!-- Remove button -->
<button
type="button"
class="btn btn-xs btn-ghost text-error ml-2"
title={remove()}
on:click={() => removeEvent(index)}
aria-label={remove() + ' ' + life_events()}
>
&#10005;
</button>
{:else}
{event.from ?? unknown().toLowerCase()}
{/if}
</div>
<div class="timeline-middle">
<div class="badge badge-primary"></div>
</div>
<div class="timeline-end space-y-1">
{#if editorMode}
<textarea
class="textarea textarea-xs textarea-bordered w-full"
value={event.description ?? ''}
on:input={(e) => updateEvent(index, 'description', e.currentTarget.value)}
placeholder={description()}
></textarea>
{:else}
<p>{event.description}</p>
{/if}
{#if event.to || editorMode}
<p class="text-sm opacity-50">
{until()}
{#if editorMode}
<input
type="date"
class="input input-xs input-bordered ml-1"
value={event.to ?? ''}
on:input={(e) => updateEvent(index, 'to', e.currentTarget.value)}
placeholder={unknown().toLowerCase()}
/>
{:else}
{event.to ?? unknown().toLowerCase()}
{/if}
</p>
{/if}
</div>
<hr />
</li>
{/each}
</ul>
{/if}
{#if editorMode}
<div class="mt-4 flex justify-center">
<button class="btn btn-primary btn-sm" on:click={addEvent}>
{add_life_event()}
</button>
</div>
{/if}

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import type { components } from '$lib/api/api.gen';
import { video, photos, upload } from '$lib/paraglide/messages';
import UploadMediaModal from '$lib/profile/editors/UploadMediaModal.svelte';
export let person: components['schemas']['PersonProperties'];
export let editorMode = false;
let uploadModal = false;
let mediaType: 'audio' | 'video' | 'photo' | undefined = undefined;
</script>
{#if person.photos?.length || person.videos?.length}
<div class="divider">{photos()} & {video()}</div>
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
{#each person.photos ?? [] as picture}
<img
src={picture.url}
alt={picture.description ?? photos()}
class="h-32 w-full rounded-lg object-cover shadow-md"
/>
{/each}
{#each person.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}
{#if false}
<div class="divider">{upload()}</div>
<div class="grid grid-cols-2 gap-4">
<button
class="btn btn-soft btn-xs"
on:click={() => {
uploadModal = true;
mediaType = 'photo';
}}
>
{'+ ' + photos()}
</button>
<button
class="btn btn-soft btn-xs"
on:click={() => {
uploadModal = true;
mediaType = 'video';
}}
>
{'+ ' + video()}
</button>
</div>
{/if}
{#if uploadModal}
<UploadMediaModal
closeModal={() => {
uploadModal = false;
}}
{mediaType}
onCreation={(newMedia: { url: string; name: string; description: string; date: string }) => {
if (mediaType === 'photo') {
person.photos = [...(person.photos ?? []), newMedia];
} else if (mediaType === 'video') {
person.videos = [...(person.videos ?? []), newMedia];
}
}}
/>
{/if}

View File

@@ -0,0 +1,99 @@
<script lang="ts">
import { died } from './../paraglide/messages/en.js';
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 {
closeModal = () => {},
person = {}
}: {
closeModal: () => void;
person: components['schemas']['PersonProperties'] & {
id?: string;
};
} = $props();
let editorMode = $state(false);
let draftPerson = $state({} as components['schemas']['PersonProperties']);
editorMode = false;
function handleDraftPersonChange(
field: keyof components['schemas']['PersonProperties'],
value: any
) {
draftPerson[field] = value;
if (field === 'invite_code') {
save().then(() => {
editorMode = true;
});
return;
}
}
function close() {
closeModal();
editorMode = false;
draftPerson = {};
}
function toggleEdit() {
editorMode = !editorMode;
}
async function save() {
try {
console.debug('Saving person data:', draftPerson);
const response = await fetch(`/api/person/${person.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(draftPerson)
});
if (!response.ok) {
console.error('Error saving person data, status: ', response.status, (await response.json()));
alert('Error saving person data, status: ' + response.status + (await response.json()));
return;
}
if (response.status === 200) {
person = { ...person, ...draftPerson };
const data = (await response.json()) as {
person?: components['schemas']['Person'];
};
} else {
const errorDetails = await response.json();
console.error('Error details:', errorDetails);
alert(`Error saving person data, status: ${response.status} ${JSON.stringify(errorDetails)}`);
}
} catch (error) {
alert('An unexpected error occurred: ' + error);
}
editorMode = !editorMode;
}
</script>
<div class="modal modal-open" transition:fade>
<div class="modal-box max-h-80 max-h-screen w-full max-w-5xl overflow-y-auto">
<div class="bg-base-100 z-7 sticky top-0">
<ModalButtons {editorMode} onClose={close} onSave={save} onToggleEdit={toggleEdit} />
<div class="divider"></div>
</div>
<ProfileHeader {person} {editorMode} onChange={handleDraftPersonChange} />
<MediaGallery {person} {editorMode} />
<LifeEventsTimeline
person_life_events={person.life_events}
{editorMode}
onChange={handleDraftPersonChange}
/>
<OtherDetails {person} {editorMode} onChange={handleDraftPersonChange} />
</div>
</div>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
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-secondary btn-sm" on:click={onToggleEdit}>
{editorMode ? back() : edit()}
</button>
{#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

@@ -0,0 +1,155 @@
<script lang="ts">
import { callMessageFunction } from '$lib/i18n';
import type { MessageKeys } from '$lib/i18n';
import { add_note, notes, theme } from '$lib/paraglide/messages';
import type { components } from '$lib/api/api.gen';
export let person: components['schemas']['PersonProperties'];
export let editorMode = false;
export let onChange: (field: keyof components['schemas']['PersonProperties'], value: any) => void;
const skipFields = [
'id',
'first_name',
'last_name',
'born',
'died',
'middle_name',
'biological_sex',
'email',
'limit',
'mothers_first_name',
'mothers_last_name',
'profile_picture',
'photos',
'videos',
'life_events',
'residence',
'medications',
'medical_conditions',
'languages',
'notes',
'phone',
'audios',
'google_id',
'invite_code'
];
let newNote = {
title: " ",
note: ""
};
</script>
<div class="mt-5 flex flex-col gap-2 justify-center items-center">
{#each person.notes ?? [] as note, i}
<div class="card bg-base-100 shadow-sm relative w-full max-w-xl">
<div class="card-body p-4 w-full">
{#if editorMode}
<input
type="text"
class="input input-bordered input-sm w-full mb-2"
placeholder={theme()}
bind:value={note.title}
oninput={() => onChange('notes', person.notes)}
/>
<textarea
class="textarea textarea-bordered textarea-sm w-full"
placeholder={notes()}
bind:value={note.note}
oninput={() => onChange('notes', person.notes)}
></textarea>
<button
type="button"
class="absolute top-2 right-2 btn btn-xs btn-ghost text-error ml-2"
aria-label="Remove note"
onclick={() => {
person.notes = (person.notes ?? []).filter((_, idx) => idx !== i);
onChange('notes', person.notes);
}}
>
&#10005;
</button>
{:else}
<h2 class="card-title">{note.title}</h2>
<p class="text-sm text-gray-500">{note.date}</p>
<p>{note.note}</p>
{/if}
</div>
</div>
{/each}
{#if editorMode}
<button
class="btn btn-accent btn-sm w-auto self-start"
onclick={() => {
const now = new Date();
const formattedDate = now.getFullYear() + '-' +
String(now.getMonth() + 1).padStart(2, '0') + '-' +
String(now.getDate()).padStart(2, '0');
person.notes = [...(person.notes ?? []), { title: '', note: '', date: formattedDate }];
onChange('notes', person.notes);
}}
>
{add_note()}
</button>
{/if}
</div>
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
{#each Object.entries(person) as [key, value]}
{#if !skipFields.includes(key) && ((value !== undefined && value !== null) || editorMode)}
<div>
<label class="label font-semibold"
>{callMessageFunction(key as MessageKeys) || key}:
{#if editorMode}
{#if typeof value === 'string'}
{#if value.length > 100}
<textarea
bind:value={person[key as keyof components['schemas']['PersonProperties']]}
class="textarea textarea-bordered textarea-sm w-full"
oninput={(e) =>
onChange(
key as keyof components['schemas']['PersonProperties'],
String(person[key as keyof components['schemas']['PersonProperties']])
)}
></textarea>
{:else}
<input
type="text"
class="input input-bordered input-sm w-full"
bind:value={person[key as keyof components['schemas']['PersonProperties']]}
oninput={() =>
onChange(
key as keyof components['schemas']['PersonProperties'],
String(person[key as keyof components['schemas']['PersonProperties']])
)}
/>
{/if}
{:else if typeof value === 'boolean'}
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:value={person[key as keyof components['schemas']['PersonProperties']]}
onchange={(e) =>
onChange(
key as keyof components['schemas']['PersonProperties'],
Boolean(person[key as keyof components['schemas']['PersonProperties']])
)}
/>
{:else if typeof value === 'number'}
<input
type="number"
class="input input-bordered input-sm w-full"
bind:value={person[key as keyof components['schemas']['PersonProperties']]}
oninput={(e) =>
onChange(
key as keyof components['schemas']['PersonProperties'],
Number(person[key as keyof components['schemas']['PersonProperties']])
)}
/>
{/if}
{:else}
<p>{value ?? '-'}</p>
{/if}
</label>
</div>
{/if}
{/each}
</div>

View File

@@ -0,0 +1,205 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { components } from '$lib/api/api.gen';
import { v4 as uuidv4 } from 'uuid';
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,
create_invite_code,
invite_code,
phone
} from '$lib/paraglide/messages';
import { callMessageFunction } from '$lib/i18n';
import type { MessageKeys } from '$lib/i18n';
export let person: components['schemas']['PersonProperties'] & {
id?: string;
};
export let editorMode = false;
export let onChange: (field: keyof components['schemas']['PersonProperties'], value: any) => void;
let new_invite_code: string | undefined;
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().split('T')[0];
onChange('born', date.toISOString().split('T')[0]);
}
});
// 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().split('T')[0];
onChange('died', date.toISOString().split('T')[0]);
}
});
// Clean up when component unmounts
return () => picker.destroy();
});
}
});
</script>
<div class="flex flex-col gap-6 md:flex-row">
<div class="flex flex-shrink-0 flex-col items-center gap-2">
<img
src={person.profile_picture || 'https://cdn-icons-png.flaticon.com/512/10628/10628885.png'}
alt={profile_picture()}
class="h-48 w-48 rounded-lg object-cover shadow-md"
/>
{#if false}
<button class="btn btn-neutral btn-soft btn-xs" onclick={() => {}}>
{change_profile_picture()}
</button>
{/if}
</div>
<div class="grid flex-1 grid-cols-1 gap-4 md:grid-cols-2">
<div class="flex flex-col gap-2">
<p>
<strong>{first_name()}: </strong>
{#if editorMode}<input
bind:value={person.first_name}
onchange={() => onChange('first_name', person.first_name)}
class="input input-sm input-bordered w-full"
/>{:else}{person.first_name ?? '-'}{/if}
</p>
<p>
<strong>{last_name()}: </strong>
{#if editorMode}<input
bind:value={person.last_name}
onchange={() => onChange('last_name', person.last_name)}
class="input input-sm input-bordered w-full"
/>{:else}{person.last_name ?? '-'}{/if}
</p>
<p>
<strong>{middle_name()}:</strong>
{#if editorMode}<input
bind:value={person.middle_name}
onchange={() => onChange('middle_name', person.middle_name)}
class="input input-sm input-bordered w-full"
/>{:else}{person.middle_name ?? '-'}{/if}
</p>
<p>
<strong>{born()}: </strong>
{#if editorMode}<input
type="text"
class="pika-single w-full"
id="birth_date"
bind:this={birth_date}
placeholder={person.born}
onchange={() => onChange('born', birth_date.value)}
/>
{:else}{person.born ?? '-'}{/if}
</p>
<p>
<strong>{died()}: </strong>
{#if editorMode}<input
type="text"
class="pika-single w-full"
id="death_date"
placeholder={person.died ?? died()}
bind:this={death_date}
onchange={() => onChange('died', death_date.value)}
/>{:else}{person.died ?? '-'}{/if}
</p>
<p>
<strong>{biological_sex()}: </strong>
{#if editorMode}
<select
name="biological_sex"
class="select select-bordered select-sm w-full"
id="biological_sex"
bind:value={person.biological_sex}
onchange={() => onChange('biological_sex', person.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(person.biological_sex as MessageKeys) ?? '-'}{/if}
</p>
</div>
<div class="flex flex-col gap-2">
<p>
<strong>{email()}:</strong>
{#if editorMode}<input
bind:value={person.email}
onchange={() => onChange('email', person.email)}
class="input input-sm input-bordered w-full"
/>{:else}{person.email ?? '-'}{/if}
</p>
<p>
<strong>{phone()}:</strong>
{#if editorMode}<input
bind:value={person.phone}
onchange={() => onChange('phone', person.phone)}
class="input input-sm input-bordered w-full"
/>{:else}{person.phone ?? '-'}{/if}
<p>
<strong>{mothers_first_name()}:</strong>
{#if editorMode}<input
bind:value={person.mothers_first_name}
onchange={() => onChange('mothers_first_name', person.mothers_first_name)}
class="input input-sm input-bordered w-full"
/>{:else}{person.mothers_first_name ?? '-'}{/if}
</p>
<p>
<strong>{mothers_last_name()}:</strong>
{#if editorMode}<input
bind:value={person.mothers_last_name}
onchange={() => onChange('mothers_last_name', person.mothers_last_name)}
class="input input-sm input-bordered w-full"
/>{:else}{person.mothers_last_name ?? '-'}{/if}
</p>
<p><strong>{id()}: </strong>{' ' + (person.id ?? '-')}</p>
<p><strong>Limit: </strong>{' ' + (person.limit ?? '-')}</p>
{#if editorMode && (person.google_id === undefined || person.google_id === null || person.google_id === '')}
{#if new_invite_code === undefined}
<button
class="btn btn-soft btn-accent btn-m"
onclick={() => {
new_invite_code = uuidv4();
person.invite_code = new_invite_code;
onChange('invite_code', new_invite_code);
}}>{create_invite_code()}</button
>
{:else}
<p>
<strong>{invite_code()}:</strong>{person.invite_code}
</p>
{/if}
{/if}
</div>
</div>
</div>

View File

@@ -0,0 +1,382 @@
<script lang="ts">
import { fade } from 'svelte/transition';
import {
create,
create_person,
close,
born,
mothers_first_name,
mothers_last_name,
last_name,
first_name,
email,
biological_sex,
male,
female,
other,
intersex,
create_relationship_and_person,
child,
sibling,
parent,
spouse,
relation,
relation_type,
notes,
until,
optional_field,
from_time
} from '$lib/paraglide/messages';
import { onMount } from 'svelte';
import type { components } from '$lib/api/api.gen.js';
import { validatePersonRegistration, validateFamilyRelationship } from './validate_fields';
import type { Node, Edge } from '@xyflow/svelte';
let {
closeModal = () => {},
onCreation,
onOnlyPersonCreation = (person: components['schemas']['Person']) => {},
relationshipStartID
}: {
closeModal: () => void;
onCreation: (newNode: Node,newEdges: Edge[]) => void;
onOnlyPersonCreation: (person: components['schemas']['Person']) => void | undefined;
relationshipStartID: number | null;
} = $props();
let birth_date: HTMLInputElement;
let relationship_from_time: HTMLInputElement = $state({} as HTMLInputElement);
let relationship_until: HTMLInputElement = $state({} as HTMLInputElement);
let draftRelationship: (components['schemas']['FamilyRelationship'] & { type: string }) | null =
$state({} as components['schemas']['FamilyRelationship'] & { type: string });
let draftPerson: components['schemas']['PersonRegistration'] = $state(
{} as components['schemas']['PersonRegistration']
);
let error: string | undefined | null = $state();
function onClose() {
closeModal();
}
async function onCreate(event: SubmitEvent) {
event.preventDefault();
error = validatePersonRegistration(draftPerson);
if (error) {
return;
}
if (relationshipStartID !== null) {
if (draftRelationship !== null) {
error = validateFamilyRelationship(draftRelationship);
if (error) {
return;
}
}
let requestBody = {
relationship: draftRelationship,
type: draftRelationship!.type,
person: draftPerson
} as {
person: components['schemas']['PersonRegistration'];
type?: 'child' | 'parent' | 'spouse' | 'sibling';
relationship: components['schemas']['FamilyRelationship'];
};
let response = await fetch(`/api/person_and_relationship/${relationshipStartID}`, {
method: 'POST',
body: JSON.stringify(requestBody),
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
error = 'Error creating person and relationship';
return;
}
let data = (await response.json()) as {
person?: components['schemas']['Person'];
relationships?: components['schemas']['dbtypeRelationship'][];
};
if (onCreation !== undefined) {
let edges: Array<Edge> = [];
data.relationships?.map((relationship) =>
edges.push({
id: "person"+String(relationship.Id),
source: "person"+String(relationship.StartElementId),
target: "person"+String(relationship.EndElementId),
data: {
...relationship.Props,
type: relationship.Type
}
})
);
let newNode = {
id: "person"+String(data.person?.Id),
data: {
...data.person?.Props,
id: data.person?.Id,
},
position: { x: 0, y: 0 },
type: 'personNode'
} as Node;
onCreation(newNode, edges);
}
} else {
let requestBody = draftPerson as components['schemas']['PersonRegistration'];
let response = await fetch(`/api/person`, {
method: 'POST',
body: JSON.stringify(requestBody),
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
error = 'Error creating person';
return;
}
if (onOnlyPersonCreation !== undefined) {
onOnlyPersonCreation(await response.json());
}
}
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().split('T')[0];
draftPerson.born = date.toISOString().split('T')[0];
}
});
// Clean up when component unmounts
return () => picker.destroy();
});
}
if (relationship_from_time) {
import('pikaday').then(({ default: Pikaday }) => {
const picker = new Pikaday({
format: 'YYYY-MM-DD',
minDate: new Date(1900, 0, 1),
field: relationship_from_time,
onOpen: function () {
relationship_from_time.placeholder = '';
},
onSelect: function (date) {
relationship_from_time.value = date.toISOString().split('T')[0];
draftRelationship.from = date.toISOString().split('T')[0];
}
});
// Clean up when component unmounts
return () => picker.destroy();
});
}
if (relationship_until) {
import('pikaday').then(({ default: Pikaday }) => {
const picker = new Pikaday({
format: 'YYYY-MM-DD',
minDate: new Date(1900, 0, 1),
field: relationship_until,
onOpen: function () {
relationship_until.placeholder = '';
},
onSelect: function (date) {
relationship_until.value = date.toISOString().split('T')[0];
draftRelationship.to = date.toISOString().split('T')[0];
}
});
// Clean up when component unmounts
return () => picker.destroy();
});
}
});
</script>
<div class="modal modal-open max-h-screen" transition:fade>
<div class="modal-box flex w-full max-w-5xl flex-col items-center justify-center overflow-y-auto">
<div class="flex w-full max-w-5xl items-center justify-between p-2">
<h3 class="text-left text-lg font-bold">{relationshipStartID !== null?create_relationship_and_person():create_person()}</h3>
<div>
<button class="btn btn-error btn-sm" onclick={onClose}>
{close()}
</button>
</div>
</div>
<div class="divider"></div>
<form onsubmit={onCreate} class="w-full">
<fieldset
class="fieldset grid w-full grid-cols-1 items-center gap-y-4 md:grid-cols-2 md:gap-x-6"
>
{#if error}
<div role="alert" class="alert alert-error col-span-full">
<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 relationshipStartID !== null}
<input type="hidden" name="relationshipStartID" value={relationshipStartID} />
<div class="flex flex-col">
<label class="label" for="relationship_type">{relation_type()}</label>
<select
name="relationship_type"
class="select select-bordered"
id="relationship_type"
bind:value={draftRelationship.type}
>
<option value="child">{child()}</option>
<option value="parent">{parent()}</option>
<option value="sibling">{sibling()}</option>
<option value="spouse">{spouse()}</option>
</select>
</div>
<div class="flex flex-col">
<label class="label" for="relationship_notes"
>{relation() + ' ' + notes().toLowerCase()}:</label
>
<textarea
name="relationship_notes"
class="textarea"
bind:value={draftRelationship.notes}
placeholder={notes().toLowerCase() + ' ' + optional_field().toLowerCase()}
></textarea>
</div>
<div class="flex flex-col">
<label class="label" for="relationship_from_time">{from_time()}</label>
<input
type="text"
name="relationship_from_time"
id="relationship_from_time"
class="input input-bordered validator pika-single"
placeholder={optional_field()}
bind:this={relationship_from_time}
/>
</div>
<div class="flex flex-col">
<label class="label" for="relationship_until">{until()}</label>
<input
type="text"
name="relationship_until"
id="relationship_until"
class="input input-bordered validator pika-single"
placeholder={optional_field()}
bind:this={relationship_until}
/>
</div>
<div class="divider margin-t-2 col-span-full"></div>
{/if}
<!-- Inputs -->
<div class="flex flex-col">
<label class="label" for="first_name">{first_name()}</label>
<input
type="text"
name="first_name"
class="input input-bordered"
placeholder={first_name()}
bind:value={draftPerson.first_name}
/>
</div>
<div class="flex flex-col">
<label class="label" for="last_name">{last_name()}</label>
<input
type="text"
name="last_name"
class="input input-bordered"
placeholder={last_name()}
bind:value={draftPerson.last_name}
/>
</div>
<div class="flex flex-col">
<label class="label" for="email">{email()}</label>
<input
type="email"
name="email"
class="input input-bordered validator"
placeholder={email() + ' ' + optional_field().toLowerCase()}
bind:value={draftPerson.email}
/>
</div>
<div class="flex flex-col">
<label class="label" for="birth_date">{born()}</label>
<input
type="text"
name="birth_date"
class="input input-bordered validator pika-single"
placeholder={born()}
bind:this={birth_date}
/>
</div>
<div class="flex flex-col">
<label class="label" for="biological_sex">{biological_sex()}</label>
<select
name="biological_sex"
class="select select-bordered"
id="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>
</div>
<div class="flex flex-col">
<label class="label" for="mothers_last_name">{mothers_last_name()}</label>
<input
type="text"
name="mothers_last_name"
class="input input-bordered"
placeholder={mothers_last_name()}
bind:value={draftPerson.mothers_last_name}
/>
</div>
<div class="flex flex-col">
<label class="label" for="mothers_first_name">{mothers_first_name()}</label>
<input
type="text"
name="mothers_first_name"
class="input input-bordered"
placeholder={mothers_first_name()}
bind:value={draftPerson.mothers_first_name}
/>
</div>
<!-- Submit button spans full width -->
<div class="col-span-full mt-4 flex justify-center">
<button type="submit" class="btn btn-neutral mt-4">{create()}</button>
</div>
</fieldset>
</form>
</div>
</div>

View File

@@ -0,0 +1,79 @@
import type { components } from '$lib/api/api.gen.js';
import { first_name, last_name, missing_field, mothers_first_name } from '$lib/paraglide/messages';
export function validatePersonRegistration(
data: components['schemas']['PersonRegistration']
): string | null {
if (!data.first_name || data.first_name.trim() === '') {
return missing_field({
field: first_name()
});
}
if (!data.last_name || data.last_name.trim() === '') {
return missing_field({
field: last_name()
});
}
if (
data.email !== undefined &&
data.email !== null &&
!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email)
) {
return 'Invalid email format.';
}
if (!data.born || !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 missing_field({
field: mothers_first_name()
});
}
if (!data.mothers_last_name || data.mothers_last_name.trim() === '') {
return missing_field({
field: "Mother's last name"
});
}
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

@@ -0,0 +1,40 @@
<script lang="ts">
import type { components } from '$lib/api/api.gen';
export let key: keyof components['schemas']['PersonProperties'];
export let value: any;
export let editorMode = false;
export let onChange: (field: keyof components['schemas']['PersonProperties'], value: any) => void;
let numberField: HTMLInputElement;
let textField: HTMLTextAreaElement;
let checkboxField: HTMLInputElement;
</script>
{#if editorMode}
{#if typeof value === 'boolean'}
<input
type="checkbox"
class="toggle toggle-primary"
checked={value}
bind:this={checkboxField}
oninput={() => onChange(key, checkboxField.value === 'true')}
/>
{:else if typeof value === 'number'}
<input
type="number"
class="input input-bordered input-sm w-full"
{value}
bind:this={numberField}
oninput={() => onChange(key, Number(numberField.value))}
/>
{:else}
<textarea
class="textarea textarea-bordered textarea-sm w-full"
{value}
oninput={() => onChange(key, textField.value)}
bind:this={textField}
></textarea>
{/if}
{:else}
<p class="text-sm text-gray-700">{value ?? '-'}</p>
{/if}

View File

@@ -0,0 +1,101 @@
<script lang="ts">
import { date, description, file, media_title, title, upload } from '$lib/paraglide/messages';
export let closeModal: () => void;
export let onCreation: (newMedia: {
url: string;
name: string;
description: string;
date: string;
}) => void = () => {};
export let mediaType: 'audio' | 'video' | 'photo' = 'photo';
let selectedFile: File | null = null;
let newMedia = {
url: '',
name: '',
description: '',
date: ''
};
// Determine accepted input types based on mediaType
$: acceptTypes =
mediaType === 'audio' ? 'audio/*' : mediaType === 'video' ? 'video/*' : 'image/*';
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
selectedFile = input.files[0];
}
}
async function uploadMedia() {
if (!selectedFile) {
alert('Please select a file');
return;
}
// Simulate file upload (replace with actual upload logic)
newMedia.url = URL.createObjectURL(selectedFile);
// Emit event using custom dispatch
const uploadEvent = new CustomEvent('upload', {
detail: { ...newMedia }
});
dispatchEvent(uploadEvent);
// Clean up
selectedFile = null;
newMedia = { url: '', name: '', description: '', date: '' };
onCreation(newMedia);
closeModal();
}
</script>
<div class="modal modal-open z-8">
<div class="modal-box w-full max-w-xl">
<h3 class="text-lg font-bold">{upload() + mediaType}</h3>
<div class="form-control mt-4">
<label for="mfile" class="label">{upload() + ' ' + file()}</label>
<input
id="mfile"
type="file"
accept={acceptTypes}
class="file-input file-input-bordered w-full"
on:change={handleFileChange}
/>
</div>
<div class="form-control mt-4">
<label for="mtitle" class="label">{media_title()}</label>
<input id="mtitle" bind:value={newMedia.name} class="input input-bordered w-full" />
</div>
<div class="form-control mt-4">
<label for="mdesc" class="label">{description()}</label>
<textarea
id="mdesc"
bind:value={newMedia.description}
class="textarea textarea-bordered w-full"
>
</textarea>
</div>
<div class="form-control mt-4">
<label for="mdate" class="label">{date()}</label>
<input
id="mdate"
type="date"
bind:value={newMedia.date}
class="input input-bordered w-full"
/>
</div>
<div class="modal-action">
<button class="btn btn-outline" on:click={closeModal}>Cancel</button>
<button class="btn btn-primary" on:click={uploadMedia}>Upload</button>
</div>
</div>
</div>

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

@@ -0,0 +1,292 @@
<script lang="ts">
import {
child,
from_time,
id,
notes,
parent,
relation,
relation_type,
verified,
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';
let {
closeModal,
onCreation = (newEdges: Edge[]) => {},
editorMode = false,
createRelationship = false,
startNode = undefined,
endNode = undefined
} = $props<{
closeModal: () => void;
onCreation?: (newEdges: Edge[]) => void;
editorMode?: boolean;
createRelationship?: boolean;
startNode?: string;
endNode?: string;
}>();
let relationships: components['schemas']['dbtypeRelationship'][] = $state([]);
let newRelationship: components['schemas']['FamilyRelationship'] = $state({
verified: false,
notes: '',
from: '',
to: ''
});
let relationshiptype: 'sibling' | 'child' | 'parent' | 'spouse' | undefined = $state('sibling');
async function getRelationships(startId: string, endId: string) {
if (
startId === undefined ||
endId === undefined ||
startId === '' ||
endId === '' ||
startId === endId
) {
return;
}
const response = await fetch(`/api/relationship/${startId}/${endId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
console.log('Cannot get relationships, status: ' + response.status);
return;
}
relationships.push((await response.json()) as components['schemas']['dbtypeRelationship']);
}
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.debug('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}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patchBody)
});
if (!response.ok) {
console.error(`Failed to save relationship ${r.StartId}${r.EndId}`);
}
if (response.status === 200) {
console.debug(`Relationship ${r.StartId}${r.EndId} saved successfully`);
} else {
console.error(`Failed to save relationship ${r.StartId}${r.EndId}`);
}
}
editorMode = !editorMode;
}
async function createNewRelationship() {
if (relationships.length > 0) {
alert('Relationship already exists');
createRelationship = false;
return;
}
if (!startNode || !endNode) {
alert('Please select nodes');
return;
}
let body: operations['createRelationship']['requestBody']['content']['application/json'] = {
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' },
body: JSON.stringify(body)
});
if (!response.ok) {
console.error('Cannot create relationship' + ', status: ' + response.status + (await response.json()));
return;
}
const created = (await response.json()) as components['schemas']['dbtypeRelationship'][];
console.debug('Relationship created successfully',created);
relationships.push(...created);
let newEdges: Edge[] = [];
for (const r of created) {
newEdges.push({
id: r.ElementId!,
source: r.StartElementId!,
target: r.EndElementId!,
type: 'relationship',
data: { ...r.Props, type: r.Type }
});
}
onCreation(newEdges);
closeModal();
}
</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-7">
<ModalButtons
{editorMode}
createMode={createRelationship}
onCreate={createNewRelationship}
onClose={closeModal}
onSave={save}
onToggleEdit={() => {
editorMode = !editorMode;
}}
/>
<div class="divider"></div>
</div>
{#if createRelationship}
<!-- Relationship type selector -->
<div class="form-control mt-4">
<label for="relationshiptype" class="label">{relation_type()}</label>
<select id="relationshiptype" bind:value={relationshiptype} 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>
</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">
<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:checked={relationships[index].Props!.verified}
class="checkbox"
/>
{:else}
<p><strong>{verified()}:</strong>{r.Props?.verified}</p>
{/if}
</div>
<div class="form-control mt-2">
{#if editorMode}
<label for={`notes-${index}`} class="label">{notes()}</label>
<textarea
id={`notes-${index}`}
bind:value={relationships[index].Props!.notes}
class="textarea textarea-bordered w-full"
></textarea>
{:else}
<p><strong>{notes()}:</strong> {relationships[index].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={relationships[index].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={relationships[index].Props!.to}
class="input input-bordered w-full"
/>
{:else}
<p><strong>{until()}:</strong> {r.Props?.to}</p>
{/if}
</div>
</div>
{/each}
{:else}
<!-- Creator mode: only one relationship -->
<div class="border-base-300 mt-4 rounded border p-4">
<div class="form-control">
<label for="verified" class="label">Verified</label>
<input
id="verified"
type="checkbox"
bind:checked={newRelationship.verified}
class="checkbox"
/>
</div>
<div class="form-control mt-2">
<label for="notes" class="label">{notes()}</label>
<textarea
id="notes"
bind:value={newRelationship.notes}
class="textarea textarea-bordered w-full"
></textarea>
</div>
<div class="form-control mt-2">
<label for="from" class="label">{from_time()}</label>
<input
id="from"
type="date"
bind:value={newRelationship.from}
class="input input-bordered w-full"
/>
</div>
<div class="form-control mt-2">
<label for="to" class="label">{until()}</label>
<input
id="to"
type="date"
bind:value={newRelationship.to}
class="input input-bordered w-full"
/>
</div>
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import {
add_relationship,
back,
close,
edit,
relation,
save
} from '$lib/paraglide/messages';
export let editorMode = false;
export let createMode = false;
export let onClose: () => void;
export let onToggleEdit: () => void;
export let onSave: () => void;
export let onCreate: () => void;
</script>
<div class="flex items-center justify-between p-2">
<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 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()}
</button>
</div>
</div>

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

@@ -0,0 +1,8 @@
import { Google } from 'arctic';
import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, GOOGLE_CALLBACK_URI } from '$env/static/private';
export const google = new Google(
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
GOOGLE_CALLBACK_URI || 'http://localhost:5173/login/google/callback'
);

View File

@@ -0,0 +1,89 @@
import type { KVNamespace } from '@cloudflare/workers-types';
import { encodeBase32, encodeHexLowerCase } from '@oslojs/encoding';
import { sha256 } from '@oslojs/crypto/sha2';
import type { RequestEvent } from '@sveltejs/kit';
// in seconds
const EXPIRATION_TTL: number = 60 * 60 * 24 * 7;
export async function validateSessionToken(
token: string,
sessions: KVNamespace
): Promise<SessionValidationResult> {
const session: Session | null = await sessions.get(token, { type: 'json' });
if (!session) {
return null;
}
if (Date.now() >= session.expiresAt - 1000 * 60 * 60 * 24 * 15) {
await sessions.put(token, JSON.stringify(session), { expirationTtl: EXPIRATION_TTL });
}
return session;
}
export async function invalidateSession(sessionId: string, sessions: KVNamespace): Promise<void> {
await sessions.delete(sessionId);
}
export async function invalidateUserSessions(userId: number, sessions: KVNamespace): Promise<void> {
const keys = await sessions.list({ prefix: `${userId}:` });
for (const key of keys.keys) {
await sessions.delete(key.name);
}
}
export function setSessionTokenCookie(
event: RequestEvent,
token: string,
expiresAt: EpochTimeStamp
): void {
event.cookies.set('session', token, {
httpOnly: true,
path: '/',
secure: import.meta.env.PROD,
sameSite: 'lax',
expires: new Date(expiresAt)
});
}
export function deleteSessionTokenCookie(event: RequestEvent): void {
event.cookies.set('session', '', {
httpOnly: true,
path: '/',
secure: import.meta.env.PROD,
sameSite: 'lax',
maxAge: 0
});
}
export function generateSessionToken(userId: string): string {
const tokenBytes = new Uint8Array(20);
crypto.getRandomValues(tokenBytes);
const token = encodeBase32(tokenBytes).toLowerCase();
return `${userId}:${encodeHexLowerCase(sha256(new TextEncoder().encode(token)))}`;
}
export async function createSession(
token: string,
userId: number,
sessions: KVNamespace
): Promise<Session> {
const session: Session = {
id: token,
userId,
expiresAt: Date.now() + 1000 * EXPIRATION_TTL
};
await sessions.put(token, JSON.stringify(session), { expirationTtl: EXPIRATION_TTL });
return session;
}
export interface Session {
id: string;
expiresAt: EpochTimeStamp;
userId: number;
}
type SessionValidationResult = Session | null;

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import { managed_profiles } from '$lib/paraglide/messages';
let clicked = $state(false);
let {
open_admin_panel = () => {
console.log('admin panel opened');
}
}: { open_admin_panel: () => void } = $props();
</script>
<div class="dropdown">
<button
tabindex="0"
class={'btn btn-circle swap swap-rotate' + (clicked ? ' swap-active' : '')}
onclick={() => (clicked = !clicked)}
>
<input type="checkbox" />
<!-- hamburger icon -->
<svg
class="swap-off fill-current"
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 512 512"
>
<path d="M64,384H448V341.33H64Zm0-106.67H448V234.67H64ZM64,128v42.67H448V128Z" />
</svg>
<!-- close icon -->
<svg
class="swap-on fill-current"
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 512 512"
>
<polygon
points="400 145.49 366.51 112 256 222.51 145.49 112 112 145.49 222.51 256 112 366.51 145.49 400 256 289.49 366.51 400 400 366.51 289.49 256 400 145.49"
/>
</svg>
</button>
<ul class="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow">
<li>
<button
tabindex="0"
class="btn btn-primary"
aria-label="close sidebar"
onclick={open_admin_panel}
>
{managed_profiles()}
</button>
</li>
</ul>
</div>

View File

@@ -0,0 +1,13 @@
<script>
import { managed_profiles } from '$lib/paraglide/messages';
</script>
<div class="drawer-side">
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
<ul class="menu bg-base-200 text-base-content min-h-full w-80 gap-4 p-4 pt-16">
<!-- Sidebar content here -->
<li>
<button class="btn btn-primary" aria-label="close sidebar">{managed_profiles()}</button>
</li>
</ul>
</div>

View File

@@ -0,0 +1,41 @@
import { describe, it, expect, vi } from 'vitest';
import type { Mock } from 'vitest';
import { switchToLanguage } from './switchToLanguage';
import { i18n } from '$lib/i18n';
import { goto } from '$app/navigation';
vi.mock('$lib/i18n', () => ({
i18n: {
route: vi.fn().mockImplementation((translatedPath: string) => ''),
resolveRoute: vi.fn().mockImplementation((path: string, lang?: string) => '')
}
}));
vi.mock('$app/state', () => ({
page: {
url: {
pathname: '/current-path'
}
}
}));
vi.mock('$app/navigation', () => ({
goto: vi.fn()
}));
describe('switchToLanguage', () => {
it('should switch to the new language', () => {
const newLanguage = 'en';
const canonicalPath = '/canonical-path';
const localisedPath = '/en/canonical-path';
(i18n.route as Mock).mockReturnValue(canonicalPath);
(i18n.resolveRoute as Mock).mockReturnValue(localisedPath);
switchToLanguage(newLanguage);
expect(i18n.route).toHaveBeenCalledWith('/current-path');
expect(i18n.resolveRoute).toHaveBeenCalledWith(canonicalPath, newLanguage);
expect(goto).toHaveBeenCalledWith(localisedPath);
});
});

View File

@@ -0,0 +1,10 @@
import type { AvailableLanguageTag } from '$lib/paraglide/runtime';
import { i18n } from '$lib/i18n';
import { page } from '$app/state';
import { goto } from '$app/navigation';
export function switchToLanguage(newLanguage: AvailableLanguageTag) {
const canonicalPath = i18n.route(page.url.pathname);
const localisedPath = i18n.resolveRoute(canonicalPath, newLanguage);
goto(localisedPath);
}

View File

@@ -0,0 +1,19 @@
export function tailwindClassToPixels(className: string): number | null {
const remSize = getRemInPixels(); // <-- real rem size at runtime
const regex = /^(w|h)-(\d+)$/;
const match = className.match(regex);
if (!match) return null;
const value = parseInt(match[2], 10);
return (value / 4) * remSize;
}
export function getRemInPixels(): number {
try {
const fontSize = getComputedStyle(document.documentElement).fontSize;
return parseFloat(fontSize);
} catch (e) {
return 16; // Default to 16px if unable to get computed style
}
}

View File

@@ -0,0 +1 @@
export const themes = ['light', 'dark', 'coffee', 'cyberpunk', 'synthwave', 'retro', 'dracula'];

View File

@@ -0,0 +1,17 @@
<script lang="ts">
import '../app.css';
import { i18n } from '$lib/i18n';
import { ParaglideJS } from '@inlang/paraglide-sveltekit';
let { children } = $props();
import ThemeButton from '$lib/ThemeSelect.svelte';
import Logout from '$lib/Logout.svelte';
import { page } from '$app/state';
</script>
<ParaglideJS {i18n}>
{@render children()}
<div class="absolute top-2 right-2 flex flex-row items-center gap-2">
<ThemeButton />
<Logout show={!page.url.pathname.includes('login')} />
</div>
</ParaglideJS>

View File

@@ -0,0 +1,31 @@
import { redirect } from '@sveltejs/kit';
import { parseFamilyTree } from '$lib/graph/parse_family_tree';
import type { components } from '$lib/api/api.gen';
import type { RequestEvent } from './$types';
import { browser } from '$app/environment';
import type { Layout } from '$lib/graph/model';
export async function load(event: RequestEvent) {
if (event.locals.session === null /*|| event.locals.familytree === nul*/) {
return redirect(302, '/login');
}
//prevent loading in developer mode, due to some issues with universal load, even if this is a server only ts,it will still run on client in dev mode idk
if (browser) {
return {};
}
const response = await event.fetch('/api/family_tree?with_out_spouse=false', {
method: 'GET'
});
if (response.status !== 200) {
console.error(await response.text());
}
const data = (await response.json()) as components['schemas']['FamilyTree'];
const layout = parseFamilyTree(data) as Layout & { id: string };
layout.id = event.locals.session.userId;
return layout;
}

View File

@@ -0,0 +1,382 @@
<script lang="ts">
import CreateRelationship from '$lib/relationship/Modal.svelte';
import { onMount } from 'svelte';
import { nodeTypes, edgeTypes } from '$lib/graph/model';
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';
import type { OnConnectEnd, Node, Edge, NodeEventWithPointer } from '@xyflow/svelte';
import PersonModal from '$lib/profile/Modal.svelte';
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';
import { FamilyTree } from '$lib/graph/layout';
import { tailwindClassToPixels } from '$lib/tailwindSizeToPx';
import type { Layout } from '$lib/graph/model';
import HamburgerIcon from '$lib/sidebar/hamburgerIcon.svelte';
let { data }: { data: Layout & { id: string } } = $props();
let selectedPerson: components['schemas']['PersonProperties'] & { id: string | undefined } =
$state({
id: undefined
});
let selectedRelationship: Edge | undefined = $state(undefined);
let openPersonPanel = $state(false);
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(
data.Nodes,
data.Edges,
tailwindClassToPixels('w-40') || 160,
tailwindClassToPixels('h-40') || 160,
'TB'
);
let nodes = $state.raw<Node[]>([] as Node[]);
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();
let clientHeight: number | undefined = $state();
let removePersonFromGraph = (id: any) => {
nodes = nodes.filter((n) => n.data.id !== id);
edges = edges.filter((e) => e.source !== 'person' + id && e.target !== 'person' + id);
};
let delete_profile = (id: any) => {
fetch('/api/person/' + id, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
})
.then((response) => {
if (response.ok) {
removePersonFromGraph(id);
} else {
alert('Error deleting person');
}
})
.catch((error) => {
console.error('Error:', error);
});
};
const handleContextMenu: NodeEventWithPointer<MouseEvent> = ({ event, node }) => {
event.preventDefault();
if (clientHeight === undefined || clientWidth === undefined) {
clientHeight = window.innerHeight;
clientWidth = window.innerWidth;
}
if (openPersonMenu !== undefined) {
openPersonMenu.onClick();
}
openPersonMenu = {
XUserId: data.id,
onClick: () => {
openPersonMenu = undefined;
},
deleteNode: () => {
if (Number(data.id) === Number(node.data.id)) {
relationshipStart = null;
openPersonMenu = undefined;
return;
}
delete_profile(node.data.id);
openPersonMenu = undefined;
},
createRelationshipAndNode: () => {
relationshipStart = Number(node.data.id);
createPerson = true;
openPersonMenu = undefined;
},
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: () => {
relationshipStart = Number(node.data.id);
openPersonMenu = undefined;
},
addRecipe: () => {
relationshipStart = Number(node.data.id);
openPersonMenu = undefined;
},
id: String(node.data.id),
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
};
};
function onCreation(newNodes: Array<Node> | null, newEdges: Array<Edge> | null): void {
if (newNodes !== null) {
nodes = [...nodes, ...newNodes];
}
if (newEdges !== null) {
edges = [...edges, ...newEdges];
}
let newLayout = familyTreeDAG.getLayoutedElements(
nodes,
edges,
tailwindClassToPixels('w-40') || 160,
tailwindClassToPixels('h-40') || 160,
'TB'
);
edges = [...newLayout.Edges];
nodes = [...newLayout.Nodes];
}
let handleNodeClickFunc = handleNodeClick(
(
person: components['schemas']['PersonProperties'] & {
id: number | undefined;
}
) => {
selectedPerson = { ...person, id: String(person.id) };
openPersonPanel = true;
fetch('/api/person/' + person.id, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
.then((response) => {
if (response.ok) {
return response.json() as Promise<components['schemas']['Person']>;
} else {
alert('Error fetching person data');
return null;
}
})
.then((data) => {
if (data) {
selectedPerson = data.Props as components['schemas']['PersonProperties'] & {
id: string | undefined;
};
selectedPerson.id = String(person.id);
}
console.debug('Fetched person data:', data);
});
}
);
let handlePaneClick = ({ event }: { event: MouseEvent }) => {
openPersonPanel = false;
openPersonMenu = undefined;
};
const handleConnectEnd: OnConnectEnd = (event, connectionState) => {
event.preventDefault();
const sourceNodeId = connectionState.fromNode?.data.id;
if (sourceNodeId === undefined) return;
relationshipStart = Number(sourceNodeId);
if (connectionState.isValid) {
createRelationship = true;
selectedRelationship = {
id: 'relationship' + connectionState.toNode?.data.id,
source: String(relationshipStart),
target: String(connectionState.toNode?.data.id)
};
return;
}
createPerson = true;
};
onMount(() => {
nodes = [...layout.Nodes];
edges = [...layout.Edges];
});
</script>
<svelte:head>
<title>{title({ page: family_tree() })}</title>
</svelte:head>
<div style="height:100vh;" class="!bg-base-200 flex flex-col">
<SvelteFlowProvider>
<SvelteFlow
bind:nodes
bind:edges
onconnectend={handleConnectEnd}
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={true}
>
<MiniMap class="!bg-base-300" />
<Controls class="!bg-base-300" />
{#if openPersonPanel}
<PersonModal
person={selectedPerson}
closeModal={() => {
openPersonPanel = false;
}}
/>
{/if}
{#if createPerson}
<CreatePerson
onOnlyPersonCreation={() => {
createPerson = false;
}}
onCreation={(node, edges) => {
onCreation([node], edges);
createPerson = false;
}}
closeModal={() => {
createPerson = false;
}}
relationshipStartID={relationshipStart}
></CreatePerson>
{/if}
{#if selectedRelationship}
<CreateRelationship
{createRelationship}
onCreation={(newEdges: Array<Edge>) => {
onCreation(null, newEdges);
createRelationship = false;
}}
closeModal={() => {
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(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) => {
selectedPerson = { id: String(id) };
fetch('/api/person/' + id, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
.then((response) => {
if (response.ok) {
return response.json() as Promise<components['schemas']['Person']>;
} else {
alert('Error fetching person data');
return null;
}
})
.then((data) => {
if (data) {
selectedPerson = data.Props as components['schemas']['PersonProperties'] & {
id: string | undefined;
};
selectedPerson.id = String(id);
openPersonPanel = true;
} else {
alert('Error fetching person data');
}
});
}}
removePersonFromGraph={removePersonFromGraph}
/>
{/if}
</SvelteFlow>
</SvelteFlowProvider>
</div>
<div class="absolute left-2 top-2 flex flex-row items-center gap-2">
<HamburgerIcon
open_admin_panel={() => {
adminMenu = !adminMenu;
}}
/>
</div>

View File

@@ -0,0 +1,26 @@
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('/admin/{id1}', {
params: {
path: { id1: Number(event.params.ID1) },
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

@@ -0,0 +1,72 @@
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('/admin/{id1}/{id2}', {
params: {
path: { id1: Number(event.params.ID1), id2: Number(event.params.ID2) },
header: { 'X-User-ID': event.locals.session.userId }
}
});
if (response.response.ok) {
return new Response(JSON.stringify(response.data), {
status: response.response.status
});
} else {
return new Response(JSON.stringify(response.error), {
status: response.response.status
});
}
}
export async function POST(event: RequestEvent): Promise<Response> {
if (event.locals.session === null) {
return redirect(302, '/login');
}
const response = await client.POST('/admin/{id1}/{id2}', {
params: {
path: { id1: Number(event.params.ID1), id2: Number(event.params.ID2) },
header: { 'X-User-ID': event.locals.session.userId }
}
});
if (response.response.ok) {
return new Response(JSON.stringify(response.data), {
status: response.response.status
});
} else {
return new Response(JSON.stringify(response.error), {
status: response.response.status
});
}
}
export async function DELETE(event: RequestEvent): Promise<Response> {
if (event.locals.session === null) {
return redirect(302, '/login');
}
const response = await client.DELETE('/admin/{id1}/{id2}', {
params: {
path: { id1: Number(event.params.ID1), id2: Number(event.params.ID2) },
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

@@ -0,0 +1,99 @@
import { redirect } from '@sveltejs/kit';
import { client } from '$lib/api/client';
import type { RequestEvent } from './$types';
import type { components } from '$lib/api/api.gen';
export async function POST(event: RequestEvent): Promise<Response> {
if (event.locals.session === null) {
return redirect(302, '/login');
}
let message = (await event.request.json()) as components['schemas']['Message'];
message.edited = null;
message.sent_at = new Date(Date.now()).toISOString();
const response = await client.POST('/comment/{id}', {
params: {
path: { id: Number(event.params.ID) },
header: { 'X-User-ID': event.locals.session.userId }
},
body: message
});
return new Response(await response.response.json(), {
status: response.response.status
});
}
export async function GET(event: RequestEvent): Promise<Response> {
if (event.locals.session === null) {
return redirect(302, '/login');
}
const response = await client.GET('/comment/{id}', {
params: {
path: { id: Number(event.params.ID) },
header: { 'X-User-ID': event.locals.session.userId }
}
});
if (response.response.ok) {
return new Response(JSON.stringify(response.data), {
status: response.response.status
});
} else {
return new Response(JSON.stringify(response.error), {
status: response.response.status
});
}
}
export async function DELETE(event: RequestEvent): Promise<Response> {
if (event.locals.session === null) {
return redirect(302, '/login');
}
const response = await client.DELETE('/comment/{id}', {
params: {
path: { id: Number(event.params.ID) },
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
});
}
}
export async function PATCH(event: RequestEvent): Promise<Response> {
if (event.locals.session === null) {
return redirect(302, '/login');
}
let message = (await event.request.json()) as components['schemas']['Message'];
message.edited = new Date(Date.now()).toISOString();
const response = await client.PATCH('/comment/{id}', {
params: {
path: { id: Number(event.params.ID) },
header: { 'X-User-ID': event.locals.session.userId }
},
body: message
});
if (response.response.ok) {
return new Response(JSON.stringify(response.data), {
status: response.response.status
});
} else {
return new Response(JSON.stringify(response.error), {
status: response.response.status
});
}
}

View File

@@ -0,0 +1,57 @@
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';
export async function GET(event: RequestEvent): Promise<Response> {
if (event.locals.session === null) {
return redirect(302, '/login');
}
const response = await client.GET(
event.url.searchParams.get('with_out_spouse') === 'false'
? '/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 graphToReturn: components['schemas']['FamilyTree'] = {
people: [],
relationships: response.data.relationships
};
for (const person of response.data.people) {
let newPerson = person;
if (newPerson.profile_picture !== null && newPerson.profile_picture !== undefined) {
}
if (graphToReturn.people !== undefined) {
graphToReturn.people.push(newPerson);
}
}
return new Response(JSON.stringify(graphToReturn), {
status: 200
});
}

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(JSON.stringify(response.data), {
status: response.response.status
});
} else {
return new Response(JSON.stringify(response.error), {
status: response.response.status
});
}
}

View File

@@ -0,0 +1,27 @@
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';
export async function POST(event: RequestEvent): Promise<Response> {
if (event.locals.session === null) {
return redirect(302, '/login');
}
const response = await client.POST('/person', {
params: {
header: { 'X-User-ID': event.locals.session.userId }
},
body: (await event.request.json()) as components['schemas']['PersonRegistration']
});
if (response.response.ok) {
return new Response(JSON.stringify(response.data), {
status: response.response.status
});
} else {
return new Response(JSON.stringify(response.error), {
status: response.response.status
});
}
}

View File

@@ -0,0 +1,74 @@
import { redirect } from '@sveltejs/kit';
import { client } from '$lib/api/client';
import type { RequestEvent } from './$types';
import type { components } from '$lib/api/api.gen';
export async function GET(event: RequestEvent): Promise<Response> {
if (event.locals.session === null) {
return redirect(302, '/login');
}
const response = await client.GET('/person/{id}', {
params: {
path: { id: Number(event.params.ID) },
header: { 'X-User-ID': event.locals.session.userId }
}
});
if (response.response.ok) {
return new Response(JSON.stringify(response.data), {
status: response.response.status
});
} else {
return new Response(JSON.stringify(response.error), {
status: response.response.status
});
}
}
export async function DELETE(event: RequestEvent): Promise<Response> {
if (event.locals.session === null) {
return redirect(302, '/login');
}
const response = await client.DELETE('/person/{id}', {
params: {
path: { id: Number(event.params.ID) },
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
});
}
}
export async function PATCH(event: RequestEvent): Promise<Response> {
if (event.locals.session === null) {
return redirect(302, '/login');
}
const response = await client.PATCH('/person/{id}', {
params: {
path: { id: Number(event.params.ID) },
header: { 'X-User-ID': event.locals.session.userId }
},
body: (await event.request.json()) as components['schemas']['PersonProperties']
});
if (response.response.ok) {
return new Response(JSON.stringify(response.data), {
status: response.response.status
});
} else {
return new Response(JSON.stringify(response.error), {
status: response.response.status
});
}
}

View File

@@ -0,0 +1,26 @@
import { error, redirect } from '@sveltejs/kit';
import { client } from '$lib/api/client';
import type { RequestEvent } from './$types';
export async function DELETE(event: RequestEvent): Promise<Response> {
if (event.locals.session === null) {
return redirect(302, '/login');
}
const response = await client.DELETE('/person/{id}/hard-delete', {
params: {
path: { id: Number(event.params.ID) },
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

@@ -0,0 +1,32 @@
import { redirect } from '@sveltejs/kit';
import { client } from '$lib/api/client';
import type { RequestEvent } from './$types';
import type { components } from '$lib/api/api.gen';
export async function POST(event: RequestEvent): Promise<Response> {
if (event.locals.session === null) {
return redirect(302, '/login');
}
const response = await client.POST('/person_and_relationship/{id}', {
params: {
path: { id: Number(event.params.ID) },
header: { 'X-User-ID': event.locals.session.userId }
},
body: (await event.request.json()) as {
person: components['schemas']['PersonRegistration'];
type?: 'child' | 'parent' | 'spouse' | 'sibling';
relationship: components['schemas']['FamilyRelationship'];
}
});
if (response.response.ok && response.response.status === 200) {
return new Response(JSON.stringify(response.data), {
status: response.response.status
});
} else {
return new Response(JSON.stringify(response.error), {
status: response.response.status
});
}
}

View File

@@ -0,0 +1,32 @@
import { redirect } from '@sveltejs/kit';
import { client } from '$lib/api/client';
import type { RequestEvent } from './$types';
import type { components } from '$lib/api/api.gen';
export async function POST(event: RequestEvent): Promise<Response> {
if (event.locals.session === null) {
return redirect(302, '/login');
}
const response = await client.POST('/relationship', {
params: {
header: { 'X-User-ID': event.locals.session.userId }
},
body: (await event.request.json()) as {
id1?: number;
id2?: number;
type?: 'child' | 'parent' | 'spouse' | 'sibling';
relationship?: components['schemas']['FamilyRelationship'];
}
});
if (response.response.ok) {
return new Response(JSON.stringify(response.data), {
status: response.response.status
});
} else {
return new Response(JSON.stringify(response.error), {
status: response.response.status
});
}
}

View File

@@ -0,0 +1,76 @@
import { redirect } from '@sveltejs/kit';
import { client } from '$lib/api/client';
import type { RequestEvent } from './$types';
import type { components } from '$lib/api/api.gen';
export async function GET(event: RequestEvent): Promise<Response> {
if (event.locals.session === null) {
return redirect(302, '/login');
}
const response = await client.GET('/relationship/{id1}/{id2}', {
params: {
path: { id1: Number(event.params.ID1), id2: Number(event.params.ID2) },
header: { 'X-User-ID': event.locals.session.userId }
}
});
if (response.response.ok) {
return new Response(JSON.stringify(response.data), {
status: response.response.status
});
} else {
return new Response(JSON.stringify(response.error), {
status: response.response.status
});
}
}
export async function PATCH(event: RequestEvent): Promise<Response> {
if (event.locals.session === null) {
return redirect(302, '/login');
}
const response = await client.PATCH('/relationship/{id1}/{id2}', {
params: {
path: { id1: Number(event.params.ID1), id2: Number(event.params.ID2) },
header: { 'X-User-ID': event.locals.session.userId }
},
body: {
relationship: (await event.request.json()) as components['schemas']['FamilyRelationship']
}
});
if (response.response.ok) {
return new Response(JSON.stringify(response.data), {
status: response.response.status
});
} else {
return new Response(JSON.stringify(response.error), {
status: response.response.status
});
}
}
export async function DELETE(event: RequestEvent): Promise<Response> {
if (event.locals.session === null) {
return redirect(302, '/login');
}
const response = await client.DELETE('/relationship/{id1}/{id2}', {
params: {
path: { id1: Number(event.params.ID1), id2: Number(event.params.ID2) },
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
});
}
}

Some files were not shown because too many files have changed in this diff Show More