mirror of
https://github.com/vcscsvcscs/GenerationsHeritage.git
synced 2025-08-12 13:59:08 +02:00
Compare commits
405 Commits
feature/cr
...
f801e8893a
Author | SHA1 | Date | |
---|---|---|---|
f801e8893a | |||
82dc8d8e08 | |||
57ac9c068a | |||
a77f0f434e | |||
3ed3c037ab | |||
46a612e31d | |||
8358a38f4d | |||
d393959c0d | |||
4f67a973a2 | |||
40e557f8c7 | |||
7397ba0ccc | |||
58bb20e608 | |||
|
c706785e51 | ||
f396ad612e | |||
6343065c4d | |||
f1e26f467d | |||
a8222892f7 | |||
3a84989a9e | |||
d3fddf34d9 | |||
f2250134aa | |||
6ab6dbd5bc | |||
6d49d128ba | |||
59159512b1 | |||
66c49b4c9a | |||
4ab380f107 | |||
3b4677614c | |||
fe6ad219e0 | |||
28c11dc1d1 | |||
4e682d7003 | |||
aa8004ebf0 | |||
c9e308d578 | |||
7f32896db0 | |||
37c80d523e | |||
6062f7616d | |||
aadb4098cc | |||
9b3930dcb6 | |||
473d624973 | |||
5cdaa03636 | |||
37d54f3873 | |||
9814c76643 | |||
56665ccc19 | |||
f42b327566 | |||
6de063a06d | |||
9c08b39800 | |||
bd7d28ce0e | |||
68c73ca859 | |||
69c43d6d79 | |||
466ea365de | |||
3ffc12012f | |||
|
5b2fd594f2 | ||
|
9802513d8e | ||
|
e5061d6b4f | ||
|
0d998f648c | ||
|
2cf59b8313 | ||
|
39431de08a | ||
|
ef4961bdb0 | ||
|
fd00471bbc | ||
|
d65de3d3c4 | ||
4a4117acb9 | |||
1df2af9179 | |||
cda8d63a3f | |||
8660e29ff9 | |||
b3b42bdabf | |||
9507e0e74a | |||
ba3d77f0cb | |||
c323e80b24 | |||
4c521f8009 | |||
a71ca26b2a | |||
37bc34651a | |||
3695eb084d | |||
d88138ecce | |||
d1ce977fbc | |||
f46712a445 | |||
6c8d4bde47 | |||
1b52a4acd7 | |||
2e8b049f7a | |||
79ce1dae04 | |||
8e51cc6e15 | |||
001732bee6 | |||
50971c8dde | |||
73d593450e | |||
5cb335774b | |||
dfb6f31a73 | |||
d19e6cd980 | |||
06eea4f302 | |||
55d4175dd1 | |||
d6b9159f1a | |||
9e02317ab1 | |||
6b4b9ce973 | |||
626f6da6d2 | |||
ea3faba056 | |||
cd2116622f | |||
c8d68c5cc7 | |||
b5342a19ca | |||
92c1d29ace | |||
23da2c2186 | |||
1471ed28f0 | |||
b307658c6c | |||
87020e0daf | |||
baed702980 | |||
58ef6ecbd7 | |||
07bd4d95a6 | |||
8194bc9dea | |||
a1b907024e | |||
cc9a863311 | |||
dce8873b63 | |||
c51ec07e51 | |||
dc45e5af00 | |||
65e06b25ff | |||
db6bc33e9d | |||
07a7f7fbb8 | |||
6fcc5eb646 | |||
719952dd18 | |||
81389a2dea | |||
8a763465d8 | |||
a3eef8cf3f | |||
c1ae4b8960 | |||
8472085dba | |||
ae7589703f | |||
ab461bb60e | |||
1887454328 | |||
56fe0f6f30 | |||
443db56df4 | |||
de9c38032c | |||
cb45ef7848 | |||
2097f5ed20 | |||
68ca23821e | |||
9db46a759d | |||
29f8f539c8 | |||
b97d51a19c | |||
5593e36594 | |||
c8888b052e | |||
85a42d2658 | |||
120e7a9862 | |||
7ba69e3fce | |||
67a7cfd830 | |||
d054ec5c10 | |||
e40e306faa | |||
4a00cac333 | |||
ab1d156e04 | |||
08e60f4af3 | |||
7bf31c0975 | |||
31bc1c0bb9 | |||
48a5a7d6e0 | |||
d4acdd4d53 | |||
af77d899e6 | |||
be05f2d895 | |||
600f51ed1f | |||
4e9301a0e2 | |||
5ea3d91dab | |||
9fcbd3b79b | |||
8c50802224 | |||
2cb604951a | |||
59b31cb71a | |||
d3ed3f8b75 | |||
3a9a05103b | |||
47c5d868d0 | |||
d3e4202e06 | |||
f8f0694a1b | |||
f036bff6d6 | |||
6b41ce5f49 | |||
471ecb8956 | |||
5ea66fdd78 | |||
723c679ad0 | |||
2d3f8cfa34 | |||
391907792c | |||
84b9cdf9f6 | |||
fb2da46331 | |||
0445ef4dc1 | |||
71145858c5 | |||
40377416da | |||
bb792b41e2 | |||
c10a4a71f7 | |||
5385244f97 | |||
46077870c3 | |||
a56454110a | |||
03120d7242 | |||
166d00f0e6 | |||
|
2e9c91415d | ||
cd72097e02 | |||
2b78629fd0 | |||
db93735058 | |||
97737375be | |||
267285c356 | |||
d9d565e0ee | |||
8d27d31968 | |||
c716973525 | |||
62566b3ec5 | |||
bbf66ddc74 | |||
833c396b9b | |||
44de928ab6 | |||
75b2cb2dcc | |||
1c7f107c23 | |||
6ba03fc10d | |||
004c0f9c05 | |||
2008e8ba94 | |||
ced4e1b89c | |||
f860f25e56 | |||
34bacf8b93 | |||
ccb377e6f6 | |||
85cae6f503 | |||
f5e7292728 | |||
3242ad1c8c | |||
a6bf548c0c | |||
a70636df9e | |||
29ffebc33a | |||
7b5c22c10e | |||
086e21e447 | |||
61790ed96c | |||
33ca58194a | |||
bbbdedec9f | |||
5e1074571d | |||
61665cd198 | |||
57dcd3b3eb | |||
3e4d8c901d | |||
36482465a4 | |||
ef8f8a5118 | |||
a8c4aa9351 | |||
0161931cb4 | |||
1d0541b1bd | |||
436d20301b | |||
b433a38bc7 | |||
8bb54cea37 | |||
59b748aa6c | |||
1d12aac6ec | |||
c0e487b320 | |||
04e159d40e | |||
cd30fc72f5 | |||
9e0c4d47a0 | |||
5af2492ff2 | |||
d758b2cde2 | |||
5433d45269 | |||
efa7d06e0b | |||
413fe2bd85 | |||
63783dce67 | |||
|
47dca8ed3d | ||
42b88e14a7 | |||
bbddd08d24 | |||
fa4c886300 | |||
9da42c62cd | |||
d0f80818b0 | |||
677adc1e22 | |||
fa7086cd3c | |||
3bfc20cb21 | |||
ebd82475fd | |||
5658c10b56 | |||
3c601caf02 | |||
|
877557febe | ||
10bee29482 | |||
4db7a35271 | |||
069d9317a3 | |||
ab2fd071e0 | |||
0b1ca6338f | |||
c466587bb5 | |||
b292f39da0 | |||
8991c30dd2 | |||
7a26af537a | |||
1163222406 | |||
e8962e2915 | |||
517ef26598 | |||
f2d6bd1f8c | |||
321136ae4c | |||
741286f773 | |||
2c6b21fa77 | |||
4ac9a4d437 | |||
78305e7ca2 | |||
cb6809b2a4 | |||
416b4f0302 | |||
f484a271e2 | |||
8139673405 | |||
b13c1228af | |||
b2ef584057 | |||
a7ad330b27 | |||
4c8d74ae04 | |||
f461825234 | |||
bae91335fe | |||
c10a1857a3 | |||
6bdbee1271 | |||
8ec95c4de1 | |||
8176277fcb | |||
d0af8f0250 | |||
|
935b3ac5cf | ||
10c9ad393d | |||
9f21a36406 | |||
a28a6fa675 | |||
2f795d6901 | |||
|
5a958840b4 | ||
c1df052462 | |||
64b9361651 | |||
|
e5425b1cc8 | ||
5a7e62a183 | |||
5e871cb272 | |||
22ca38ad86 | |||
3ade387d7d | |||
77042ffdc5 | |||
5b9b6c53a6 | |||
0b0b138c16 | |||
304552c2a5 | |||
cf4b79c593 | |||
bc7cf7f1a6 | |||
e49aba7c58 | |||
01c6e4b0c9 | |||
564ef322e3 | |||
d85d37eb2d | |||
913042d441 | |||
56607b31e5 | |||
a5822913f6 | |||
5d03c51097 | |||
5e8cdecca7 | |||
40a70ecc93 | |||
12bb08d6ce | |||
eadfcd7afc | |||
5d19dad30f | |||
7358ef5db1 | |||
68bd7dec11 | |||
f10d8a87db | |||
72f81214be | |||
47b52d8a33 | |||
2e4cd879b2 | |||
ca67dead2b | |||
162fe47051 | |||
d49601b871 | |||
f5e95095c7 | |||
c54b142b70 | |||
65345e0e76 | |||
a34a934bf4 | |||
73627c7c59 | |||
33aa4945af | |||
3b12f4798c | |||
cb6628a83c | |||
a4c1bc56f8 | |||
79256f2f10 | |||
cdea69736d | |||
|
ffde94d457 | ||
423e5502a7 | |||
422e2f683a | |||
35f478e24c | |||
a6718b2487 | |||
|
317fc9067e | ||
4c1b5bcce4 | |||
|
30321ba5d7 | ||
f42d966183 | |||
94820faf8c | |||
8d1bb6dbfe | |||
150d3d7b33 | |||
d004a155d7 | |||
9996b79b46 | |||
504de2fc2e | |||
36b34a161f | |||
|
87b63e40ce | ||
3e40c09f18 | |||
ab007395bc | |||
a1e14f0a7b | |||
2ba61b0854 | |||
8bf6027e42 | |||
723c108ec4 | |||
2e718c06f6 | |||
26c227c062 | |||
2e720c326a | |||
608b0e3fb2 | |||
a88550f8cd | |||
b06cfcaf69 | |||
a78161d389 | |||
0aad11b1d6 | |||
4199d65d6c | |||
d533738a32 | |||
a0233fdc2d | |||
e89b1638d9 | |||
2492b94be8 | |||
518f86caa4 | |||
303c582217 | |||
cc9fbd42da | |||
16457111cd | |||
28395c2859 | |||
b7eaa44d26 | |||
ac514578f3 | |||
13f0eea33c | |||
91376d87de | |||
0226b93135 | |||
65f6200b81 | |||
6e7e767f54 | |||
30ae64e2f6 | |||
cda79c4428 | |||
db0b28ca79 | |||
befd578bf9 | |||
2e233abfd6 | |||
d3be181d77 | |||
a230886c40 | |||
4bc365c4a2 | |||
26b446ab54 | |||
a4a79a1136 | |||
4764be0c2e | |||
2cb55937c9 | |||
de64411eb0 | |||
1ee384813b | |||
bb7370ae33 | |||
3c47578809 | |||
23743a23b9 | |||
e2450247e7 | |||
3d9398e9c1 | |||
59b3b31b44 | |||
3a2dd1972a | |||
48e7e414dd | |||
494278875b | |||
d0915986a6 |
53
.github/workflows/cloudflare_cd.yml
vendored
Normal file
53
.github/workflows/cloudflare_cd.yml
vendored
Normal 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
40
.github/workflows/db_adapter_ci.yml
vendored
Normal 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'
|
54
.github/workflows/docker_build.yml
vendored
Normal file
54
.github/workflows/docker_build.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
name: Release to Docker Hub
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
service-name:
|
||||
required: true
|
||||
type: string
|
||||
working-directory:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
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(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({
|
||||
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}}:${{ inputs.working-directory }}"
|
||||
tags: vcscsvcscs/${{ inputs.service-name }}:${{steps.create_image_tag.outputs.result}}
|
||||
platforms: linux/amd64, linux/arm64
|
40
.github/workflows/go_test.yml
vendored
Normal file
40
.github/workflows/go_test.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Go Test
|
||||
|
||||
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.24.x'
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
cache-dependency-path: |
|
||||
${{ inputs.working-directory }}/go.sum
|
||||
go-version: '1.24.1'
|
||||
|
||||
- name: Display Go version
|
||||
run: go version
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd ${{ inputs.working-directory }}
|
||||
go get ./...
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd ${{ inputs.working-directory }}
|
||||
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
|
23
.github/workflows/svelte_ci.yml
vendored
Normal file
23
.github/workflows/svelte_ci.yml
vendored
Normal 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'
|
24
.github/workflows/svelte_lint.yml
vendored
Normal file
24
.github/workflows/svelte_lint.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
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: '22.x'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd ${{ inputs.working-directory }}
|
||||
npm ci
|
||||
- name: Lint
|
||||
run: |
|
||||
cd ${{ inputs.working-directory }}
|
||||
npm run lint
|
36
.github/workflows/svelte_test.yml
vendored
Normal file
36
.github/workflows/svelte_test.yml
vendored
Normal 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 }}
|
21
.vscode/launch.json
vendored
Normal file
21
.vscode/launch.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
@@ -23,3 +23,10 @@ The purpose of the thesis is to develop a web application that enables the commu
|
||||
Users could register and the system would provide the ability to edit their own profiles, which are part of the family tree structure. The family tree would contain not only names and birth dates but also additional information such as schools, residences, workplaces, life wisdom, important knowledge, and photos. Furthermore, the application would employ protective measures, so that only those related by blood could access each other's data.
|
||||
|
||||
The task is highly complex, as it involves not only designing the user interface and the family tree structure but also properly implementing security layers. The development of the database system, the cloud-based server, and the CI/CD system present further challenges. The application must ensure optimal display on various devices and screen sizes, which requires additional development and design skills. The thesis details the extent to which specific challenges and solutions related to the project contribute to the success and functionality of the software.
|
||||
|
||||
## Deployment
|
||||
To deploy all micro services use:
|
||||
|
||||
```bash:
|
||||
kubectl apply --server-side -k .
|
||||
```
|
3328
api/openapi.json
Normal file
3328
api/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
27
apps/app/.github/workflows/playwright.yml
vendored
Normal file
27
apps/app/.github/workflows/playwright.yml
vendored
Normal 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
36
apps/app/.gitignore
vendored
Normal 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
4
apps/app/.prettierignore
Normal file
@@ -0,0 +1,4 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
15
apps/app/.prettierrc
Normal file
15
apps/app/.prettierrc
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
20
apps/app/.storybook/main.ts
Normal file
20
apps/app/.storybook/main.ts
Normal 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;
|
16
apps/app/.storybook/preview.ts
Normal file
16
apps/app/.storybook/preview.ts
Normal 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
12
apps/app/.vscode/extensions.json
vendored
Normal 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
5
apps/app/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"files.associations": {
|
||||
"wrangler.json": "jsonc"
|
||||
}
|
||||
}
|
@@ -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.
|
18
apps/app/e2e/example.spec.ts
Normal file
18
apps/app/e2e/example.spec.ts
Normal 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
9
apps/app/env.example
Normal 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
34
apps/app/eslint.config.js
Normal 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
174
apps/app/messages/en.json
Normal 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
172
apps/app/messages/hu.json
Normal 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
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
73
apps/app/package.json
Normal 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"
|
||||
}
|
||||
}
|
79
apps/app/playwright.config.ts
Normal file
79
apps/app/playwright.config.ts
Normal 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,
|
||||
// },
|
||||
});
|
5
apps/app/postcss.config.js
Normal file
5
apps/app/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {}
|
||||
}
|
||||
};
|
1
apps/app/project.inlang/.gitignore
vendored
Normal file
1
apps/app/project.inlang/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
cache
|
1
apps/app/project.inlang/project_id
Normal file
1
apps/app/project.inlang/project_id
Normal file
@@ -0,0 +1 @@
|
||||
3e148103694315c86d552d141b1e0996d919bde0a260527d1d9f4af226be7582
|
17
apps/app/project.inlang/settings.json
Normal file
17
apps/app/project.inlang/settings.json
Normal 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
32
apps/app/src/app.css
Normal 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
20
apps/app/src/app.d.ts
vendored
Normal 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
14
apps/app/src/app.html
Normal 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>
|
52
apps/app/src/hooks.server.ts
Normal file
52
apps/app/src/hooks.server.ts
Normal 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
2
apps/app/src/hooks.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { i18n } from '$lib/i18n';
|
||||
export const reroute = i18n.reroute();
|
27
apps/app/src/lib/Logout.stories.ts
Normal file
27
apps/app/src/lib/Logout.stories.ts
Normal 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
|
||||
}
|
||||
};
|
8
apps/app/src/lib/Logout.svelte
Normal file
8
apps/app/src/lib/Logout.svelte
Normal 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}
|
54
apps/app/src/lib/ThemeSelect.svelte
Normal file
54
apps/app/src/lib/ThemeSelect.svelte
Normal 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>
|
174
apps/app/src/lib/admin/Modal.svelte
Normal file
174
apps/app/src/lib/admin/Modal.svelte
Normal 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>
|
28
apps/app/src/lib/admin/ModalButtons.svelte
Normal file
28
apps/app/src/lib/admin/ModalButtons.svelte
Normal 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>
|
2242
apps/app/src/lib/api/api.gen.ts
Normal file
2242
apps/app/src/lib/api/api.gen.ts
Normal file
File diff suppressed because it is too large
Load Diff
11
apps/app/src/lib/api/client.ts
Normal file
11
apps/app/src/lib/api/client.ts
Normal 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 || ''
|
||||
}
|
||||
});
|
20
apps/app/src/lib/cookiesAlert.svelte
Normal file
20
apps/app/src/lib/cookiesAlert.svelte
Normal 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>
|
98
apps/app/src/lib/graph/FamilyEdge.svelte
Normal file
98
apps/app/src/lib/graph/FamilyEdge.svelte
Normal 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} />
|
88
apps/app/src/lib/graph/PersonMenu.svelte
Normal file
88
apps/app/src/lib/graph/PersonMenu.svelte
Normal 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>
|
117
apps/app/src/lib/graph/PersonNode.svelte
Normal file
117
apps/app/src/lib/graph/PersonNode.svelte
Normal 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>
|
10
apps/app/src/lib/graph/connection.ts
Normal file
10
apps/app/src/lib/graph/connection.ts
Normal 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;
|
||||
}
|
140
apps/app/src/lib/graph/layout.ts
Normal file
140
apps/app/src/lib/graph/layout.ts
Normal 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 };
|
||||
}
|
||||
}
|
28
apps/app/src/lib/graph/model.ts
Normal file
28
apps/app/src/lib/graph/model.ts
Normal 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>;
|
||||
};
|
11
apps/app/src/lib/graph/node_click.ts
Normal file
11
apps/app/src/lib/graph/node_click.ts
Normal 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 });
|
||||
};
|
||||
}
|
43
apps/app/src/lib/graph/parse_family_tree.ts
Normal file
43
apps/app/src/lib/graph/parse_family_tree.ts
Normal 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
22
apps/app/src/lib/i18n.ts
Normal 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 '';
|
||||
}
|
||||
}
|
7
apps/app/src/lib/model.ts
Normal file
7
apps/app/src/lib/model.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface PersonProperties {
|
||||
google_id: string;
|
||||
first_name: string;
|
||||
middle_name?: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
}
|
113
apps/app/src/lib/profile/LifeEventsTimeline.svelte
Normal file
113
apps/app/src/lib/profile/LifeEventsTimeline.svelte
Normal 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()}
|
||||
>
|
||||
✕
|
||||
</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}
|
69
apps/app/src/lib/profile/MediaGallery.svelte
Normal file
69
apps/app/src/lib/profile/MediaGallery.svelte
Normal 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}
|
99
apps/app/src/lib/profile/Modal.svelte
Normal file
99
apps/app/src/lib/profile/Modal.svelte
Normal 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>
|
26
apps/app/src/lib/profile/ModalButtons.svelte
Normal file
26
apps/app/src/lib/profile/ModalButtons.svelte
Normal 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>
|
155
apps/app/src/lib/profile/OtherDetails.svelte
Normal file
155
apps/app/src/lib/profile/OtherDetails.svelte
Normal 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);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</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>
|
205
apps/app/src/lib/profile/ProfileHeader.svelte
Normal file
205
apps/app/src/lib/profile/ProfileHeader.svelte
Normal 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>
|
382
apps/app/src/lib/profile/create/Modal.svelte
Normal file
382
apps/app/src/lib/profile/create/Modal.svelte
Normal 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>
|
79
apps/app/src/lib/profile/create/validate_fields.ts
Normal file
79
apps/app/src/lib/profile/create/validate_fields.ts
Normal 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
|
||||
}
|
40
apps/app/src/lib/profile/editors/EditableField.svelte
Normal file
40
apps/app/src/lib/profile/editors/EditableField.svelte
Normal 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}
|
101
apps/app/src/lib/profile/editors/UploadMediaModal.svelte
Normal file
101
apps/app/src/lib/profile/editors/UploadMediaModal.svelte
Normal 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>
|
91
apps/app/src/lib/relationship/EdgeMenu.svelte
Normal file
91
apps/app/src/lib/relationship/EdgeMenu.svelte
Normal 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>
|
292
apps/app/src/lib/relationship/Modal.svelte
Normal file
292
apps/app/src/lib/relationship/Modal.svelte
Normal 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>
|
41
apps/app/src/lib/relationship/ModalButtons.svelte
Normal file
41
apps/app/src/lib/relationship/ModalButtons.svelte
Normal 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>
|
12
apps/app/src/lib/relationship/model.ts
Normal file
12
apps/app/src/lib/relationship/model.ts
Normal 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;
|
||||
}
|
8
apps/app/src/lib/server/oauth.ts
Normal file
8
apps/app/src/lib/server/oauth.ts
Normal 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'
|
||||
);
|
89
apps/app/src/lib/server/session.ts
Normal file
89
apps/app/src/lib/server/session.ts
Normal 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;
|
54
apps/app/src/lib/sidebar/hamburgerIcon.svelte
Normal file
54
apps/app/src/lib/sidebar/hamburgerIcon.svelte
Normal 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>
|
13
apps/app/src/lib/sidebar/sideBar.svelte
Normal file
13
apps/app/src/lib/sidebar/sideBar.svelte
Normal 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>
|
41
apps/app/src/lib/switchToLanguage.test.ts
Normal file
41
apps/app/src/lib/switchToLanguage.test.ts
Normal 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);
|
||||
});
|
||||
});
|
10
apps/app/src/lib/switchToLanguage.ts
Normal file
10
apps/app/src/lib/switchToLanguage.ts
Normal 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);
|
||||
}
|
19
apps/app/src/lib/tailwindSizeToPx.ts
Normal file
19
apps/app/src/lib/tailwindSizeToPx.ts
Normal 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
|
||||
}
|
||||
}
|
1
apps/app/src/lib/themes.ts
Normal file
1
apps/app/src/lib/themes.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const themes = ['light', 'dark', 'coffee', 'cyberpunk', 'synthwave', 'retro', 'dracula'];
|
17
apps/app/src/routes/+layout.svelte
Normal file
17
apps/app/src/routes/+layout.svelte
Normal 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>
|
31
apps/app/src/routes/+page.server.ts
Normal file
31
apps/app/src/routes/+page.server.ts
Normal 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;
|
||||
}
|
382
apps/app/src/routes/+page.svelte
Normal file
382
apps/app/src/routes/+page.svelte
Normal 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>
|
26
apps/app/src/routes/api/admin/[ID1]/+server.ts
Normal file
26
apps/app/src/routes/api/admin/[ID1]/+server.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
72
apps/app/src/routes/api/admin/[ID1]/[ID2]/+server.ts
Normal file
72
apps/app/src/routes/api/admin/[ID1]/[ID2]/+server.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
99
apps/app/src/routes/api/comment/[ID]/+server.ts
Normal file
99
apps/app/src/routes/api/comment/[ID]/+server.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
57
apps/app/src/routes/api/family_tree/+server.ts
Normal file
57
apps/app/src/routes/api/family_tree/+server.ts
Normal 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
|
||||
});
|
||||
}
|
25
apps/app/src/routes/api/managed_profiles/+server.ts
Normal file
25
apps/app/src/routes/api/managed_profiles/+server.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
27
apps/app/src/routes/api/person/+server.ts
Normal file
27
apps/app/src/routes/api/person/+server.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
74
apps/app/src/routes/api/person/[ID]/+server.ts
Normal file
74
apps/app/src/routes/api/person/[ID]/+server.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
26
apps/app/src/routes/api/person/[ID]/hard-delete/+server.ts
Normal file
26
apps/app/src/routes/api/person/[ID]/hard-delete/+server.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
32
apps/app/src/routes/api/relationship/+server.ts
Normal file
32
apps/app/src/routes/api/relationship/+server.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
76
apps/app/src/routes/api/relationship/[ID1]/[ID2]/+server.ts
Normal file
76
apps/app/src/routes/api/relationship/[ID1]/[ID2]/+server.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
11
apps/app/src/routes/login/+page.server.ts
Normal file
11
apps/app/src/routes/login/+page.server.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
import type { RequestEvent } from './$types';
|
||||
|
||||
export async function load(event: RequestEvent) {
|
||||
if (event.locals.session !== null) {
|
||||
return redirect(302, '/');
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
42
apps/app/src/routes/login/+page.svelte
Normal file
42
apps/app/src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { sign_in, site_intro, title, family_tree, welcome } from '$lib/paraglide/messages.js';
|
||||
import FamilyTree from './highresolution_icon_no_background_croped.png';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title({ page: sign_in() })}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="hero bg-base-200 min-h-screen">
|
||||
<div class="hero-content flex flex-col items-center justify-center text-center">
|
||||
<div class="max-w-lg">
|
||||
<figure class="top-margin-10 px-10 pt-10">
|
||||
<img src={FamilyTree} alt={family_tree()} class="rounded-xl" />
|
||||
</figure>
|
||||
<h1 class="text-3xl font-bold">{welcome()}</h1>
|
||||
<p class="py-6">
|
||||
{site_intro()}
|
||||
</p>
|
||||
<a href="/login/google" class="btn rounded-full border-[#e5e5e5] bg-white text-black">
|
||||
<!-- Google -->
|
||||
<svg
|
||||
aria-label="Google logo"
|
||||
width="16"
|
||||
height="16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
><g
|
||||
><path d="m0 0H512V512H0" fill="none"></path><path
|
||||
fill="#34a853"
|
||||
d="M153 292c30 82 118 95 171 60h62v48A192 192 0 0190 341"
|
||||
></path><path fill="#4285f4" d="m386 400a140 175 0 0053-179H260v74h102q-7 37-38 57"
|
||||
></path><path fill="#fbbc02" d="m90 341a208 200 0 010-171l63 49q-12 37 0 73"></path>
|
||||
<path fill="#ea4335" d="m153 219c22-69 116-109 179-50l55-54c-78-75-230-72-297 55"
|
||||
></path></g
|
||||
></svg
|
||||
>
|
||||
Google {sign_in()}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
32
apps/app/src/routes/login/google/+server.ts
Normal file
32
apps/app/src/routes/login/google/+server.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { google } from '$lib/server/oauth';
|
||||
import { generateCodeVerifier, generateState } from 'arctic';
|
||||
|
||||
import type { RequestEvent } from './$types';
|
||||
|
||||
export function GET(event: RequestEvent): Response {
|
||||
const state = generateState();
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const url = google.createAuthorizationURL(state, codeVerifier, ['openid', 'profile', 'email']);
|
||||
|
||||
event.cookies.set('google_oauth_state', state, {
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 10,
|
||||
secure: import.meta.env.PROD,
|
||||
path: '/',
|
||||
sameSite: 'lax'
|
||||
});
|
||||
event.cookies.set('google_code_verifier', codeVerifier, {
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 10,
|
||||
secure: import.meta.env.PROD,
|
||||
path: '/',
|
||||
sameSite: 'lax'
|
||||
});
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: url.toString()
|
||||
}
|
||||
});
|
||||
}
|
358
apps/app/src/routes/login/google/callback/+page.server.ts
Normal file
358
apps/app/src/routes/login/google/callback/+page.server.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { google } from '$lib/server/oauth';
|
||||
import { ObjectParser } from '@pilcrowjs/object-parser';
|
||||
import { browser } from '$app/environment';
|
||||
import { client } from '$lib/api/client';
|
||||
import { type components } from '$lib/api/api.gen';
|
||||
import { createSession, generateSessionToken, setSessionTokenCookie } from '$lib/server/session';
|
||||
import { decodeIdToken } from 'arctic';
|
||||
import {
|
||||
missing_field,
|
||||
last_name,
|
||||
first_name,
|
||||
mothers_first_name,
|
||||
mothers_last_name,
|
||||
born,
|
||||
failed_to_create_user,
|
||||
biological_sex
|
||||
} from '$lib/paraglide/messages';
|
||||
|
||||
import type { PageServerLoad, Actions, RequestEvent } from './$types';
|
||||
import type { OAuth2Tokens } from 'arctic';
|
||||
import type { PersonProperties } from '$lib/model';
|
||||
import { error, redirect, fail } from '@sveltejs/kit';
|
||||
|
||||
const StorageLimit = 200 * 1024 * 1024;
|
||||
|
||||
export const load: PageServerLoad = async (event: RequestEvent) => {
|
||||
//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 storedState = event.cookies.get('google_oauth_state') ?? null;
|
||||
const codeVerifier = event.cookies.get('google_code_verifier') ?? null;
|
||||
const code = event.url.searchParams.get('code');
|
||||
const state = event.url.searchParams.get('state');
|
||||
|
||||
if (storedState === null || codeVerifier === null || code === null || state === null) {
|
||||
return error(400, { message: 'Please restart the process.' });
|
||||
}
|
||||
if (storedState !== state) {
|
||||
return error(400, { message: 'Please restart the process.' });
|
||||
}
|
||||
|
||||
let tokens: OAuth2Tokens;
|
||||
try {
|
||||
tokens = await google.validateAuthorizationCode(code, codeVerifier);
|
||||
} catch (e) {
|
||||
let already_loaded = event.cookies.get('already_loaded') ?? null;
|
||||
if (already_loaded !== null) {
|
||||
event.cookies.delete('already_loaded', {
|
||||
path: '/login/google/callback',
|
||||
sameSite: 'lax',
|
||||
httpOnly: true,
|
||||
maxAge: 0,
|
||||
secure: import.meta.env.PROD
|
||||
});
|
||||
return {};
|
||||
}
|
||||
return error(400, { message: 'Failed to validate authorization code with ' + e });
|
||||
}
|
||||
|
||||
const claims = decodeIdToken(tokens.idToken());
|
||||
const claimsParser = new ObjectParser(claims);
|
||||
|
||||
const sub = claimsParser.getString('sub');
|
||||
const family_name = claimsParser.getString('family_name');
|
||||
const first_name = claimsParser.getString('given_name');
|
||||
const email = claimsParser.getString('email');
|
||||
|
||||
const response = await client.GET('/person/google/{google_id}', {
|
||||
params: {
|
||||
path: { google_id: sub }
|
||||
}
|
||||
});
|
||||
|
||||
if (response.response.status === 200) {
|
||||
if (response.data?.Id !== undefined) {
|
||||
if (!event.platform || !event.platform.env || !event.platform.env.GH_SESSIONS) {
|
||||
return error(500, {
|
||||
message: 'Server configuration error. GH_SESSIONS KeyValue store missing'
|
||||
});
|
||||
}
|
||||
|
||||
const sessionToken = generateSessionToken(String(response.data.Id));
|
||||
const session = await createSession(
|
||||
sessionToken,
|
||||
response.data.Id,
|
||||
event.platform.env.GH_SESSIONS
|
||||
);
|
||||
if (session === null) {
|
||||
return error(500, {
|
||||
message: 'Failed to create session'
|
||||
});
|
||||
}
|
||||
|
||||
event.cookies.delete('already_loaded', {
|
||||
path: '/login/google/callback',
|
||||
sameSite: 'lax',
|
||||
httpOnly: true,
|
||||
maxAge: 0,
|
||||
secure: import.meta.env.PROD
|
||||
});
|
||||
|
||||
setSessionTokenCookie(event, sessionToken, session.expiresAt);
|
||||
|
||||
return redirect(302, '/');
|
||||
}
|
||||
}
|
||||
|
||||
let personP: PersonProperties = {
|
||||
google_id: sub,
|
||||
first_name: first_name,
|
||||
last_name: family_name,
|
||||
email: email
|
||||
};
|
||||
|
||||
event.cookies.set('already_loaded', 'true', {
|
||||
path: '/login/google/callback',
|
||||
sameSite: 'lax',
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 10,
|
||||
secure: import.meta.env.PROD
|
||||
});
|
||||
|
||||
return {
|
||||
props: personP
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
register: register
|
||||
};
|
||||
|
||||
async function register(event: RequestEvent) {
|
||||
if (browser) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const data = await event.request.formData();
|
||||
let parsedData: components['schemas']['PersonRegistration'] = {
|
||||
first_name: data.get('first_name'),
|
||||
last_name: data.get('last_name'),
|
||||
email: data.get('email'),
|
||||
biological_sex: data.get('biological_sex'),
|
||||
born: data.get('birth_date'),
|
||||
mothers_first_name: data.get('mothers_first_name'),
|
||||
mothers_last_name: data.get('mothers_last_name'),
|
||||
google_id: data.get('google_id'),
|
||||
limit: StorageLimit
|
||||
} as components['schemas']['PersonRegistration'];
|
||||
|
||||
if (!event.platform || !event.platform.env || !event.platform.env.GH_SESSIONS) {
|
||||
return fail(500, {
|
||||
data: parsedData,
|
||||
message: 'Server configuration error. GH_SESSIONS KeyValue store missing'
|
||||
});
|
||||
}
|
||||
|
||||
const first_name_f = data.get('first_name');
|
||||
if (first_name_f === null || first_name_f === '') {
|
||||
return fail(400, {
|
||||
data: parsedData,
|
||||
message: missing_field({
|
||||
field: first_name()
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
const google_id = data.get('google_id');
|
||||
if (google_id === null || google_id === '') {
|
||||
return fail(400, {
|
||||
data: parsedData,
|
||||
message: missing_field({
|
||||
field: 'google_id'
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
const last_name_f = data.get('last_name');
|
||||
if (last_name_f === null || last_name_f === '') {
|
||||
return fail(400, {
|
||||
data: parsedData,
|
||||
message: missing_field({
|
||||
field: last_name()
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
const email = data.get('email');
|
||||
if (email === null || email === '') {
|
||||
return fail(400, {
|
||||
message: missing_field({
|
||||
field: 'Email'
|
||||
})
|
||||
});
|
||||
}
|
||||
let birth_date = data.get('birth_date');
|
||||
if (birth_date === null || birth_date === '') {
|
||||
return fail(400, {
|
||||
message: missing_field({
|
||||
field: born()
|
||||
})
|
||||
});
|
||||
} else {
|
||||
birth_date = birth_date.toString();
|
||||
}
|
||||
|
||||
const bbiological_sex = data.get('biological_sex');
|
||||
if (bbiological_sex === null || bbiological_sex === '') {
|
||||
return fail(400, {
|
||||
data: parsedData,
|
||||
message: missing_field({
|
||||
field: biological_sex()
|
||||
})
|
||||
});
|
||||
} else if (
|
||||
!['male', 'female', 'intersex', 'unknown', 'other'].includes(bbiological_sex.toString())
|
||||
) {
|
||||
return fail(400, {
|
||||
data: parsedData,
|
||||
message: `Invalid value for biological_sex. Must be one of "male", "female", "intersex", "unknown", or "other".`
|
||||
});
|
||||
}
|
||||
|
||||
const mothers_first_name_f = data.get('mothers_first_name');
|
||||
if (mothers_first_name_f === null || mothers_first_name_f === '') {
|
||||
return fail(400, {
|
||||
data: parsedData,
|
||||
message: missing_field({
|
||||
field: mothers_first_name()
|
||||
})
|
||||
});
|
||||
}
|
||||
const mothers_last_name_f = data.get('mothers_last_name');
|
||||
if (mothers_last_name_f === null) {
|
||||
return fail(400, {
|
||||
data: parsedData,
|
||||
message: missing_field({
|
||||
field: mothers_last_name()
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
const parsed_date = new Date(birth_date as string);
|
||||
let personP: components['schemas']['PersonRegistration'] = {
|
||||
first_name: first_name_f as string,
|
||||
last_name: last_name_f as string,
|
||||
email: email as string,
|
||||
born: parsed_date.toISOString().split('T')[0],
|
||||
mothers_first_name: mothers_first_name_f as string,
|
||||
mothers_last_name: mothers_last_name_f as string,
|
||||
biological_sex:
|
||||
bbiological_sex as components['schemas']['PersonRegistration']['biological_sex'],
|
||||
limit: StorageLimit
|
||||
};
|
||||
|
||||
let invite_code = data.get('invite_code');
|
||||
if (invite_code !== null) {
|
||||
invite_code = invite_code.toString();
|
||||
} else {
|
||||
invite_code = '';
|
||||
}
|
||||
|
||||
let responseData:
|
||||
| {
|
||||
Id?: number;
|
||||
ElementId?: string;
|
||||
Labels?: string[];
|
||||
Props?: components['schemas']['PersonProperties'];
|
||||
}
|
||||
| undefined = undefined;
|
||||
if (!(invite_code.length > 0)) {
|
||||
let response = await client.POST('/person/google/{google_id}', {
|
||||
params: {
|
||||
data: parsedData,
|
||||
path: { google_id: google_id.toString() }
|
||||
},
|
||||
body: personP
|
||||
});
|
||||
|
||||
if (response.response.status !== 200) {
|
||||
return fail(400, {
|
||||
data: parsedData,
|
||||
message: failed_to_create_user() + response.error?.msg
|
||||
});
|
||||
}
|
||||
|
||||
if (response.data === undefined) {
|
||||
return fail(400, {
|
||||
data: parsedData,
|
||||
message: failed_to_create_user() + 'No user data returned'
|
||||
});
|
||||
}
|
||||
responseData = response.data;
|
||||
} else {
|
||||
let response = await client.PATCH('/person/google/{google_id}', {
|
||||
params: {
|
||||
path: { google_id: google_id.toString() }
|
||||
},
|
||||
body: {
|
||||
invite_code: invite_code,
|
||||
person: personP
|
||||
}
|
||||
});
|
||||
if (response.response.status !== 200) {
|
||||
return fail(400, {
|
||||
data: parsedData,
|
||||
message: failed_to_create_user() + response.error?.msg
|
||||
});
|
||||
}
|
||||
|
||||
if (response.data === undefined) {
|
||||
return fail(400, {
|
||||
data: parsedData,
|
||||
message: failed_to_create_user() + 'No user data returned'
|
||||
});
|
||||
}
|
||||
responseData = response.data;
|
||||
}
|
||||
|
||||
if (responseData.Id === undefined) {
|
||||
return fail(400, {
|
||||
data: parsedData,
|
||||
message: failed_to_create_user() + 'No user ID returned'
|
||||
});
|
||||
}
|
||||
|
||||
if (!event.platform) {
|
||||
return fail(500, {
|
||||
data: parsedData,
|
||||
message: 'Server configuration error. GH_SESSIONS KeyValue store missing'
|
||||
});
|
||||
}
|
||||
|
||||
const sessionToken = generateSessionToken(String(responseData.Id));
|
||||
const session = await createSession(
|
||||
sessionToken,
|
||||
responseData.Id,
|
||||
event.platform.env.GH_SESSIONS
|
||||
);
|
||||
if (session === null) {
|
||||
return fail(500, {
|
||||
data: parsedData,
|
||||
message: failed_to_create_user() + 'Failed to create session'
|
||||
});
|
||||
}
|
||||
|
||||
setSessionTokenCookie(event, sessionToken, session.expiresAt);
|
||||
|
||||
event.cookies.delete('already_loaded', {
|
||||
path: '/login/google/callback',
|
||||
sameSite: 'lax',
|
||||
httpOnly: true,
|
||||
maxAge: 0,
|
||||
secure: import.meta.env.PROD
|
||||
});
|
||||
return redirect(302, '/');
|
||||
}
|
194
apps/app/src/routes/login/google/callback/+page.svelte
Normal file
194
apps/app/src/routes/login/google/callback/+page.svelte
Normal file
@@ -0,0 +1,194 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import {
|
||||
register,
|
||||
title,
|
||||
family_tree,
|
||||
welcome,
|
||||
site_intro,
|
||||
born,
|
||||
mothers_first_name,
|
||||
mothers_last_name,
|
||||
last_name,
|
||||
first_name,
|
||||
email,
|
||||
biological_sex,
|
||||
male,
|
||||
female,
|
||||
other,
|
||||
intersex,
|
||||
invite_code,
|
||||
have_invite_code
|
||||
} from '$lib/paraglide/messages';
|
||||
import { onMount } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import FamilyTree from '../../highresolution_icon_no_background_croped.png';
|
||||
let {
|
||||
data,
|
||||
form
|
||||
}: {
|
||||
data: PageData;
|
||||
form: {
|
||||
message: string;
|
||||
};
|
||||
} = $props();
|
||||
|
||||
let birth_date: HTMLInputElement;
|
||||
let birth_date_value: 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,
|
||||
onOpen: function () {
|
||||
birth_date_value.placeholder = '';
|
||||
},
|
||||
onSelect: function (date) {
|
||||
birth_date_value.value = date.toISOString().split('T')[0];
|
||||
}
|
||||
});
|
||||
// Clean up when component unmounts
|
||||
return () => picker.destroy();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let showInviteInput = $state(false);
|
||||
function toggleInviteInput() {
|
||||
showInviteInput = !showInviteInput;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{title({ page: register() })}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="hero bg-base-200 min-h-screen">
|
||||
<div class="hero-content flex-col lg:flex-row-reverse">
|
||||
<div class="max-w-xxl flex flex-col items-center justify-center text-center">
|
||||
<figure class="top-margin-10 max-w-sm px-10 pt-10">
|
||||
<img src={FamilyTree} alt={family_tree()} class="rounded-xl" />
|
||||
</figure>
|
||||
<h1 class="text-5xl font-bold">{welcome()}</h1>
|
||||
<p class="py-6">
|
||||
{site_intro()}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card bg-base-100 w-full max-w-sm shrink-0 shadow-2xl">
|
||||
<div class="card-body">
|
||||
<form method="POST" action="?/register" use:enhance>
|
||||
<fieldset class="fieldset">
|
||||
{#if form?.message}
|
||||
<div role="alert" class="alert alert-error">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{form.message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<label class="fieldset-label" for="email">{email()}</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
class="input"
|
||||
placeholder={email()}
|
||||
value={data.props?.email}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="google_id"
|
||||
class="hidden"
|
||||
id="google_id"
|
||||
placeholder="Google ID"
|
||||
value={data.props?.google_id}
|
||||
/>
|
||||
<label class="fieldset-label" for="first_name">{first_name()}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
name="first_name"
|
||||
id="first_name"
|
||||
placeholder={first_name()}
|
||||
value={data.props?.first_name}
|
||||
/>
|
||||
<label class="fieldset-label" for="last_name">{last_name()}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
name="last_name"
|
||||
id="last_name"
|
||||
placeholder={last_name()}
|
||||
value={data.props?.last_name}
|
||||
/>
|
||||
<label class="fieldset-label" for="birth_date">{born()}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input pika-single"
|
||||
id="birth_date"
|
||||
placeholder={born()}
|
||||
bind:this={birth_date}
|
||||
/>
|
||||
<input type="text" class="hidden" name="birth_date" bind:this={birth_date_value} />
|
||||
<label class="fieldset-label" for="biological_sex">{biological_sex()}</label>
|
||||
<select
|
||||
name="biological_sex"
|
||||
class="select select-bordered w-full max-w-xs"
|
||||
id="biological_sex"
|
||||
placeholder={biological_sex()}
|
||||
>
|
||||
<option value="male">{male()} </option>
|
||||
<option value="female">{female()} </option>
|
||||
<option value="intersex">{intersex()} </option>
|
||||
<option value="other">{other()} </option>
|
||||
</select>
|
||||
<label class="fieldset-label" for="mothers_last_name">{mothers_last_name()}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
name="mothers_last_name"
|
||||
id="mothers_last_name"
|
||||
placeholder={mothers_last_name()}
|
||||
/>
|
||||
<label class="fieldset-label" for="mothers_first_name">{mothers_first_name()}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input"
|
||||
name="mothers_first_name"
|
||||
id="mothers_first_name"
|
||||
placeholder={mothers_first_name()}
|
||||
/>
|
||||
<button type="button" class="btn btn-soft mt-4 max-w-xs" onclick={toggleInviteInput}>
|
||||
{have_invite_code()}
|
||||
</button>
|
||||
{#if showInviteInput}
|
||||
<div class="mt-4">
|
||||
<label class="fieldset-label" for="invite_code">Meghívókód</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
name="invite_code"
|
||||
id="invite_code"
|
||||
placeholder={invite_code()}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button class="btn btn-neutral mt-4 max-w-xs">{register()}</button>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
Binary file not shown.
After Width: | Height: | Size: 690 KiB |
20
apps/app/src/routes/logout/+page.server.ts
Normal file
20
apps/app/src/routes/logout/+page.server.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { invalidateSession, deleteSessionTokenCookie } from '$lib/server/session';
|
||||
|
||||
import type { RequestEvent } from './$types';
|
||||
|
||||
export async function load(event: RequestEvent): Promise<Response> {
|
||||
if (event.locals.session === null) {
|
||||
return redirect(302, '/login');
|
||||
}
|
||||
|
||||
if (event.platform && event.platform.env && event.platform.env.GH_SESSIONS) {
|
||||
await invalidateSession(event.locals.session.id, event.platform.env.GH_SESSIONS);
|
||||
} else {
|
||||
return error(500, { message: 'Server configuration error' });
|
||||
}
|
||||
|
||||
deleteSessionTokenCookie(event);
|
||||
|
||||
return redirect(302, '/login');
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user