mirror of
https://github.com/vcscsvcscs/GenerationsHeritage.git
synced 2025-08-11 21:39:06 +02:00
Merge pull request #12 from vcscsvcscs/feature/migrate-100-percent-to-svelte
Migrate 100 percent to svelte
This commit is contained in:
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 }}
|
7
.github/workflows/db_adapter_ci.yml
vendored
7
.github/workflows/db_adapter_ci.yml
vendored
@@ -1,9 +1,14 @@
|
||||
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:
|
||||
@@ -16,7 +21,7 @@ jobs:
|
||||
go-version: '1.24'
|
||||
cache: false
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v4
|
||||
uses: golangci/golangci-lint-action@v7
|
||||
with:
|
||||
version: latest
|
||||
working-directory: 'apps/db-adapter'
|
||||
|
2
.github/workflows/docker_build.yml
vendored
2
.github/workflows/docker_build.yml
vendored
@@ -12,7 +12,7 @@ on:
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
name: Build and Push Backend image to Docker Hub
|
||||
name: Build and Push ${{inputs.service-name}} image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v6
|
||||
|
11
.github/workflows/go_test.yml
vendored
11
.github/workflows/go_test.yml
vendored
@@ -15,6 +15,8 @@ jobs:
|
||||
- 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
|
||||
@@ -22,13 +24,16 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
go get ./${{ inputs.working-directory }}/...
|
||||
cd ${{ inputs.working-directory }}
|
||||
go get ./...
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
go test ./${{ inputs.working-directory }}/... -json > TestResults.json
|
||||
|
||||
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
|
||||
|
7
.github/workflows/svelte_ci.yml
vendored
7
.github/workflows/svelte_ci.yml
vendored
@@ -1,9 +1,14 @@
|
||||
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:
|
||||
@@ -15,4 +20,4 @@ jobs:
|
||||
needs: lint
|
||||
uses: ./.github/workflows/svelte_test.yml
|
||||
with:
|
||||
working-directory: 'apps/app'
|
||||
working-directory: 'apps/app'
|
||||
|
2
.github/workflows/svelte_lint.yml
vendored
2
.github/workflows/svelte_lint.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '21.x'
|
||||
node-version: '22.x'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd ${{ inputs.working-directory }}
|
||||
|
2
.github/workflows/svelte_test.yml
vendored
2
.github/workflows/svelte_test.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '21.x'
|
||||
node-version: '22.x'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
cd ${{ inputs.working-directory }}
|
||||
|
132
.golangci.yml
132
.golangci.yml
@@ -1,132 +0,0 @@
|
||||
linters-settings:
|
||||
depguard:
|
||||
rules:
|
||||
logger:
|
||||
deny:
|
||||
# logging is allowed only by logutils.Log,
|
||||
# logrus is allowed to use only in logutils package.
|
||||
- pkg: "github.com/sirupsen/logrus"
|
||||
desc: logging is allowed only by logutils.Log.
|
||||
- pkg: "github.com/pkg/errors"
|
||||
desc: Should be replaced by standard lib errors package.
|
||||
- pkg: "github.com/instana/testify"
|
||||
desc: It's a fork of github.com/stretchr/testify.
|
||||
dupl:
|
||||
threshold: 100
|
||||
funlen:
|
||||
lines: -1 # the number of lines (code + empty lines) is not a right metric and leads to code without empty line or one-liner.
|
||||
statements: 50
|
||||
goconst:
|
||||
min-len: 2
|
||||
min-occurrences: 3
|
||||
gocritic:
|
||||
enabled-tags:
|
||||
- diagnostic
|
||||
- experimental
|
||||
- opinionated
|
||||
- performance
|
||||
- style
|
||||
disabled-checks:
|
||||
- dupImport # https://github.com/go-critic/go-critic/issues/845
|
||||
- ifElseChain
|
||||
- octalLiteral
|
||||
- whyNoLint
|
||||
gocyclo:
|
||||
min-complexity: 15
|
||||
gofmt:
|
||||
rewrite-rules:
|
||||
- pattern: 'interface{}'
|
||||
replacement: 'any'
|
||||
gomnd:
|
||||
# don't include the "operation" and "assign"
|
||||
checks:
|
||||
- argument
|
||||
- case
|
||||
- condition
|
||||
- return
|
||||
ignored-numbers:
|
||||
- '0'
|
||||
- '1'
|
||||
- '2'
|
||||
- '3'
|
||||
ignored-functions:
|
||||
- strings.SplitN
|
||||
govet:
|
||||
settings:
|
||||
printf:
|
||||
funcs:
|
||||
- (github.com/golangci/golangci-lint/pkg/logutils.Log).Infof
|
||||
- (github.com/golangci/golangci-lint/pkg/logutils.Log).Warnf
|
||||
- (github.com/golangci/golangci-lint/pkg/logutils.Log).Errorf
|
||||
- (github.com/golangci/golangci-lint/pkg/logutils.Log).Fatalf
|
||||
enable:
|
||||
- nilness
|
||||
- shadow
|
||||
errorlint:
|
||||
asserts: false
|
||||
lll:
|
||||
line-length: 140
|
||||
misspell:
|
||||
locale: US
|
||||
nolintlint:
|
||||
allow-unused: false # report any unused nolint directives
|
||||
require-explanation: false # don't require an explanation for nolint directives
|
||||
require-specific: false # don't require nolint directives to be specific about which linter is being skipped
|
||||
revive:
|
||||
rules:
|
||||
- name: unexported-return
|
||||
disabled: true
|
||||
- name: unused-parameter
|
||||
|
||||
linters:
|
||||
disable-all: true
|
||||
enable:
|
||||
- bodyclose
|
||||
- depguard
|
||||
- dogsled
|
||||
- dupl
|
||||
- errcheck
|
||||
- errorlint
|
||||
- exportloopref
|
||||
- funlen
|
||||
- gocheckcompilerdirectives
|
||||
- gochecknoinits
|
||||
- goconst
|
||||
- gocritic
|
||||
- gocyclo
|
||||
- gofmt
|
||||
- goimports
|
||||
- gomnd
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- gosimple
|
||||
- govet
|
||||
- ineffassign
|
||||
- lll
|
||||
- misspell
|
||||
- nakedret
|
||||
- noctx
|
||||
- nolintlint
|
||||
- revive
|
||||
- staticcheck
|
||||
- stylecheck
|
||||
- typecheck
|
||||
- unconvert
|
||||
- unparam
|
||||
- unused
|
||||
- whitespace
|
||||
|
||||
# don't enable:
|
||||
# - asciicheck
|
||||
# - gochecknoglobals
|
||||
# - gocognit
|
||||
# - godot
|
||||
# - godox
|
||||
# - goerr113
|
||||
# - nestif
|
||||
# - prealloc
|
||||
# - testpackage
|
||||
# - wsl
|
||||
|
||||
run:
|
||||
timeout: 5m
|
21
.vscode/launch.json
vendored
Normal file
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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
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
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
172
apps/app/messages/en.json
Normal file
172
apps/app/messages/en.json
Normal file
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"about": "About",
|
||||
"accept": "Accept",
|
||||
"add": "Add",
|
||||
"add_administrator": "Add administrator",
|
||||
"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",
|
||||
"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"
|
||||
}
|
170
apps/app/messages/hu.json
Normal file
170
apps/app/messages/hu.json
Normal file
@@ -0,0 +1,170 @@
|
||||
{
|
||||
"$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_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",
|
||||
"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>
|
177
apps/app/src/lib/admin/Modal.svelte
Normal file
177
apps/app/src/lib/admin/Modal.svelte
Normal file
@@ -0,0 +1,177 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
hard_delete,
|
||||
managed_profiles,
|
||||
delete_profile,
|
||||
edit,
|
||||
from_time,
|
||||
admin,
|
||||
create_relationship_and_person,
|
||||
add_relationship
|
||||
} from '$lib/paraglide/messages';
|
||||
import ModalButtons from './ModalButtons.svelte';
|
||||
import type { components, operations } from '$lib/api/api.gen';
|
||||
|
||||
let {
|
||||
closeModal,
|
||||
editProfile = () => {},
|
||||
onChange = () => {},
|
||||
addRelationship = () => {},
|
||||
createProfile = () => {},
|
||||
createRelationshipAndProfile = () => {}
|
||||
} = $props<{
|
||||
closeModal: () => void;
|
||||
onChange?: () => void;
|
||||
addRelationship?: (id: number) => void;
|
||||
createRelationshipAndProfile?: (id: number) => void;
|
||||
editProfile?: (id: number) => void;
|
||||
createProfile?: () => void;
|
||||
}>();
|
||||
|
||||
let managed_profiles_list: components['schemas']['Admin'][] = $state([]);
|
||||
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) {
|
||||
onChange();
|
||||
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) {
|
||||
onChange();
|
||||
managed_profiles_list = managed_profiles_list.filter((profile) => profile.id !== id);
|
||||
return;
|
||||
} else {
|
||||
alert('Error deleting person');
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error:', error);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="modal modal-open z-8">
|
||||
<div class="modal-box w-full max-w-xl gap-4">
|
||||
<div class="bg-base-100 sticky top-0 z-5">
|
||||
<ModalButtons onClose={closeModal} {createProfile} />
|
||||
<div class="divider"></div>
|
||||
</div>
|
||||
<ul class="list bg-base-100 rounded-box shadow-md">
|
||||
<li class="p-4 pb-2 text-xs tracking-wide opacity-60">{managed_profiles()}</li>
|
||||
{#each managed_profiles_list as profile}
|
||||
<li class="list-row">
|
||||
<div>
|
||||
<div 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}
|
||||
<button
|
||||
class="btn btn-secondary btn-sm"
|
||||
onclick={() => {
|
||||
editProfile(profile.id!);
|
||||
}}
|
||||
>
|
||||
{edit()}
|
||||
</button>
|
||||
{#if profile.label?.includes('DeletedPerson')}
|
||||
<button
|
||||
class="btn btn-error btn-sm"
|
||||
onclick={() => {
|
||||
hardDeleteProfile(profile.id!);
|
||||
}}
|
||||
>
|
||||
{hard_delete()}
|
||||
</button>
|
||||
{:else}
|
||||
<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;
|
||||
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;
|
||||
}
|
96
apps/app/src/lib/profile/LifeEventsTimeline.svelte
Normal file
96
apps/app/src/lib/profile/LifeEventsTimeline.svelte
Normal file
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
add_life_event,
|
||||
description,
|
||||
life_events,
|
||||
unknown,
|
||||
until
|
||||
} 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: '', description: '' };
|
||||
person_life_events = [...(person_life_events ?? []), newEvent];
|
||||
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">
|
||||
{#if editorMode}
|
||||
<input
|
||||
type="text"
|
||||
class="input input-xs input-bordered"
|
||||
value={event.from ?? ''}
|
||||
on:input={(e) => updateEvent(index, 'from', e.currentTarget.value)}
|
||||
placeholder={unknown().toLowerCase()}
|
||||
/>
|
||||
{: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="text"
|
||||
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();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
closeModal();
|
||||
editorMode = false;
|
||||
draftPerson = {};
|
||||
}
|
||||
|
||||
function toggleEdit() {
|
||||
editorMode = !editorMode;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
const response = await fetch(`/api/person/${person.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(draftPerson)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
alert('Error saving person data, status: ' + response.status);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.status === 200) {
|
||||
person = { ...person, ...draftPerson };
|
||||
const data = (await response.json()) as {
|
||||
person?: components['schemas']['Person'];
|
||||
};
|
||||
} else {
|
||||
const errorDetails = await response.json();
|
||||
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-screen w-full max-h-80 max-w-5xl overflow-y-auto">
|
||||
<div class="bg-base-100 sticky top-0 z-7">
|
||||
<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>
|
108
apps/app/src/lib/profile/OtherDetails.svelte
Normal file
108
apps/app/src/lib/profile/OtherDetails.svelte
Normal file
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { callMessageFunction } from '$lib/i18n';
|
||||
import type { MessageKeys } from '$lib/i18n';
|
||||
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'
|
||||
];
|
||||
let newNote = {
|
||||
title: " ",
|
||||
note: ""
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{#each person.notes??[] as note}
|
||||
<div class="card bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">{note.title}</h2>
|
||||
<p>{note.note}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#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>
|
380
apps/app/src/lib/profile/create/Modal.svelte
Normal file
380
apps/app/src/lib/profile/create/Modal.svelte
Normal file
@@ -0,0 +1,380 @@
|
||||
<script lang="ts">
|
||||
import { fade } from 'svelte/transition';
|
||||
import {
|
||||
create,
|
||||
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
|
||||
},
|
||||
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">{create_relationship_and_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>
|
290
apps/app/src/lib/relationship/Modal.svelte
Normal file
290
apps/app/src/lib/relationship/Modal.svelte
Normal file
@@ -0,0 +1,290 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
child,
|
||||
from_time,
|
||||
id,
|
||||
notes,
|
||||
parent,
|
||||
relation,
|
||||
relation_type,
|
||||
relationship,
|
||||
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.log('Cannot create relationship' + ', status: ' + response.status);
|
||||
return;
|
||||
}
|
||||
|
||||
const created = (await response.json()) as components['schemas']['dbtypeRelationship'][];
|
||||
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);
|
||||
}
|
||||
</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;
|
||||
}
|
376
apps/app/src/routes/+page.svelte
Normal file
376
apps/app/src/routes/+page.svelte
Normal file
@@ -0,0 +1,376 @@
|
||||
<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 delete_profile = (id: any) => {
|
||||
fetch('/api/person/' + id, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
nodes = nodes.filter((n) => n.data.id !== id);
|
||||
edges = edges.filter((e) => e.source !== 'person' + id && e.target !== 'person' + 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;
|
||||
}
|
||||
) => {
|
||||
openPersonPanel = true;
|
||||
selectedPerson = { ...person, id: String(person.id) };
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
}}
|
||||
onChange={() => {}}
|
||||
/>
|
||||
{/if}
|
||||
</SvelteFlow>
|
||||
</SvelteFlowProvider>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-2 left-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
|
||||
});
|
||||
}
|
26
apps/app/src/routes/api/managed_profiles/+server.ts
Normal file
26
apps/app/src/routes/api/managed_profiles/+server.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { client } from '$lib/api/client';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from './$types';
|
||||
import { json } from 'stream/consumers';
|
||||
|
||||
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: 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