Merge pull request #12 from vcscsvcscs/feature/migrate-100-percent-to-svelte

Migrate 100 percent to svelte
This commit is contained in:
Vargha Csongor
2025-05-01 23:13:56 +02:00
committed by GitHub
338 changed files with 40367 additions and 8897 deletions

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

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

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

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

View File

@@ -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 }}

View File

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

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

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

3328
api/openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

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

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

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

4
apps/app/.prettierignore Normal file
View File

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

15
apps/app/.prettierrc Normal file
View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

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

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

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

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

172
apps/app/messages/en.json Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

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

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

View File

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

View File

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

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

@@ -0,0 +1 @@
cache

View File

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

View File

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

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

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

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

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

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

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="%paraglide.lang%" dir="%paraglide.textDirection%" data-theme="" class="bg-base-200">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" style="width: 100vw; height: 100vh" class="bg-base-200">
<div style="display: contents; width: 100vw; height: 100vh" class="bg-base-200">
%sveltekit.body%
</div>
</body>
</html>

View File

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

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

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

View File

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

View File

@@ -0,0 +1,8 @@
<script lang="ts">
import { logout } from '$lib/paraglide/messages.js';
export let show = false;
</script>
{#if show}
<a class="btn btn-error btn-xs h-8 min-h-0 px-4 py-0 text-sm" href="/logout">{logout()}</a>
{/if}

View File

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

View File

@@ -0,0 +1,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>

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,88 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
add_relationship,
remove,
create_relationship_and_person,
add_administrator
} from '$lib/paraglide/messages';
export let id: string;
export let XUserId: string;
export let top: number | undefined;
export let left: number | undefined;
export let right: number | undefined;
export let bottom: number | undefined;
export let onClick: () => void;
export let deleteNode: () => void;
export let createRelationshipAndNode: () => void;
export let addRelationship: () => void;
export let addAdmin: (() => void) | undefined;
let contextMenu: HTMLDivElement;
let isAdmin: boolean = false;
onMount(() => {
if (top) {
contextMenu.style.top = `${top}px`;
}
if (left) {
contextMenu.style.left = `${left}px`;
}
if (right) {
contextMenu.style.right = `${right}px`;
}
if (bottom) {
contextMenu.style.bottom = `${bottom}px`;
}
fetch(`/api/admin/${id}/${XUserId}`)
.then((response) => {
if (response.status === 200) {
isAdmin = true;
} else {
isAdmin = false;
}
})
.catch((error) => {
console.error('Error fetching admin status:', error);
});
});
</script>
<div
role="menu"
tabindex="-1"
bind:this={contextMenu}
class="context-menu bg-primary-100 rounded-lg shadow-lg"
onclick={onClick}
onkeydown={(e) => {
if (e.key === 'Esc' || e.key === ' ' || e.key === 'Escape') {
onClick();
}
}}
>
<button onclick={createRelationshipAndNode} class="btn">
{create_relationship_and_person()}
</button>
<button onclick={addRelationship} class="btn">{add_relationship()}</button>
<!-- <button onclick={addAdmin} class="btn">{add_administrator()}</button> -->
{#if Number(XUserId) !== Number(id) && isAdmin}
<button onclick={deleteNode} class="btn">{remove()}</button>
{/if}
</div>
<style>
.context-menu {
border-style: solid;
box-shadow: 10px 19px 20px rgba(0, 0, 0, 10%);
position: absolute;
z-index: 10;
}
.context-menu button {
border: none;
display: block;
padding: 0.5em;
text-align: left;
width: 100%;
}
</style>

View File

@@ -0,0 +1,117 @@
<!-- <svelte:options immutable /> -->
<script lang="ts">
import { Handle, Position, type NodeProps } from '@xyflow/svelte';
import type { components } from '$lib/api/api.gen';
import { isValidConnection } from './connection.js';
type $$Props = NodeProps;
export let data: NodeProps['data'] & components['schemas']['PersonProperties'];
let nodeColor = ' bg-neutral text-neutral-content';
switch (data.biological_sex) {
case 'female':
nodeColor = ' bg-secondary text-secondary-content';
break;
case 'male':
nodeColor = ' bg-primary text-primary-content';
break;
case 'intersex':
nodeColor = ' bg-accent text-accent-content';
break;
}
</script>
<div
class={'card card-compact flex h-40 w-40 flex-col items-center justify-center rounded-full shadow-lg' +
nodeColor}
>
<Handle
class="customHandle"
id="child"
{isValidConnection}
isConnectable={true}
position={Position.Bottom}
type="source"
style="z-index: 1;"
/>
<Handle
class="customHandle"
{isValidConnection}
position={Position.Left}
isConnectable={true}
type="target"
isConnectableStart={false}
/>
<Handle
class="customHandle"
{isValidConnection}
position={Position.Right}
isConnectable={true}
type="target"
isConnectableStart={false}
/>
<Handle
class="customHandle"
{isValidConnection}
position={Position.Left}
isConnectable={true}
type="source"
isConnectableStart={true}
/>
<Handle
class="customHandle"
{isValidConnection}
position={Position.Right}
isConnectable={true}
type="source"
isConnectableStart={true}
/>
<Handle
class="customHandle"
id="parent"
{isValidConnection}
position={Position.Top}
isConnectable={true}
type="target"
isConnectableStart={false}
/>
<div class="avatar mb-2" style="z-index: 2; cursor: pointer;">
<div class={"w-24 rounded-full border-0 ring-offset-1"+nodeColor}>
<img
src={data.profile_picture || 'https://cdn-icons-png.flaticon.com/512/10628/10628885.png'}
alt="Picture of {data.last_name} {data.first_name}"
/>
</div>
</div>
<div class="px-2 text-center" style="z-index: 2; cursor: pointer;">
<h2 class="text-sm leading-tight font-semibold">
{data.first_name}
{data.middle_name ? data.middle_name : ''}
{data.last_name}
</h2>
<h3 class="text-xs opacity-70">
{data.born}{data.death ? ' - ' + data.death : ''}
</h3>
</div>
</div>
<style>
:global(div.customHandle) {
width: 100%;
height: 100%;
background: blue;
position: absolute;
top: 0;
left: 0;
border-radius: 0;
transform: none;
border: none;
opacity: 0;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,99 @@
<script lang="ts">
import { died } from './../paraglide/messages/en.js';
import { fade } from 'svelte/transition';
import ModalButtons from './ModalButtons.svelte';
import ProfileHeader from './ProfileHeader.svelte';
import MediaGallery from './MediaGallery.svelte';
import LifeEventsTimeline from './LifeEventsTimeline.svelte';
import OtherDetails from './OtherDetails.svelte';
import type { components } from '$lib/api/api.gen.js';
let {
closeModal = () => {},
person = {}
}: {
closeModal: () => void;
person: components['schemas']['PersonProperties'] & {
id?: string;
};
} = $props();
let editorMode = $state(false);
let draftPerson = $state({} as components['schemas']['PersonProperties']);
editorMode = false;
function handleDraftPersonChange(
field: keyof components['schemas']['PersonProperties'],
value: any
) {
draftPerson[field] = value;
if (field === 'invite_code') {
save();
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>

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { back, biography, close, edit, save } from '$lib/paraglide/messages';
export let editorMode = false;
export let onClose: () => void;
export let onToggleEdit: () => void;
export let onSave: () => void;
</script>
<div class="flex items-center justify-between p-2">
<h3 class="text-lg font-bold">{biography()}</h3>
<div class="space-x-2">
<button class="btn btn-secondary btn-sm" on:click={onToggleEdit}>
{editorMode ? back() : edit()}
</button>
{#if editorMode}
<button class="btn btn-accent btn-sm" on:click={onSave}>
{save()}
</button>
{/if}
<button class="btn btn-error btn-sm" on:click={onClose}>
{close()}
</button>
</div>
</div>

View File

@@ -0,0 +1,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>

View File

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

View File

@@ -0,0 +1,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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import {
add_relationship,
back,
close,
edit,
relation,
save
} from '$lib/paraglide/messages';
export let editorMode = false;
export let createMode = false;
export let onClose: () => void;
export let onToggleEdit: () => void;
export let onSave: () => void;
export let onCreate: () => void;
</script>
<div class="flex items-center justify-between p-2">
<h3 class="text-lg font-bold">{relation()}</h3>
<div class="space-x-2">
{#if !createMode}
<button class="btn btn-secondary btn-sm" on:click={onToggleEdit}>
{editorMode ? back() : edit()}
</button>
{/if}
{#if createMode}
<button class="btn btn-accent btn-sm" on:click={onCreate}>
{add_relationship()}
</button>
{:else if editorMode}
<button class="btn btn-accent btn-sm" on:click={onSave}>
{save()}
</button>
{/if}
<button class="btn btn-error btn-sm" on:click={onClose}>
{close()}
</button>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 {};
}

View 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>

View 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()
}
});
}

View 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, '/');
}

View 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

View 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