├── .dockerignore
├── .env
├── .github
├── dependabot.yml
└── workflows
│ ├── docker-image.yml
│ ├── lint.yml
│ └── release.yml
├── .gitignore
├── .npmrc
├── .prettierignore
├── .prettierrc
├── Dockerfile
├── LICENSE
├── README.md
├── dev
├── Caddyfile.dev
├── Caddyfile.test
├── dev.sh
└── test.sh
├── docs
└── screenshot.png
├── entrypoint.js
├── eslint.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.cjs
├── src
├── app.css
├── app.d.ts
├── app.html
├── hooks.server.ts
├── lib
│ ├── backgrounds
│ │ ├── Trianglify.svelte
│ │ ├── effects.ts
│ │ ├── particles
│ │ │ ├── Background.svelte
│ │ │ ├── Thumbnail.svelte
│ │ │ ├── definitions
│ │ │ │ ├── bubbles.json
│ │ │ │ ├── circles.json
│ │ │ │ ├── drizzle.json
│ │ │ │ ├── leaves.json
│ │ │ │ ├── rain.json
│ │ │ │ ├── snow.json
│ │ │ │ ├── squares.json
│ │ │ │ ├── stars.json
│ │ │ │ └── triangles.json
│ │ │ ├── index.ts
│ │ │ ├── thumbnails
│ │ │ │ ├── bubbles.png
│ │ │ │ ├── circles.png
│ │ │ │ ├── drizzle.png
│ │ │ │ ├── leaves.png
│ │ │ │ ├── rain.png
│ │ │ │ ├── snow.png
│ │ │ │ ├── squares.png
│ │ │ │ ├── stars.png
│ │ │ │ └── triangles.png
│ │ │ └── types.d.ts
│ │ └── random.ts
│ ├── server
│ │ ├── authz.ts
│ │ ├── background.ts
│ │ ├── calendar.ts
│ │ ├── fetch.ts
│ │ ├── fs.ts
│ │ ├── httpcache.ts
│ │ ├── random.ts
│ │ ├── sysconfig
│ │ │ ├── default.yml
│ │ │ ├── index.ts
│ │ │ └── types.d.ts
│ │ ├── types.d.ts
│ │ ├── userconfig
│ │ │ ├── default.json
│ │ │ ├── index.ts
│ │ │ └── types.d.ts
│ │ └── weather.ts
│ ├── translations
│ │ ├── de
│ │ │ ├── calendar.json
│ │ │ ├── clock.json
│ │ │ ├── common.json
│ │ │ ├── dashboard.json
│ │ │ ├── settings.json
│ │ │ └── weather.json
│ │ ├── en
│ │ │ ├── calendar.json
│ │ │ ├── clock.json
│ │ │ ├── common.json
│ │ │ ├── dashboard.json
│ │ │ ├── settings.json
│ │ │ └── weather.json
│ │ ├── es
│ │ │ ├── calendar.json
│ │ │ ├── clock.json
│ │ │ ├── common.json
│ │ │ ├── dashboard.json
│ │ │ ├── settings.json
│ │ │ └── weather.json
│ │ ├── index.ts
│ │ └── uk
│ │ │ ├── calendar.json
│ │ │ ├── clock.json
│ │ │ ├── common.json
│ │ │ ├── dashboard.json
│ │ │ ├── settings.json
│ │ │ └── weather.json
│ └── utils.ts
├── routes
│ ├── +error.svelte
│ ├── +layout.server.ts
│ ├── +layout.svelte
│ ├── +layout.ts
│ ├── +page.server.ts
│ ├── +page.svelte
│ ├── Header.svelte
│ ├── Tile.svelte
│ ├── TileFolder.svelte
│ ├── background
│ │ ├── +server.ts
│ │ ├── [slug]
│ │ │ └── +server.ts
│ │ └── wallpaper
│ │ │ └── [...wallpaper]
│ │ │ └── +server.ts
│ ├── calendar
│ │ ├── +page.server.ts
│ │ ├── +page.svelte
│ │ ├── CalendarList.svelte
│ │ ├── Widget.svelte
│ │ ├── entries
│ │ │ └── +server.ts
│ │ └── types.d.ts
│ ├── clock
│ │ ├── +layout.svelte
│ │ ├── +server.ts
│ │ ├── ClockFace.svelte
│ │ ├── Widget.svelte
│ │ ├── stopwatch
│ │ │ └── +page.svelte
│ │ ├── timer
│ │ │ ├── +page.svelte
│ │ │ └── NumberInput.svelte
│ │ └── utils.ts
│ ├── healthcheck
│ │ └── +server.ts
│ ├── logo
│ │ └── [slug]
│ │ │ └── +server.ts
│ ├── messages
│ │ ├── Widget.svelte
│ │ └── messages.css
│ ├── search
│ │ ├── +server.ts
│ │ └── Widget.svelte
│ ├── settings
│ │ ├── +layout.server.ts
│ │ ├── +layout.svelte
│ │ ├── +server.ts
│ │ ├── Message.svelte
│ │ ├── SaveButton.svelte
│ │ ├── admin
│ │ │ ├── +page.server.ts
│ │ │ └── +page.svelte
│ │ ├── background
│ │ │ ├── +page.server.ts
│ │ │ └── +page.svelte
│ │ ├── dashboard
│ │ │ ├── +page.server.ts
│ │ │ ├── +page.svelte
│ │ │ ├── TilesVisualizer.svelte
│ │ │ └── Toggle.svelte
│ │ ├── system
│ │ │ ├── +page.server.ts
│ │ │ └── +page.svelte
│ │ ├── utils.ts
│ │ └── weather
│ │ │ ├── +page.server.ts
│ │ │ └── +page.svelte
│ ├── sverdle
│ │ ├── +layout.svelte
│ │ ├── +page.server.ts
│ │ ├── +page.svelte
│ │ ├── game.ts
│ │ ├── how-to-play
│ │ │ └── +page.svelte
│ │ └── words.server.ts
│ └── weather
│ │ ├── +page.server.ts
│ │ ├── +page.svelte
│ │ ├── LineChart.svelte
│ │ ├── Widget.svelte
│ │ ├── current
│ │ └── +server.ts
│ │ └── types.d.ts
└── types.d.ts
├── static
├── fallback-logos
│ ├── adguard.png
│ ├── authelia.png
│ ├── cryptpad.svg
│ ├── cyberchef.png
│ ├── docker-registry.png
│ ├── drawio.svg
│ ├── drone.svg
│ ├── excalidraw.svg
│ ├── filebrowser.svg
│ ├── gitea.png
│ ├── gitlab.svg
│ ├── goaccess.svg
│ ├── gotify.svg
│ ├── grafana.svg
│ ├── hedgedoc.png
│ ├── influxdb.png
│ ├── invidious.svg
│ ├── jellyfin.png
│ ├── juiceshop.png
│ ├── languagetool.png
│ ├── librespeed.png
│ ├── libretranslate.svg
│ ├── linkwarden.png
│ ├── mealie.png
│ ├── miniflux.svg
│ ├── myspeed.png
│ ├── navidrome.png
│ ├── nextcloud.svg
│ ├── nitter.svg
│ ├── pgadmin.png
│ ├── phpmyadmin.svg
│ ├── pihole.svg
│ ├── portainer.svg
│ ├── postgres.png
│ ├── psitransfer.png
│ ├── radicale.svg
│ ├── rssbridge.png
│ ├── screego.png
│ ├── snapdrop.png
│ ├── stirlingpdf.svg
│ ├── syncthing.svg
│ ├── tandoor.svg
│ ├── traefik.svg
│ ├── vscode.svg
│ └── whoogle.png
├── favicon.png
└── robots.txt
├── svelte.config.js
├── tsconfig.json
└── vite.config.ts
/.dockerignore:
--------------------------------------------------------------------------------
1 | .idea
2 | .svelte-kit
3 | node_modules
4 | dev
5 | data
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | # public env vars required by sveltekit for build and check
2 | PUBLIC_BUILD_DATE=""
3 | PUBLIC_VERSION=""
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: 'npm' # See documentation for possible values
9 | directory: '/' # Location of package manifests
10 | schedule:
11 | interval: 'weekly'
12 | - package-ecosystem: 'docker'
13 | directory: '/'
14 | schedule:
15 | interval: 'weekly'
16 | - package-ecosystem: 'github-actions'
17 | directory: '/'
18 | schedule:
19 | interval: 'weekly'
20 |
--------------------------------------------------------------------------------
/.github/workflows/docker-image.yml:
--------------------------------------------------------------------------------
1 | name: Docker Image CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | tags:
8 | - 'v*.*.*'
9 | pull_request:
10 | # The branches below must be a subset of the branches above
11 | branches: [main]
12 |
13 | jobs:
14 | docker:
15 | runs-on: ubuntu-latest
16 | timeout-minutes: 30
17 | steps:
18 | - name: Check out the repo
19 | uses: actions/checkout@v4
20 |
21 | - name: Docker meta
22 | id: meta
23 | uses: docker/metadata-action@v5
24 | with:
25 | # list of Docker images to use as base name for tags
26 | images: |
27 | ghcr.io/${{ github.repository }}
28 | # ${{ github.repository }}
29 | # generate Docker tags based on the following events/attributes
30 | tags: |
31 | type=semver,pattern={{major}}.{{minor}}.{{patch}}
32 | type=semver,pattern={{major}}.{{minor}}
33 | type=semver,pattern={{major}}
34 | type=edge,branch=main
35 |
36 | - name: Set up QEMU
37 | uses: docker/setup-qemu-action@v3
38 |
39 | - name: Set up Docker Buildx
40 | uses: docker/setup-buildx-action@v3
41 |
42 | # - name: Log in to Docker Hub
43 | # if: github.event_name != 'pull_request'
44 | # uses: docker/login-action@v1
45 | # with:
46 | # username: ${{ secrets.DOCKER_USERNAME }}
47 | # password: ${{ secrets.DOCKER_TOKEN }}
48 |
49 | - name: Login to GHCR
50 | if: github.event_name != 'pull_request'
51 | uses: docker/login-action@v3
52 | with:
53 | registry: ghcr.io
54 | username: ${{ github.repository_owner }}
55 | password: ${{ secrets.GITHUB_TOKEN }}
56 |
57 | - name: Build and push
58 | uses: docker/build-push-action@v6
59 | with:
60 | context: .
61 | build-args: |
62 | PUBLIC_VERSION=${{ github.ref_name }}
63 | platforms: linux/amd64,linux/arm64
64 | push: ${{ github.event_name != 'pull_request' }}
65 | tags: ${{ steps.meta.outputs.tags }}
66 | labels: ${{ steps.meta.outputs.labels }}
67 |
68 | - name: Run Trivy vulnerability scanner
69 | uses: aquasecurity/trivy-action@master
70 | with:
71 | image-ref: "ghcr.io/${{ github.repository }}"
72 | format: 'sarif'
73 | output: 'trivy-results.sarif'
74 |
75 | - name: Upload Trivy scan results to GitHub Security tab
76 | uses: github/codeql-action/upload-sarif@v3
77 | with:
78 | sarif_file: 'trivy-results.sarif'
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
1 | name: Lint
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | # The branches below must be a subset of the branches above
9 | branches: [main]
10 |
11 | jobs:
12 | hadolint:
13 | runs-on: ubuntu-latest
14 | timeout-minutes: 15
15 | steps:
16 | - uses: actions/checkout@v4
17 | - uses: hadolint/hadolint-action@v3.1.0
18 | with:
19 | dockerfile: Dockerfile
20 | ignore: DL3018
21 |
22 | njsscan:
23 | runs-on: ubuntu-latest
24 | timeout-minutes: 15
25 | name: njsscan code scanning
26 | steps:
27 | - name: Checkout the code
28 | uses: actions/checkout@v4
29 | - name: nodejsscan scan
30 | id: njsscan
31 | uses: ajinabraham/njsscan-action@master
32 | with:
33 | args: '. --sarif --output results.sarif || true'
34 | - name: Upload njsscan report
35 | uses: github/codeql-action/upload-sarif@v3
36 | with:
37 | sarif_file: results.sarif
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Releases
2 |
3 | on:
4 | push:
5 | tags:
6 | - 'v*.*.*'
7 |
8 | jobs:
9 | build:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v4
13 | - id: imagetag
14 | run: |
15 | echo "DOCKER_IMAGE_TAG=$(echo ${{github.ref_name}} | cut -dv -f2)" >> $GITHUB_ENV
16 | - name: Create Release
17 | id: create_release
18 | if: github.event_name != 'pull_request'
19 | uses: actions/create-release@v1
20 | env:
21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
22 | with:
23 | tag_name: ${{ github.ref_name }}
24 | release_name: ${{ github.ref_name }}
25 | draft: false
26 | prerelease: false
27 | body: |
28 | Docker image: ghcr.io/${{ github.repository }}:${{ env.DOCKER_IMAGE_TAG }}
29 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /data
2 | .idea
3 | /dev/data
4 | /dev/config.yml
5 | /dev/secrets
6 |
7 | .DS_Store
8 | node_modules
9 | /build
10 | /.svelte-kit
11 | /package
12 | .env
13 | .env.*
14 | !.env.example
15 | !/.env
16 | .vercel
17 | .output
18 | vite.config.js.timestamp-*
19 | vite.config.ts.timestamp-*
20 |
21 |
22 |
23 | # Logs
24 | logs
25 | *.log
26 | npm-debug.log*
27 | yarn-debug.log*
28 | yarn-error.log*
29 | lerna-debug.log*
30 |
31 | # Diagnostic reports (https://nodejs.org/api/report.html)
32 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
33 |
34 | # Runtime data
35 | pids
36 | *.pid
37 | *.seed
38 | *.pid.lock
39 |
40 | # Directory for instrumented libs generated by jscoverage/JSCover
41 | lib-cov
42 |
43 | # Coverage directory used by tools like istanbul
44 | coverage
45 | *.lcov
46 |
47 | # nyc test coverage
48 | .nyc_output
49 |
50 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
51 | .grunt
52 |
53 | # Bower dependency directory (https://bower.io/)
54 | bower_components
55 |
56 | # node-waf configuration
57 | .lock-wscript
58 |
59 | # Compiled binary addons (https://nodejs.org/api/addons.html)
60 | build/Release
61 |
62 | # Dependency directories
63 | node_modules/
64 | jspm_packages/
65 |
66 | # TypeScript v1 declaration files
67 | typings/
68 |
69 | # TypeScript cache
70 | *.tsbuildinfo
71 |
72 | # Optional npm cache directory
73 | .npm
74 |
75 | # Optional eslint cache
76 | .eslintcache
77 |
78 | # Microbundle cache
79 | .rpt2_cache/
80 | .rts2_cache_cjs/
81 | .rts2_cache_es/
82 | .rts2_cache_umd/
83 |
84 | # Optional REPL history
85 | .node_repl_history
86 |
87 | # Output of 'npm pack'
88 | *.tgz
89 |
90 | # Yarn Integrity file
91 | .yarn-integrity
92 |
93 | # dotenv environment variables file
94 | .env.test
95 |
96 | # parcel-bundler cache (https://parceljs.org/)
97 | .cache
98 |
99 | # Next.js build output
100 | .next
101 |
102 | # Nuxt.js build / generate output
103 | .nuxt
104 | dist
105 |
106 | # Gatsby files
107 | .cache/
108 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
109 | # https://nextjs.org/blog/next-9-1#public-directory-support
110 | # public
111 |
112 | # vuepress build output
113 | .vuepress/dist
114 |
115 | # Serverless directories
116 | .serverless/
117 |
118 | # FuseBox cache
119 | .fusebox/
120 |
121 | # DynamoDB Local files
122 | .dynamodb/
123 |
124 | # TernJS port file
125 | .tern-port
126 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | engine-strict=true
2 | node-options="--unhandled-rejections=strict"
3 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | /build
4 | /.svelte-kit
5 | /package
6 | .env
7 | .env.*
8 | !.env.example
9 |
10 | # Ignore files for PNPM, NPM and YARN
11 | pnpm-lock.yaml
12 | package-lock.json
13 | yarn.lock
14 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "useTabs": false,
3 | "singleQuote": true,
4 | "trailingComma": "none",
5 | "printWidth": 160,
6 | "semi": false,
7 | "arrowParens": "avoid",
8 | "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
9 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
10 | }
11 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:24.0.2-alpine3.21 AS build
2 |
3 | COPY . /app/
4 | WORKDIR /app
5 |
6 | ARG PUBLIC_VERSION
7 | RUN PUBLIC_BUILD_DATE="$(date -Iseconds)" && \
8 | export PUBLIC_BUILD_DATE && \
9 | npm install && \
10 | npm audit --audit-level=high && \
11 | yarn licenses generate-disclaimer > static/3rdpartylicenses.txt && \
12 | NODE_ENV=development npm run check && \
13 | npm run lint:check && \
14 | npm run format:check && \
15 | NODE_ENV=production npm run build
16 |
17 |
18 |
19 | FROM node:24.0.2-alpine3.21 AS deps
20 |
21 | COPY package.json package-lock.json .npmrc /app/
22 | WORKDIR /app
23 |
24 | RUN NODE_ENV=production npm install --omit=dev
25 |
26 |
27 |
28 | FROM node:24.0.2-alpine3.21
29 |
30 | RUN apk update --no-cache && \
31 | apk add --no-cache curl
32 | ENV NODE_ENV=production
33 | ARG NODE_OPTIONS=""
34 | ENV NODE_OPTIONS="$NODE_OPTIONS --unhandled-rejections=strict"
35 |
36 | COPY --from=build --chown=node:node /app/build /app
37 | COPY --from=deps --chown=0:0 /app/node_modules /app/node_modules
38 | COPY --chown=0:0 package.json package-lock.json entrypoint.js .npmrc /app/
39 |
40 | VOLUME /data
41 | RUN mkdir -p /data && \
42 | chown -R node:node /data
43 |
44 | USER node
45 | WORKDIR /app
46 |
47 | EXPOSE 3000/tcp
48 |
49 | HEALTHCHECK --interval=30s --timeout=1s --retries=2 \
50 | CMD curl --fail --silent --output /dev/null --header "$HTTP_HEADER_USERID: healthcheck" "http://localhost:3000/healthcheck"
51 |
52 | CMD ["node", "/app/entrypoint.js"]
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | # one of debug, info, warn, error
61 | ENV LOG_LEVEL="info"
62 |
63 | # paths for app & user config
64 | ENV APP_CONFIG_FILE="/data/config.yml"
65 | ENV FAVICON_FILE="/data/favicon.png"
66 | ENV USERS_DIR="/data/users"
67 | ENV LOGOS_DIR="/data/logos"
68 | ENV WALLPAPER_DIR="/data/wallpaper"
69 |
70 | # userinfo headers set by the reverse proxy
71 | ENV HTTP_HEADER_USERID="Remote-User"
72 | ENV HTTP_HEADER_USERNAME="Remote-Name"
73 | ENV HTTP_HEADER_EMAIL="Remote-Email"
74 | ENV HTTP_HEADER_GROUPS="Remote-Groups"
75 | # per default groups are split by one of ,;:|
76 | ENV HTTP_HEADER_GROUPS_SEPARATOR=""
77 |
78 | # timeout for requests from server to third party apis, in millisecs
79 | ENV SERVER_REQUEST_FAILFAST_TIMEOUT="750"
80 | ENV SERVER_REQUEST_MAX_TIMEOUT="15000"
81 |
82 | # cache lifetime for requests from server to third party apis, in minutes
83 | ENV SERVER_REQUEST_CACHE_TTL="10"
84 |
85 | # Single User Mode disables web proxy authentication and ignores all HTTP_HEADER_* userinfo
86 | ENV SINGLE_USER_MODE="false"
87 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 knrdl
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/dev/Caddyfile.dev:
--------------------------------------------------------------------------------
1 | http://:8000 {
2 | bind 0.0.0.0
3 | reverse_proxy hubleys_app:5173 {
4 | header_up Remote-User "user1"
5 | header_up Remote-Email "user1@example.org"
6 | header_up Remote-Name "User1"
7 | header_up Remote-Groups "admin,group1"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/dev/Caddyfile.test:
--------------------------------------------------------------------------------
1 | http://:8080 {
2 | bind 0.0.0.0
3 | reverse_proxy hubleys_test_app:3000 {
4 | header_up Remote-User "user1"
5 | header_up Remote-Email "user1@example.org"
6 | header_up Remote-Name "User1"
7 | header_up Remote-Groups "admin,group1"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/dev/dev.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | podman network create hubleys_net
4 |
5 | podman run -dit --rm -p8000:8000 -p8080:8000 --name hubleys_proxy --net hubleys_net -v "$PWD/Caddyfile.dev:/etc/caddy/Caddyfile:ro" docker.io/caddy:alpine
6 |
7 | echo http://localhost:8000/
8 |
9 | mkdir -p ./userdata
10 | podman run -it --rm --name hubleys_app --net hubleys_net --env-file ./secrets \
11 | -v "$PWD/../:/app" -v "$PWD/data:/data" -w /app node:lts-alpine npm run dev
12 |
13 | podman stop hubleys_proxy
14 | podman network rm hubleys_net
15 |
--------------------------------------------------------------------------------
/dev/test.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | podman network create hubleys_test_net
4 |
5 | podman run -dit --rm -p8001:8000 --name hubleys_test_proxy --net hubleys_test_net -v "$PWD/Caddyfile.test:/etc/caddy/Caddyfile:ro" docker.io/caddy:alpine
6 |
7 | podman build -t hubleys-test ..
8 |
9 | echo http://localhost:8001/
10 |
11 | mkdir -p ./userdata
12 | podman run -it --rm --name hubleys_test_app --net hubleys_test_net --env-file ./secrets hubleys-test
13 |
14 | podman stop hubleys_test_proxy
15 | podman network rm hubleys_test_net
16 |
--------------------------------------------------------------------------------
/docs/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/docs/screenshot.png
--------------------------------------------------------------------------------
/entrypoint.js:
--------------------------------------------------------------------------------
1 | function shutdownGracefully() {
2 | console.debug('Server shutdown')
3 | app.server.close()
4 | process.exit(130)
5 | }
6 |
7 | process.on('SIGINT', shutdownGracefully)
8 | process.on('SIGTERM', shutdownGracefully)
9 |
10 |
11 |
12 | // See: https://github.com/sveltejs/kit/issues/6841#issuecomment-1330555730
13 |
14 | import { server as app } from './index.js' // created by sveltekit on build
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import typescriptEslint from "@typescript-eslint/eslint-plugin"
2 | import globals from "globals"
3 | import tsParser from "@typescript-eslint/parser"
4 | import parser from "svelte-eslint-parser"
5 | import path from "node:path"
6 | import { fileURLToPath } from "node:url"
7 | import js from "@eslint/js"
8 | import { FlatCompat } from "@eslint/eslintrc"
9 |
10 | const __filename = fileURLToPath(import.meta.url)
11 | const __dirname = path.dirname(__filename)
12 | const compat = new FlatCompat({
13 | baseDirectory: __dirname,
14 | recommendedConfig: js.configs.recommended,
15 | allConfig: js.configs.all
16 | })
17 |
18 | export default [{
19 | ignores: [
20 | "**/.DS_Store",
21 | "**/node_modules",
22 | "build",
23 | ".svelte-kit",
24 | "package",
25 | "**/.env",
26 | "**/.env.*",
27 | "!**/.env.example",
28 | "**/pnpm-lock.yaml",
29 | "**/package-lock.json",
30 | "**/yarn.lock",
31 | ],
32 | }, ...compat.extends(
33 | "eslint:recommended",
34 | "plugin:@typescript-eslint/recommended",
35 | "plugin:svelte/recommended",
36 | "prettier",
37 | ), {
38 | plugins: {
39 | "@typescript-eslint": typescriptEslint,
40 | },
41 |
42 | languageOptions: {
43 | globals: {
44 | ...globals.browser,
45 | ...globals.node,
46 | },
47 |
48 | parser: tsParser,
49 | ecmaVersion: 2020,
50 | sourceType: "module",
51 |
52 | parserOptions: {
53 | extraFileExtensions: [".svelte"],
54 | },
55 | },
56 |
57 | rules: {
58 | "@typescript-eslint/no-explicit-any": "off",
59 | "@typescript-eslint/no-unused-vars": [
60 | "error",
61 | {
62 | "args": "all",
63 | "argsIgnorePattern": "^_",
64 | "caughtErrors": "all",
65 | "caughtErrorsIgnorePattern": "^_",
66 | "destructuredArrayIgnorePattern": "^_",
67 | "varsIgnorePattern": "^_",
68 | "ignoreRestSiblings": true
69 | }
70 | ]
71 | },
72 | }, {
73 | files: ["**/*.svelte"],
74 |
75 | languageOptions: {
76 | parser: parser,
77 | ecmaVersion: 5,
78 | sourceType: "script",
79 |
80 | parserOptions: {
81 | parser: "@typescript-eslint/parser",
82 | },
83 | },
84 | }]
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hubleys",
3 | "version": "0.0.0",
4 | "scripts": {
5 | "dev": "vite dev",
6 | "build": "vite build",
7 | "preview": "vite preview",
8 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
9 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
10 | "lint:check": "eslint .",
11 | "lint:fix": "eslint . --fix",
12 | "format:check": "prettier --check src",
13 | "format:fix": "prettier --write src"
14 | },
15 | "devDependencies": {
16 | "@fortawesome/free-brands-svg-icons": "^6.6.0",
17 | "@fortawesome/free-solid-svg-icons": "^6.6.0",
18 | "@sveltejs/adapter-node": "^5.2.12",
19 | "@sveltejs/kit": "^2.21.0",
20 | "@sveltejs/vite-plugin-svelte": "^3.1.2",
21 | "@tailwindcss/postcss": "^4.1.6",
22 | "@tailwindcss/vite": "^4.1.7",
23 | "@tsparticles/svelte": "^3.1.1",
24 | "@types/cookie": "^0.6.0",
25 | "@types/js-yaml": "^4.0.9",
26 | "@types/lodash.debounce": "^4.0.9",
27 | "@types/node": "^22.15.21",
28 | "@typescript-eslint/eslint-plugin": "^8.24.1",
29 | "@typescript-eslint/parser": "^8.32.1",
30 | "eslint": "^9.25.0",
31 | "eslint-config-prettier": "^10.0.1",
32 | "eslint-plugin-svelte": "^2.46.0",
33 | "lodash.debounce": "^4.0.8",
34 | "mrmime": "^2.0.0",
35 | "prettier": "^3.5.0",
36 | "prettier-plugin-svelte": "^3.4.0",
37 | "prettier-plugin-tailwindcss": "^0.6.11",
38 | "svelte": "^4.2.19",
39 | "svelte-check": "^4.1.7",
40 | "svelte-fa": "^4.0.4",
41 | "sveltekit-i18n": "^2.4.2",
42 | "tailwindcss": "^4.1.7",
43 | "tslib": "^2.7.0",
44 | "typescript": "^5.8.3",
45 | "vite": "^5.4.19"
46 | },
47 | "dependencies": {
48 | "@victorioberra/trianglify-browser": "^4.1.1",
49 | "ical.js": "^2.0.1",
50 | "js-yaml": "^4.1.0",
51 | "tsparticles": "^3.8.1"
52 | },
53 | "type": "module"
54 | }
55 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | '@tailwindcss/postcss': {},
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/src/app.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 |
3 | @custom-variant dark (&:is(.dark *));
4 |
5 | /*
6 | The default border color has changed to `currentColor` in Tailwind CSS v4,
7 | so we've added these compatibility styles to make sure everything still
8 | looks the same as it did with Tailwind CSS v3.
9 |
10 | If we ever want to remove these styles, we need to add an explicit border
11 | color utility to any element that depends on these defaults.
12 | */
13 | @layer base {
14 | *,
15 | ::after,
16 | ::before,
17 | ::backdrop,
18 | ::file-selector-button {
19 | border-color: var(--color-gray-200, currentColor);
20 | }
21 | }
22 |
23 | :root {
24 | font-family:
25 | Arial,
26 | -apple-system,
27 | BlinkMacSystemFont,
28 | 'Segoe UI',
29 | Roboto,
30 | Oxygen,
31 | Ubuntu,
32 | Cantarell,
33 | 'Open Sans',
34 | 'Helvetica Neue',
35 | sans-serif;
36 | }
37 |
38 | @media (prefers-color-scheme: dark) {
39 | body {
40 | background: #121212;
41 | }
42 | }
43 |
44 | @media (prefers-color-scheme: light) {
45 | body {
46 | background: #fefefe;
47 | }
48 | }
49 |
50 | /*hide number input arrows*/
51 | input::-webkit-outer-spin-button,
52 | input::-webkit-inner-spin-button {
53 | -webkit-appearance: none;
54 | margin: 0;
55 | }
56 |
57 | input[type='number'] {
58 | -moz-appearance: textfield;
59 | }
60 |
61 | /*chrome won't display form controls correctly, so we have to build them manually :/ */
62 |
63 | input[type='radio'] {
64 | @apply h-4 w-4 cursor-pointer border border-gray-400 accent-blue-600;
65 | appearance: none;
66 | border-radius: 50%;
67 | }
68 |
69 | input[type='radio']:checked {
70 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e");
71 | background-repeat: no-repeat;
72 | background-position: center;
73 | background-size: contain;
74 | @apply border-blue-600 bg-blue-600;
75 | }
76 |
77 | input[type='checkbox'] {
78 | @apply h-4 w-4 cursor-pointer border border-gray-400 accent-blue-600;
79 | appearance: none;
80 | border-radius: 0.25em;
81 | }
82 |
83 | input[type='checkbox']:checked {
84 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e");
85 | background-repeat: no-repeat;
86 | background-position: center;
87 | background-size: contain;
88 | @apply border-blue-600 bg-blue-600;
89 | }
90 |
91 | select {
92 | background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg aria-hidden='true' xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 10 6'%3E%3Cpath stroke='%23999' stroke-linecap='round' stroke-linejoin='round' stroke-width='1' d='m1 1 4 4 4-4'/%3E%3C/svg%3E");
93 | background-repeat: no-repeat;
94 | background-position: right 0.75rem center;
95 | background-size: 12px 9px;
96 | appearance: none;
97 | padding-right: 1.8rem;
98 | }
99 |
--------------------------------------------------------------------------------
/src/app.d.ts:
--------------------------------------------------------------------------------
1 | // See https://kit.svelte.dev/docs/types#app
2 | // for information about these interfaces
3 |
4 | import type { Sysconfig } from '$lib/server/sysconfig/types'
5 | import type { UserConfig } from '$lib/server/userconfig/types'
6 | import type { RequestUserInfo } from '$lib/server/types'
7 |
8 | declare global {
9 | namespace App {
10 | // interface Error {}
11 | interface Locals {
12 | user: RequestUserInfo
13 | sysConfig: Sysconfig
14 | userConfig: UserConfig
15 | }
16 | // interface PageData {}
17 | // interface PageState {}
18 | // interface Platform {}
19 | }
20 | }
21 |
22 | export {}
23 |
--------------------------------------------------------------------------------
/src/app.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Hubleys
8 |
9 | %sveltekit.head%
10 |
11 |
12 |
13 | %sveltekit.body%
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/hooks.server.ts:
--------------------------------------------------------------------------------
1 | import { sysConfig, reloadSysConfig } from '$lib/server/sysconfig'
2 | import { error, type RequestEvent } from '@sveltejs/kit'
3 | import { getUserConfig, initDefaultUserConfig, runUserConfigMigrations } from '$lib/server/userconfig'
4 | import { canWrite, isDir, isFile } from '$lib/server/fs'
5 | import type { RequestUserInfo } from '$lib/server/types'
6 | import { env } from '$env/dynamic/private'
7 | import path from 'node:path'
8 | import { copyFile, mkdir } from 'node:fs/promises'
9 |
10 | function getConfiguredUserLang(ev: RequestEvent) {
11 | if (ev.locals.userConfig.language === null) return (ev.request.headers.get('accept-language') || 'en').split(/[,;]/)[0]
12 | else return ev.locals.userConfig.language
13 | }
14 |
15 | function sanitizeHeader(ev: RequestEvent, header: string | undefined) {
16 | if (header) {
17 | const val = ev.request.headers.get(header) || ''
18 | return val.split('\n', 1).shift()?.trim() || ''
19 | }
20 | }
21 |
22 | export async function handle({ event, resolve }) {
23 | if (sysConfig.single_user_mode) {
24 | const userid = 'defaultuser'
25 | event.locals.userConfig = await getUserConfig(userid)
26 | event.locals.user = {
27 | userid,
28 | email: null,
29 | username: null,
30 | groups: [],
31 | isAdmin: true,
32 | lang: getConfiguredUserLang(event)
33 | }
34 | event.locals.sysConfig = sysConfig
35 | return resolve(event)
36 | } else {
37 | const userid = sanitizeHeader(event, sysConfig.userHttpHeaders.userid)
38 | if (userid && userid.length > 0) {
39 | const groups = (sanitizeHeader(event, sysConfig.userHttpHeaders.groups) || '')
40 | .split(sysConfig.userHttpHeaders.groups_separator || /\s*[,;:|]\s*/)
41 | .map(group => group.trim())
42 | .filter(group => !!group)
43 | event.locals.userConfig = await getUserConfig(userid)
44 | event.locals.user = {
45 | userid,
46 | email: sanitizeHeader(event, sysConfig.userHttpHeaders.email) || null,
47 | username: sanitizeHeader(event, sysConfig.userHttpHeaders.username) || null,
48 | groups,
49 | isAdmin: sysConfig.admins.includes('user:' + userid) || groups.some(group => sysConfig.admins.includes('group:' + group)),
50 | lang: getConfiguredUserLang(event)
51 | } as RequestUserInfo
52 | event.locals.sysConfig = sysConfig
53 | return resolve(event)
54 | } else {
55 | error(500, 'forward auth not configured')
56 | }
57 | }
58 | }
59 |
60 | function applyLogLevels() {
61 | type LogLevel = 'debug' | 'info' | 'warn' | 'error'
62 | const logLevels: LogLevel[] = ['debug', 'info', 'warn', 'error']
63 | const shouldLog = (level: LogLevel) => logLevels.indexOf(level) >= logLevels.indexOf(sysConfig.logLevel)
64 | const _console = global.console
65 | global.console = {
66 | ..._console,
67 | debug: (...params: any[]) => shouldLog('debug') && _console.debug(...params),
68 | info: (...params: any[]) => shouldLog('info') && _console.info(...params),
69 | warn: (...params: any[]) => shouldLog('warn') && _console.warn(...params),
70 | error: (...params: any[]) => shouldLog('error') && _console.error(...params)
71 | }
72 | }
73 |
74 | async function onServerStartup() {
75 | async function ensureDirExists(dirpath: string) {
76 | if (await isDir(dirpath)) {
77 | const writable = await canWrite(dirpath)
78 | if (!writable) {
79 | console.error(`Missing write permission for folder "${dirpath}". The folder must be writable by the user with uid=1000.`)
80 | process.exit(1)
81 | }
82 | } else {
83 | try {
84 | await mkdir(dirpath, { recursive: true })
85 | } catch (e) {
86 | console.error(`Error creating folder "${dirpath}":`)
87 | console.error(e)
88 | console.error('The parent folder must be writable by the user with uid=1000 or create the folder manually.')
89 | process.exit(1)
90 | }
91 | }
92 | }
93 |
94 | await Promise.all([
95 | ensureDirExists(path.join(env.USERS_DIR!, 'backgrounds')),
96 | ensureDirExists(path.join(env.USERS_DIR!, 'config')),
97 | ensureDirExists(env.LOGOS_DIR!),
98 | (async () => {
99 | if (await isFile(env.FAVICON_FILE!)) await copyFile(env.FAVICON_FILE!, '/app/client/favicon.png')
100 | })()
101 | ])
102 |
103 | await reloadSysConfig()
104 | applyLogLevels()
105 |
106 | await Promise.all([initDefaultUserConfig(), runUserConfigMigrations()])
107 | console.debug('up and running')
108 | }
109 |
110 | await onServerStartup()
111 |
--------------------------------------------------------------------------------
/src/lib/backgrounds/Trianglify.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/lib/backgrounds/effects.ts:
--------------------------------------------------------------------------------
1 | export const blurLight = 'linear-gradient(to right, rgba(245, 235, 235, 0.6), rgba(255, 24, 49, 0))'
2 | export const blurDark = 'linear-gradient(to right, rgba(5, 8, 101, 0.8), rgba(0, 24, 49, 0))'
3 | export const dotMatrix =
4 | "url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkxheWVyXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeD0iMHB4IiB5PSIwcHgiIHdpZHRoPSI2cHgiIGhlaWdodD0iNnB4IiB2aWV3Qm94PSIwIDAgNiA2IiBzdHlsZT0iZW5hYmxlLWJhY2tncm91bmQ6bmV3IDAgMCA2IDY7IiB4bWw6c3BhY2U9InByZXNlcnZlIj48c3R5bGUgdHlwZT0idGV4dC9jc3MiPi5zdDB7ZmlsbDojRkZGRkZGO308L3N0eWxlPjxyZWN0IGNsYXNzPSJzdDAiIHdpZHRoPSIxIiBoZWlnaHQ9IjEiLz48L3N2Zz4K')"
5 |
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/Background.svelte:
--------------------------------------------------------------------------------
1 |
22 |
23 | {#if particlesName}
24 | {#await getParticles(particlesName) then particles}
25 | {#if ParticlesComponent}
26 |
30 | {/if}
31 | {/await}
32 | {/if}
33 |
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/Thumbnail.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 | {#if particlesName === 'bubbles'}
8 | {#await import('./thumbnails/bubbles.png') then thumbnail}
9 |
10 | {/await}
11 | {:else if particlesName === 'circles'}
12 | {#await import('./thumbnails/circles.png') then thumbnail}
13 |
14 | {/await}
15 | {:else if particlesName === 'drizzle'}
16 | {#await import('./thumbnails/drizzle.png') then thumbnail}
17 |
18 | {/await}
19 | {:else if particlesName === 'leaves'}
20 | {#await import('./thumbnails/leaves.png') then thumbnail}
21 |
22 | {/await}
23 | {:else if particlesName === 'rain'}
24 | {#await import('./thumbnails/rain.png') then thumbnail}
25 |
26 | {/await}
27 | {:else if particlesName === 'snow'}
28 | {#await import('./thumbnails/snow.png') then thumbnail}
29 |
30 | {/await}
31 | {:else if particlesName === 'squares'}
32 | {#await import('./thumbnails/squares.png') then thumbnail}
33 |
34 | {/await}
35 | {:else if particlesName === 'stars'}
36 | {#await import('./thumbnails/stars.png') then thumbnail}
37 |
38 | {/await}
39 | {:else if particlesName === 'triangles'}
40 | {#await import('./thumbnails/triangles.png') then thumbnail}
41 |
42 | {/await}
43 | {:else}
44 | missing thumbnail
45 | {/if}
46 |
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/definitions/bubbles.json:
--------------------------------------------------------------------------------
1 | {
2 | "emitters": {
3 | "direction": "top",
4 | "size": {
5 | "width": 100,
6 | "height": 0
7 | },
8 | "position": {
9 | "x": 50,
10 | "y": 100
11 | },
12 | "rate": {
13 | "delay": 0.5,
14 | "quantity": 2
15 | }
16 | },
17 | "particles": {
18 | "number": {
19 | "value": 0,
20 | "density": {
21 | "enable": true,
22 | "valueArea": 800
23 | }
24 | },
25 | "color": {
26 | "value": "#ffffff"
27 | },
28 | "shape": {
29 | "type": "circle"
30 | },
31 | "opacity": {
32 | "value": {
33 | "min": 0,
34 | "max": 0.5
35 | }
36 | },
37 | "size": {
38 | "value": 20,
39 | "random": false,
40 | "anim": {
41 | "enable": true,
42 | "speed": 5,
43 | "size_min": 5,
44 | "sync": true,
45 | "startValue": "min",
46 | "destroy": "max"
47 | }
48 | },
49 | "move": {
50 | "enable": true,
51 | "speed": 5,
52 | "direction": "none",
53 | "random": false,
54 | "straight": false,
55 | "outModes": {
56 | "default": "destroy"
57 | }
58 | }
59 | },
60 | "interactivity": {
61 | "detectsOn": "canvas",
62 | "events": {
63 | "onHover": {
64 | "enable": true,
65 | "mode": "repulse"
66 | },
67 | "onClick": {
68 | "enable": true,
69 | "mode": "pause"
70 | },
71 | "resize": true
72 | }
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/definitions/circles.json:
--------------------------------------------------------------------------------
1 | {
2 | "interactivity": {
3 | "detectsOn": "canvas",
4 | "events": {
5 | "onHover": {
6 | "enable": true,
7 | "mode": "repulse",
8 | "parallax": {
9 | "enable": false,
10 | "force": 1,
11 | "smooth": 1
12 | }
13 | },
14 | "onClick": {
15 | "enable": true,
16 | "mode": "pause"
17 | },
18 | "resize": true
19 | }
20 | },
21 | "particles": {
22 | "number": {
23 | "value": 80,
24 | "density": {
25 | "enable": true,
26 | "area": 800
27 | }
28 | },
29 | "color": {
30 | "value": "#ffffff"
31 | },
32 | "shape": {
33 | "type": "circle"
34 | },
35 | "opacity": {
36 | "value": {
37 | "min": 0,
38 | "max": 0.5
39 | }
40 | },
41 | "size": {
42 | "value": {
43 | "min": 10,
44 | "max": 25
45 | },
46 | "random": {
47 | "enable": true,
48 | "minimumValue": 10
49 | }
50 | },
51 | "move": {
52 | "enable": true,
53 | "speed": 0.5,
54 | "direction": "none",
55 | "random": false,
56 | "straight": false
57 | }
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/definitions/drizzle.json:
--------------------------------------------------------------------------------
1 | {
2 | "emitters": {
3 | "direction": "bottom",
4 | "size": {
5 | "width": 100,
6 | "height": 0
7 | },
8 | "position": {
9 | "x": 50,
10 | "y": 0
11 | },
12 | "rate": {
13 | "delay": 0.5,
14 | "quantity": 20
15 | }
16 | },
17 | "particles": {
18 | "number": {
19 | "value": 0
20 | },
21 | "color": {
22 | "value": "#0000ff"
23 | },
24 | "shape": {
25 | "type": "square",
26 | "options": {
27 | "square": {
28 | "fill": true,
29 | "particles": {
30 | "stroke": {
31 | "color": {
32 | "value": ["#ccf", "#ddf", "#eef", "#fff"]
33 | },
34 | "width": 1
35 | }
36 | }
37 | }
38 | }
39 | },
40 | "opacity": {
41 | "value": {
42 | "min": 0.5,
43 | "max": 0.9
44 | }
45 | },
46 | "size": {
47 | "value": {
48 | "min": 0.1,
49 | "max": 0.9
50 | },
51 | "random": true
52 | },
53 | "move": {
54 | "enable": true,
55 | "speed": {
56 | "min": 10,
57 | "max": 30
58 | },
59 | "direction": "none",
60 | "random": true,
61 | "straight": true,
62 | "outModes": {
63 | "default": "destroy"
64 | }
65 | }
66 | },
67 | "interactivity": {
68 | "detectsOn": "canvas",
69 | "events": {
70 | "onHover": {
71 | "enable": true,
72 | "mode": "repulse"
73 | },
74 | "onClick": {
75 | "enable": true,
76 | "mode": "pause"
77 | },
78 | "resize": true
79 | },
80 | "modes": {
81 | "repulse": {
82 | "distance": 75,
83 | "duration": 1
84 | }
85 | }
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/definitions/leaves.json:
--------------------------------------------------------------------------------
1 | {
2 | "particles": {
3 | "shape": {
4 | "type": "emoji",
5 | "options": {
6 | "emoji": {
7 | "value": "🍂"
8 | }
9 | }
10 | },
11 | "life": {
12 | "duration": {
13 | "value": 0
14 | }
15 | },
16 | "number": {
17 | "value": 200,
18 | "max": 0,
19 | "density": {
20 | "enable": true
21 | }
22 | },
23 | "move": {
24 | "gravity": {
25 | "enable": false
26 | },
27 | "decay": 0,
28 | "direction": "bottom",
29 | "speed": 1,
30 | "outModes": "out"
31 | },
32 | "size": {
33 | "value": 12
34 | },
35 | "opacity": {
36 | "value": 1,
37 | "animation": {
38 | "enable": false
39 | }
40 | },
41 | "rotate": {
42 | "value": {
43 | "min": 0,
44 | "max": 180
45 | },
46 | "direction": "random",
47 | "move": true,
48 | "animation": {
49 | "enable": true,
50 | "speed": 10
51 | }
52 | },
53 | "tilt": {
54 | "direction": "random",
55 | "enable": true,
56 | "move": true,
57 | "value": {
58 | "min": 0,
59 | "max": 180
60 | },
61 | "animation": {
62 | "enable": true,
63 | "speed": 30
64 | }
65 | },
66 | "roll": {
67 | "darken": {
68 | "enable": true,
69 | "value": 30
70 | },
71 | "enlighten": {
72 | "enable": true,
73 | "value": 30
74 | },
75 | "enable": true,
76 | "mode": "both",
77 | "speed": {
78 | "min": 1,
79 | "max": 25
80 | }
81 | },
82 | "wobble": {
83 | "distance": 10,
84 | "enable": true,
85 | "move": true,
86 | "speed": {
87 | "min": -15,
88 | "max": 1
89 | }
90 | }
91 | },
92 | "interactivity": {
93 | "detectsOn": "canvas",
94 | "events": {
95 | "onHover": {
96 | "enable": true,
97 | "mode": "repulse"
98 | },
99 | "onClick": {
100 | "enable": true,
101 | "mode": "pause"
102 | },
103 | "resize": true
104 | },
105 | "modes": {
106 | "repulse": {
107 | "distance": 100,
108 | "duration": 0.4
109 | }
110 | }
111 | }
112 | }
113 |
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/definitions/rain.json:
--------------------------------------------------------------------------------
1 | {
2 | "emitters": {
3 | "direction": "bottom",
4 | "size": {
5 | "width": 100,
6 | "height": 0
7 | },
8 | "position": {
9 | "x": 50,
10 | "y": 0
11 | },
12 | "rate": {
13 | "delay": 0.1,
14 | "quantity": 20
15 | }
16 | },
17 | "particles": {
18 | "number": {
19 | "value": 0
20 | },
21 | "color": {
22 | "value": "#0000ff"
23 | },
24 | "shape": {
25 | "type": "emoji",
26 | "options": {
27 | "emoji": {
28 | "value": "💧"
29 | }
30 | }
31 | },
32 | "opacity": {
33 | "value": {
34 | "min": 0.5,
35 | "max": 0.9
36 | }
37 | },
38 | "size": {
39 | "value": 4,
40 | "random": true
41 | },
42 | "move": {
43 | "enable": true,
44 | "speed": 15,
45 | "direction": "none",
46 | "random": false,
47 | "straight": true,
48 | "outModes": {
49 | "default": "destroy"
50 | }
51 | }
52 | },
53 | "interactivity": {
54 | "detectsOn": "canvas",
55 | "events": {
56 | "onHover": {
57 | "enable": true,
58 | "mode": "repulse"
59 | },
60 | "onClick": {
61 | "enable": true,
62 | "mode": "pause"
63 | },
64 | "resize": true
65 | },
66 | "modes": {
67 | "repulse": {
68 | "distance": 75,
69 | "duration": 1
70 | }
71 | }
72 | }
73 | }
74 |
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/definitions/snow.json:
--------------------------------------------------------------------------------
1 | {
2 | "emitters": {
3 | "direction": "bottom",
4 | "size": {
5 | "width": 100,
6 | "height": 0
7 | },
8 | "position": {
9 | "x": 50,
10 | "y": 0
11 | },
12 | "rate": {
13 | "delay": 0.1,
14 | "quantity": 2
15 | }
16 | },
17 | "particles": {
18 | "number": {
19 | "value": 0,
20 | "density": {
21 | "enable": true,
22 | "valueArea": 800
23 | }
24 | },
25 | "color": {
26 | "value": "#ffffff"
27 | },
28 | "shape": {
29 | "type": "circle"
30 | },
31 | "opacity": {
32 | "value": {
33 | "min": 0,
34 | "max": 0.5
35 | }
36 | },
37 | "size": {
38 | "value": 20,
39 | "random": false,
40 | "anim": {
41 | "enable": true,
42 | "speed": 5,
43 | "size_min": 5,
44 | "sync": true,
45 | "startValue": "min",
46 | "destroy": "max"
47 | }
48 | },
49 | "move": {
50 | "enable": true,
51 | "speed": 5,
52 | "direction": "none",
53 | "random": false,
54 | "straight": false,
55 | "outModes": {
56 | "default": "destroy"
57 | }
58 | }
59 | },
60 | "interactivity": {
61 | "detectsOn": "canvas",
62 | "events": {
63 | "onHover": {
64 | "enable": true,
65 | "mode": "repulse"
66 | },
67 | "onClick": {
68 | "enable": true,
69 | "mode": "pause"
70 | },
71 | "resize": true
72 | }
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/definitions/squares.json:
--------------------------------------------------------------------------------
1 | {
2 | "emitters": {
3 | "direction": "none",
4 | "size": {
5 | "width": 0,
6 | "height": 0
7 | },
8 | "position": {
9 | "x": 50,
10 | "y": 50
11 | },
12 | "rate": {
13 | "delay": 0.1,
14 | "quantity": 1
15 | }
16 | },
17 | "particles": {
18 | "number": {
19 | "value": 0
20 | },
21 | "color": {
22 | "value": "#ffffff"
23 | },
24 | "shape": {
25 | "type": "square",
26 | "options": {
27 | "square": {
28 | "fill": false,
29 | "particles": {
30 | "stroke": {
31 | "color": {
32 | "value": ["#f00", "#0f0", "#00f"]
33 | },
34 | "width": 1
35 | }
36 | }
37 | }
38 | }
39 | },
40 | "opacity": {
41 | "value": {
42 | "min": 0.01,
43 | "max": 0.3
44 | },
45 | "random": true
46 | },
47 | "rotate": {
48 | "value": 0,
49 | "random": true,
50 | "direction": "random",
51 | "animation": {
52 | "enable": true,
53 | "speed": 10,
54 | "sync": false
55 | }
56 | },
57 | "size": {
58 | "value": {
59 | "min": 1,
60 | "max": 10
61 | },
62 | "animation": {
63 | "enable": true,
64 | "speed": 1,
65 | "sync": false
66 | },
67 | "random": true
68 | },
69 | "move": {
70 | "enable": true,
71 | "speed": 1,
72 | "direction": "none",
73 | "random": true,
74 | "straight": false,
75 | "outModes": {
76 | "default": "destroy"
77 | }
78 | }
79 | },
80 | "interactivity": {
81 | "detectsOn": "canvas",
82 | "events": {
83 | "onHover": {
84 | "enable": true,
85 | "mode": "repulse"
86 | },
87 | "onClick": {
88 | "enable": true,
89 | "mode": "pause"
90 | },
91 | "resize": true
92 | }
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/definitions/stars.json:
--------------------------------------------------------------------------------
1 | {
2 | "emitters": {
3 | "direction": "none",
4 | "size": {
5 | "width": 0,
6 | "height": 0
7 | },
8 | "position": {
9 | "x": 50,
10 | "y": 50
11 | },
12 | "rate": {
13 | "delay": 0.1,
14 | "quantity": 1
15 | }
16 | },
17 | "particles": {
18 | "number": {
19 | "value": 0,
20 | "density": {
21 | "enable": true,
22 | "valueArea": 800
23 | }
24 | },
25 | "color": {
26 | "value": "#ffffff"
27 | },
28 | "shape": {
29 | "type": "star",
30 | "polygon": {
31 | "sides": 7
32 | }
33 | },
34 | "opacity": {
35 | "value": {
36 | "min": 0,
37 | "max": 0.5
38 | }
39 | },
40 | "size": {
41 | "value": {
42 | "min": 1,
43 | "max": 7
44 | },
45 | "random": true
46 | },
47 | "move": {
48 | "enable": true,
49 | "speed": 5,
50 | "direction": "none",
51 | "random": false,
52 | "size": true,
53 | "straight": true,
54 | "outModes": {
55 | "default": "destroy"
56 | }
57 | }
58 | },
59 | "interactivity": {
60 | "detectsOn": "canvas",
61 | "events": {
62 | "onClick": {
63 | "enable": true,
64 | "mode": "pause"
65 | },
66 | "resize": true
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/definitions/triangles.json:
--------------------------------------------------------------------------------
1 | {
2 | "interactivity": {
3 | "detectsOn": "canvas",
4 | "events": {
5 | "onHover": {
6 | "enable": true,
7 | "mode": "repulse",
8 | "parallax": {
9 | "enable": true,
10 | "force": 100,
11 | "smooth": 100
12 | }
13 | },
14 | "onClick": {
15 | "enable": true,
16 | "mode": "pause"
17 | },
18 | "resize": true
19 | }
20 | },
21 | "particles": {
22 | "number": {
23 | "value": 80,
24 | "density": {
25 | "enable": true,
26 | "valueArea": 800
27 | }
28 | },
29 | "color": {
30 | "value": "#ffffff"
31 | },
32 | "shape": {
33 | "type": "circle"
34 | },
35 | "opacity": {
36 | "value": {
37 | "min": 0.1,
38 | "max": 0.5
39 | }
40 | },
41 | "size": {
42 | "value": 3,
43 | "random": true
44 | },
45 | "links": {
46 | "enable": true,
47 | "distance": 100,
48 | "color": "#ffffff",
49 | "opacity": 0.4,
50 | "width": 1
51 | },
52 | "move": {
53 | "enable": true,
54 | "speed": 1,
55 | "direction": "none",
56 | "random": false,
57 | "straight": true
58 | }
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/index.ts:
--------------------------------------------------------------------------------
1 | import type { ParticlesName } from './types'
2 |
3 | export const particles: Record = {
4 | bubbles: async () => (await import('./definitions/bubbles.json')).default,
5 | circles: async () => (await import('./definitions/circles.json')).default,
6 | drizzle: async () => (await import('./definitions/drizzle.json')).default,
7 | leaves: async () => (await import('./definitions/leaves.json')).default,
8 | rain: async () => (await import('./definitions/rain.json')).default,
9 | snow: async () => (await import('./definitions/snow.json')).default,
10 | squares: async () => (await import('./definitions/squares.json')).default,
11 | stars: async () => (await import('./definitions/stars.json')).default,
12 | triangles: async () => (await import('./definitions/triangles.json')).default
13 | }
14 |
15 | export function getParticlesList() {
16 | return Object.keys(particles) as ParticlesName[]
17 | }
18 |
19 | export async function getParticles(name: ParticlesName) {
20 | if (particles[name] instanceof Function) {
21 | particles[name] = await particles[name]()
22 | }
23 | return particles[name] as any
24 | }
25 |
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/thumbnails/bubbles.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/src/lib/backgrounds/particles/thumbnails/bubbles.png
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/thumbnails/circles.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/src/lib/backgrounds/particles/thumbnails/circles.png
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/thumbnails/drizzle.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/src/lib/backgrounds/particles/thumbnails/drizzle.png
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/thumbnails/leaves.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/src/lib/backgrounds/particles/thumbnails/leaves.png
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/thumbnails/rain.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/src/lib/backgrounds/particles/thumbnails/rain.png
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/thumbnails/snow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/src/lib/backgrounds/particles/thumbnails/snow.png
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/thumbnails/squares.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/src/lib/backgrounds/particles/thumbnails/squares.png
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/thumbnails/stars.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/src/lib/backgrounds/particles/thumbnails/stars.png
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/thumbnails/triangles.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/src/lib/backgrounds/particles/thumbnails/triangles.png
--------------------------------------------------------------------------------
/src/lib/backgrounds/particles/types.d.ts:
--------------------------------------------------------------------------------
1 | export type ParticlesName = 'bubbles' | 'circles' | 'drizzle' | 'leaves' | 'rain' | 'snow' | 'squares' | 'stars' | 'triangles'
2 |
--------------------------------------------------------------------------------
/src/lib/backgrounds/random.ts:
--------------------------------------------------------------------------------
1 | import { sysConfig } from '$lib/server/sysconfig'
2 | import { fetchTimeout } from '$lib/server/fetch'
3 | import path from 'node:path'
4 | import cache from '$lib/server/httpcache'
5 | import { chooseRandom } from '$lib/server/random'
6 | import { readdir } from 'node:fs/promises'
7 | import { isDir } from '$lib/server/fs'
8 | import { env } from '$env/dynamic/private'
9 |
10 | export async function queryBgImgUrlReddit({ subreddits, failfast }: { subreddits: string; failfast: boolean }) {
11 | const fetchPosts = async (subreddits: string[]) => {
12 | const url = 'https://api.reddit.com/r/' + path.basename(subreddits.join('+')) + '/.json?limit=100'
13 | if (cache.has(url)) return cache.get(url)
14 | const response = await fetchTimeout(url, { credentials: 'omit', referrerPolicy: 'no-referrer', failfast })
15 | if (!response.ok) throw new Error(await response.text())
16 | const imgPosts = (await response.json()).data.children.filter(
17 | (post: any) =>
18 | !post.data.is_video && !post.data.stickied && post.data.url && post.data.thumbnail && ['i.imgur.com', 'i.redd.it'].includes(post.data.domain)
19 | )
20 | let imgs: string[] = imgPosts
21 | .filter((post: any) => {
22 | const src = post.data.preview?.images[0]?.source
23 | if (src) {
24 | const { width, height } = src
25 | return height > 800 && width > 1000 && width / height > 1.1
26 | } else return false
27 | })
28 | .map((post: any) => post.data.url)
29 | if (imgs.length === 0) imgs = imgPosts.filter((post: any) => !!post.data.preview?.images[0]?.source).map((post: any) => post.data.url)
30 | return cache.set(url, imgs)
31 | }
32 | return chooseRandom(await fetchPosts(subreddits.trim().split(/\s*,\s*/))) as string
33 | }
34 |
35 | export async function queryBgImgUrlUnsplash({ searchTerm, failfast }: { searchTerm: string; failfast: boolean }) {
36 | const apiKey = sysConfig.unsplash_api_key
37 | if (!apiKey) throw new Error('unsplash error: no api key given')
38 | const search = new URLSearchParams({
39 | client_id: apiKey,
40 | orientation: 'landscape',
41 | query: searchTerm
42 | })
43 | const res = await fetchTimeout('https://api.unsplash.com/photos/random?' + search.toString(), { failfast })
44 | if (res.status === 200) {
45 | const data = await res.json()
46 | return data.urls.full
47 | } else {
48 | throw new Error('unsplash error: ' + (await res.text()))
49 | }
50 | }
51 |
52 | export async function queryBgImgUrlLocal() {
53 | if (await isDir(env.WALLPAPER_DIR!)) {
54 | const entries = await readdir(env.WALLPAPER_DIR!, { recursive: true, withFileTypes: true })
55 | const files = entries.filter(e => e.isFile()).map(e => path.relative(env.WALLPAPER_DIR!, path.join(e.parentPath, e.name)))
56 | if (files.length > 0) return '/background/wallpaper/' + chooseRandom(files)
57 | }
58 | return null
59 | }
60 |
61 | export async function hasLocalBgImgs() {
62 | return (await isDir(env.WALLPAPER_DIR!)) && (await readdir(env.WALLPAPER_DIR!, { recursive: true, withFileTypes: true })).some(e => e.isFile())
63 | }
64 |
--------------------------------------------------------------------------------
/src/lib/server/authz.ts:
--------------------------------------------------------------------------------
1 | import { sysConfig } from './sysconfig'
2 | import type { AccessRule, Calendar, Message, SearchEngine, SysconfigTile } from './sysconfig/types'
3 | import type { RequestUserInfo } from './types'
4 |
5 | function isUserAllowed(allowRule: AccessRule | undefined, denyRule: AccessRule | undefined, user: RequestUserInfo) {
6 | if (denyRule === true || allowRule === false) return false
7 |
8 | function matchesRuleList(rule: AccessRule | undefined) {
9 | if (typeof rule === 'undefined') return false
10 | if (!Array.isArray(rule)) throw new Error('this function only checks rule lists')
11 | for (const cond of rule) {
12 | const [condType, ...rest] = cond.split(':')
13 | const condValue = rest.join(':')
14 | switch (condType.toLowerCase()) {
15 | case 'user':
16 | if (condValue === user.userid) return true
17 | break
18 | case 'username':
19 | if (condValue === user.username) return true
20 | break
21 | case 'email':
22 | case 'mail':
23 | if (!user.email) console.warn('User', user.userid, 'has no email provided.')
24 | else if (condValue === user.email) return true
25 | break
26 | case 'group':
27 | if (user.groups.some(userGroup => userGroup === condValue)) return true
28 | break
29 | default:
30 | console.warn('unknown config option for allow/deny:', condType)
31 | }
32 | }
33 | return false
34 | }
35 |
36 | return (allowRule === true || matchesRuleList(allowRule)) && (denyRule === false || !matchesRuleList(denyRule))
37 | }
38 |
39 | export function getUserCalendars(user: RequestUserInfo) {
40 | return structuredClone(sysConfig.calendars)
41 | .filter(cal => isUserAllowed(cal.allow, cal.deny, user))
42 | .map(cal => {
43 | delete cal.allow
44 | delete cal.deny
45 | return cal
46 | }) as Calendar[]
47 | }
48 |
49 | export function getUserSearchEngines(user: RequestUserInfo) {
50 | return structuredClone(sysConfig.search_engines)
51 | .filter(engine => isUserAllowed(engine.allow, engine.deny, user))
52 | .map(engine => {
53 | delete engine.allow
54 | delete engine.deny
55 | return engine
56 | }) as SearchEngine[]
57 | }
58 |
59 | export function getUserMessages(user: RequestUserInfo) {
60 | return structuredClone(sysConfig.messages)
61 | .filter(msg => isUserAllowed(msg.allow, msg.deny, user))
62 | .map(msg => {
63 | delete msg.allow
64 | delete msg.deny
65 | return msg
66 | }) as Message[]
67 | }
68 |
69 | export function getUserSections(user: RequestUserInfo) {
70 | function transformTiles(tiles: SysconfigTile[], user: RequestUserInfo) {
71 | return tiles
72 | .filter(tile => isUserAllowed(tile.allow, tile.deny, user))
73 | .map(tile => {
74 | if (tile.menu && tile.menu.tiles && tile.menu.tiles.length > 0) {
75 | if (isUserAllowed(tile.menu.allow, tile.menu.deny, user)) tile.menu.tiles = transformTiles(tile.menu.tiles, user)
76 | else tile.menu.tiles = []
77 | }
78 | if (!tile.menu || !tile.menu.tiles || tile.menu.tiles.length === 0) delete tile.menu
79 | delete tile.allow
80 | delete tile.deny
81 | return tile
82 | })
83 | .filter(tile => tile.menu || tile.url)
84 | }
85 |
86 | return structuredClone(sysConfig.sections)
87 | .filter(section => isUserAllowed(section.allow, section.deny, user))
88 | .map(section => {
89 | section.tiles = transformTiles(structuredClone(section.tiles), user)
90 | delete section.allow
91 | delete section.deny
92 | return section
93 | })
94 | .filter(section => section.tiles.length > 0)
95 | }
96 |
--------------------------------------------------------------------------------
/src/lib/server/background.ts:
--------------------------------------------------------------------------------
1 | import { epoch } from '$lib/utils'
2 | import type { Cookies } from '@sveltejs/kit'
3 | import type { BackgroundConfig, UserConfig } from './userconfig/types'
4 | import { queryBgImgUrlLocal, queryBgImgUrlReddit, queryBgImgUrlUnsplash } from '$lib/backgrounds/random'
5 |
6 | export async function generateCurrentBgConfig({
7 | currentBgImgUrl,
8 | userConfig,
9 | failfast
10 | }: {
11 | currentBgImgUrl?: string
12 | userConfig: UserConfig
13 | failfast: boolean
14 | }) {
15 | const bgCfg: BackgroundConfig = userConfig.backgrounds.find(bgCfg => bgCfg.selected) as BackgroundConfig
16 |
17 | let bgImg = null
18 | if (!currentBgImgUrl) {
19 | let bgImgUrlJob = null
20 |
21 | if (bgCfg.background === 'random') {
22 | try {
23 | if (bgCfg.random_image.provider === 'unsplash') bgImgUrlJob = queryBgImgUrlUnsplash({ searchTerm: bgCfg.random_image.unsplash_query, failfast })
24 | else if (bgCfg.random_image.provider === 'reddit') bgImgUrlJob = queryBgImgUrlReddit({ subreddits: bgCfg.random_image.subreddits, failfast })
25 | else if (bgCfg.random_image.provider === 'local') bgImgUrlJob = queryBgImgUrlLocal()
26 | else console.warn('unknown background image provider:', bgCfg.random_image.provider)
27 | } catch (e) {
28 | console.error(e)
29 | }
30 | } else if (bgCfg.background === 'static') {
31 | if (bgCfg.static_image.source === 'upload') bgImgUrlJob = '/background/' + bgCfg.static_image.upload_url
32 | else if (bgCfg.static_image.source === 'web') bgImgUrlJob = bgCfg.static_image.web_url
33 | else console.warn('unknown background static image source: ', bgCfg.static_image.source)
34 | }
35 |
36 | bgImg = (async () => {
37 | if (bgImgUrlJob)
38 | try {
39 | return {
40 | url: await bgImgUrlJob,
41 | expiresAt: bgCfg.random_image.duration ? epoch() + bgCfg.random_image.duration : undefined
42 | }
43 | } catch (e) {
44 | console.error('Fetch random bg image error:', e)
45 | return {}
46 | }
47 | else return {}
48 | })()
49 | } else {
50 | bgImg = { url: currentBgImgUrl }
51 | }
52 |
53 | return {
54 | image: await bgImg,
55 | triangles: bgCfg.background === 'triangles',
56 | particles: bgCfg.particles,
57 | blur: bgCfg.blur,
58 | dots: bgCfg.dots
59 | }
60 | }
61 |
62 | export function setBgImgCookie(cookies: Cookies, bgImg: { url?: string; expiresAt?: number }) {
63 | if (bgImg.url) {
64 | const expires = new Date(1970, 0, 1, 0, 0)
65 | if (bgImg.expiresAt) {
66 | expires.setSeconds(bgImg.expiresAt)
67 | } else {
68 | expires.setFullYear(9999)
69 | }
70 | cookies.set('bgimg', bgImg.url, { path: '/', expires })
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/src/lib/server/calendar.ts:
--------------------------------------------------------------------------------
1 | import ICAL from 'ical.js'
2 | import cache from '$lib/server/httpcache'
3 | import { getUserCalendars } from '$lib/server/authz'
4 | import { fetchTimeout } from '$lib/server/fetch'
5 | import type { CalendarEntry } from '../../routes/calendar/types'
6 | import type { RequestUserInfo } from './types'
7 |
8 | async function calFetch({ url, failfast }: { url: string; failfast: boolean }) {
9 | const parsedUrl = new URL(url)
10 | const headers =
11 | parsedUrl.username && parsedUrl.password ? new Headers({ Authorization: `Basic ${btoa(parsedUrl.username + ':' + parsedUrl.password)}` }) : new Headers()
12 | parsedUrl.username = ''
13 | parsedUrl.password = ''
14 |
15 | const res = await fetchTimeout(parsedUrl, { headers, redirect: 'follow', failfast })
16 | const txt = await res.text()
17 | if (res.ok) {
18 | const ical = ICAL.parse(txt)
19 | const entries: [string, [string, string, string, string][]][] = ical[2]
20 | return entries
21 | .filter(itm => itm[0] === 'vevent')
22 | .map(itm => {
23 | const dtstart = itm[1].find(key => key[0] === 'dtstart')
24 | const dtend = itm[1].find(key => key[0] === 'dtend')
25 | const summary = itm[1].find(key => key[0] === 'summary')
26 | const location = itm[1].find(key => key[0] === 'location')
27 | const description = itm[1].find(key => key[0] === 'description')
28 | return {
29 | dtstart: dtstart ? dtstart[3] : null,
30 | dtend: dtend ? dtend[3] : null,
31 | summary: summary ? summary[3] : '',
32 | location: location ? location[3] : '',
33 | description: description ? description[3] : ''
34 | } as CalendarEntry
35 | })
36 | } else {
37 | throw new Error('Calendar error: ' + txt)
38 | }
39 | }
40 |
41 | export async function queryCalendar({ user, failfast }: { user: RequestUserInfo; failfast: boolean }) {
42 | const calendars = getUserCalendars(user)
43 |
44 | if (calendars?.length > 0) {
45 | const calData = await Promise.allSettled(
46 | calendars.map(async cal => {
47 | if (cache.has(cal.url)) return cache.get(cal.url)
48 | else return cache.set(cal.url, await calFetch({ url: cal.url, failfast }))
49 | })
50 | )
51 |
52 | const dateJump = (dayDiff: number) => {
53 | const date = new Date()
54 | date.setDate(date.getDate() + dayDiff)
55 | return date.toISOString().split('T')[0]
56 | }
57 |
58 | const filterDateFrom = dateJump(-1) // include yesterday because of timezone shifts
59 | const filterDateTo = dateJump(31)
60 |
61 | const results: CalendarEntry[] = []
62 | let errors = false
63 |
64 | for (const calResult of calData) {
65 | if (calResult.status === 'fulfilled') {
66 | for (const entry of calResult.value) {
67 | const dtStartDate = entry.dtstart?.split('T')[0]
68 | const dtEndDate = entry.dtend?.split('T')[0]
69 |
70 | const containsStart = dtStartDate && dtStartDate >= filterDateFrom && dtStartDate <= filterDateTo
71 | const containsEnd = dtEndDate && dtEndDate >= filterDateFrom && dtEndDate <= filterDateTo
72 | const containsWhole = dtStartDate && dtEndDate && dtStartDate <= filterDateFrom && dtEndDate >= filterDateTo
73 |
74 | if (containsStart || containsEnd || containsWhole) {
75 | results.push(entry)
76 | }
77 | }
78 | } else {
79 | errors = true
80 | console.error(`Error loading calendar: ${calResult.reason}`)
81 | }
82 | }
83 | return {
84 | entries: results.sort((a, b) => a.dtstart?.localeCompare(b.dtstart) || a.dtend?.localeCompare(b.dtend) || a.summary?.localeCompare(b.summary)),
85 | errors
86 | }
87 | } else {
88 | return null
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/lib/server/fetch.ts:
--------------------------------------------------------------------------------
1 | import { sysConfig } from './sysconfig'
2 |
3 | type ReqInit = Omit & {
4 | failfast?: boolean | undefined
5 | }
6 |
7 | export async function fetchTimeout(input: RequestInfo | URL, init?: ReqInit): Promise {
8 | const timeout = init?.failfast ? sysConfig.server_request_timeout.failfast : sysConfig.server_request_timeout.max
9 | const abortController = new AbortController()
10 | const timeoutId = timeout ? setTimeout(() => abortController.abort(), timeout) : null
11 | console.debug('fetch', input)
12 | const res = await fetch(input, { ...init, signal: abortController.signal })
13 | if (timeoutId !== null) clearTimeout(timeoutId)
14 | return res
15 | }
16 |
--------------------------------------------------------------------------------
/src/lib/server/fs.ts:
--------------------------------------------------------------------------------
1 | import { readFile, writeFile, stat, access, constants, statfs } from 'node:fs/promises'
2 |
3 | export async function readJsonFile(filepath: string) {
4 | return JSON.parse(await readFile(filepath, { encoding: 'utf8' })) as T
5 | }
6 |
7 | export async function writeJsonFile(filepath: string, data: unknown) {
8 | return await writeFile(filepath, JSON.stringify(data, null, 4), { encoding: 'utf8' })
9 | }
10 |
11 | export async function isFile(filepath: string) {
12 | try {
13 | return (await stat(filepath)).isFile()
14 | } catch (_) {
15 | return false
16 | }
17 | }
18 |
19 | export async function isDir(filepath: string) {
20 | try {
21 | return (await stat(filepath)).isDirectory()
22 | } catch (_) {
23 | return false
24 | }
25 | }
26 |
27 | export async function canWrite(filepath: string) {
28 | try {
29 | await access(filepath, constants.R_OK | constants.W_OK)
30 | return true
31 | } catch (_) {
32 | return false
33 | }
34 | }
35 |
36 | export async function availableSpace(filepath: string) {
37 | try {
38 | const stats = await statfs(filepath)
39 | return stats.bavail * stats.bsize
40 | } catch (_) {
41 | return 0
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/lib/server/httpcache.ts:
--------------------------------------------------------------------------------
1 | import { epoch } from '$lib/utils'
2 | import { sysConfig } from './sysconfig'
3 |
4 | type CacheUrl = string
5 | type Seconds = number
6 |
7 | let _cache: Record = {}
8 | let _lifetimes: Record = {}
9 |
10 | let intervalHandle: number | null = null
11 |
12 | function cleanup() {
13 | const urls = Object.keys(_lifetimes)
14 | if (urls.length > 0) {
15 | const now = epoch()
16 | for (const url of urls) {
17 | if (_lifetimes[url] < now) {
18 | delete _cache[url]
19 | delete _lifetimes[url]
20 | }
21 | }
22 | } else {
23 | if (intervalHandle !== null) clearInterval(intervalHandle)
24 | intervalHandle = null
25 | }
26 | }
27 |
28 | export default {
29 | get(requestUrl: string): T {
30 | if (this.has(requestUrl)) return _cache[requestUrl] as T
31 | else throw new Error(`cache item "${requestUrl}" not found`)
32 | },
33 |
34 | has(requestUrl: string) {
35 | return Object.prototype.hasOwnProperty.call(_cache, requestUrl)
36 | },
37 |
38 | set(requestUrl: string, responseData: T) {
39 | if (sysConfig.httpcache_ttl) {
40 | _cache[requestUrl] = responseData
41 | _lifetimes[requestUrl] = epoch() + sysConfig.httpcache_ttl * 60
42 | if (intervalHandle === null) intervalHandle = setInterval(cleanup, 1_000) as any
43 | }
44 | return responseData
45 | },
46 |
47 | clear() {
48 | _cache = {}
49 | _lifetimes = {}
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/src/lib/server/random.ts:
--------------------------------------------------------------------------------
1 | import crypto from 'crypto'
2 |
3 | export const chooseRandom = (items: unknown[]) => items[Math.floor(Math.random() * items.length)]
4 |
5 | export const genRandomId = () => (crypto.randomUUID() + crypto.randomUUID()).replaceAll('-', '')
6 |
--------------------------------------------------------------------------------
/src/lib/server/sysconfig/types.d.ts:
--------------------------------------------------------------------------------
1 | export interface Tile {
2 | title: string
3 | subtitle?: string
4 | url?: string | { value: string; target?: 'new-tab' | 'same-tab' | '_self' | '_blank' | '_parent' | '_top' }
5 | logo?: string
6 | emoji?: string
7 | only_icon?: boolean
8 | menu?: {
9 | title: string
10 | subtitle?: string
11 | tiles: Tile[]
12 | }
13 | }
14 |
15 | export interface Section {
16 | title?: string
17 | subtitle?: string
18 | tiles: Tile[]
19 | }
20 |
21 | export interface SearchEngine {
22 | title: string
23 | search_url: string
24 | search_parameter?: string
25 | autocomplete_url?: string
26 | target?: 'same-tab' | 'new-tab' | '_self' | '_blank' | '_parent' | '_top'
27 | }
28 |
29 | export interface Calendar {
30 | url: string
31 | }
32 |
33 | export interface Message {
34 | html: string
35 | }
36 |
37 | export type AccessRule = boolean | (`user:${string}` | `username:${string}` | `group:${string}` | `mail:${string}` | `email:${string}`)[]
38 |
39 | export interface AccessConfig {
40 | allow?: AccessRule
41 | deny?: AccessRule
42 | }
43 |
44 | export interface SysconfigTile extends Tile, AccessConfig {
45 | menu?: (Tile['menu'] & { tiles: SysconfigTile[] }) & AccessConfig
46 | }
47 |
48 | export interface SysconfigSection extends Section, AccessConfig {
49 | tiles: SysconfigTile[]
50 | }
51 |
52 | interface BaseSysconfig {
53 | sections: SysconfigSection[]
54 | search_engines: (SearchEngine & AccessConfig)[]
55 | calendars: (Calendar & AccessConfig)[]
56 | messages: (Message & AccessConfig)[]
57 | }
58 |
59 | export interface FileSysconfig extends BaseSysconfig {
60 | tiles?: SysconfigTile[]
61 | }
62 |
63 | export interface Sysconfig extends BaseSysconfig {
64 | admins: string[]
65 | unsplash_api_key: string | null
66 | openweathermap_api_key: string | null
67 | userHttpHeaders: {
68 | userid: string
69 | email?: string
70 | username?: string
71 | groups?: string
72 | groups_separator?: string
73 | }
74 |
75 | server_request_timeout: {
76 | failfast: number
77 | max: number
78 | }
79 | httpcache_ttl: number
80 |
81 | logLevel: 'debug' | 'info' | 'warn' | 'error'
82 |
83 | appInfo: {
84 | buildDate: string
85 | version: string
86 | }
87 |
88 | single_user_mode: boolean
89 | }
90 |
--------------------------------------------------------------------------------
/src/lib/server/types.d.ts:
--------------------------------------------------------------------------------
1 | export interface RequestUserInfo {
2 | userid: string
3 | email: string | null
4 | username: string | null
5 | groups: string[]
6 | isAdmin: boolean
7 | lang: string
8 | }
9 |
--------------------------------------------------------------------------------
/src/lib/server/userconfig/default.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 2,
3 | "theme": "system",
4 | "language": null,
5 | "weather": {
6 | "lon": null,
7 | "lat": null,
8 | "zip_code": null,
9 | "country_code": null,
10 | "mode": "zip",
11 | "show": true,
12 | "units": "metric"
13 | },
14 | "clock": {
15 | "show": true
16 | },
17 | "calendar": {
18 | "show": true
19 | },
20 | "searchbar": {
21 | "show": true
22 | },
23 | "dashboard": {
24 | "show_settings_text": true
25 | },
26 | "tiles": {
27 | "layout": "center",
28 | "position": "bottom"
29 | },
30 | "backgrounds": [
31 | {
32 | "static_image": {
33 | "source": "upload",
34 | "web_url": "",
35 | "upload_url": ""
36 | },
37 | "random_image": {
38 | "provider": "unsplash",
39 | "unsplash_query": "",
40 | "subreddits": "",
41 | "duration": 0
42 | },
43 | "background": "triangles",
44 | "blur": "dark",
45 | "dots": true,
46 | "particles": false,
47 | "selected": true
48 | }
49 | ]
50 | }
51 |
--------------------------------------------------------------------------------
/src/lib/server/userconfig/index.ts:
--------------------------------------------------------------------------------
1 | import { isFile, readJsonFile, writeJsonFile } from '$lib/server/fs'
2 | import path from 'node:path'
3 | import type { UserConfig } from './types'
4 | import { opendir } from 'node:fs/promises'
5 | import { env } from '$env/dynamic/private'
6 |
7 | let defaultConfig: UserConfig = (await import('./default.json')).default as UserConfig
8 |
9 | type UserId = string
10 |
11 | let _cache: Record = {}
12 |
13 | function userConfigFilePath(userid: UserId) {
14 | const encUserid = encodeURIComponent(userid)
15 | return path.join(env.USERS_DIR!, 'config', encUserid + '.json')
16 | }
17 |
18 | async function readUserConfig(userid: UserId) {
19 | const filepath = userConfigFilePath(userid)
20 | if (await isFile(filepath)) {
21 | return await readJsonFile(filepath)
22 | } else {
23 | return defaultConfig
24 | }
25 | }
26 |
27 | async function writeUserConfig(userid: UserId, config: UserConfig) {
28 | const filepath = userConfigFilePath(userid)
29 | await writeJsonFile(filepath, config)
30 | }
31 |
32 | export async function getUserConfig(userid: UserId) {
33 | if (!_cache[userid]) _cache[userid] = await readUserConfig(userid)
34 | return _cache[userid]
35 | }
36 |
37 | export async function setUserConfig(userid: UserId, config: UserConfig) {
38 | _cache[userid] = config
39 | await writeUserConfig(userid, config)
40 | }
41 |
42 | export async function reloadAllUsersConfig() {
43 | _cache = {}
44 | }
45 |
46 | export function userBackgroundImgFilePath(imgId: string) {
47 | return path.join(env.USERS_DIR!, 'backgrounds', path.basename(imgId))
48 | }
49 |
50 | export async function initDefaultUserConfig() {
51 | const filepath = path.join(env.USERS_DIR!, 'default-config.json')
52 | if (await isFile(filepath)) defaultConfig = await readJsonFile(filepath)
53 | else await writeJsonFile(filepath, defaultConfig)
54 | }
55 |
56 | export async function runUserConfigMigrations() {
57 | const newest_migration_version = 2
58 | for await (const e of await opendir(path.join(env.USERS_DIR!, 'config'))) {
59 | if (e.isFile() && e.name.endsWith('.json')) {
60 | const p = path.join(e.parentPath, e.name)
61 | const profile = await readJsonFile(p)
62 | if (profile.version < newest_migration_version) {
63 | console.debug('migrating profile: ', p)
64 | if (profile.version === 0) {
65 | // new props
66 | profile.tiles = { layout: 'center', position: 'bottom' }
67 | // only the first bg is show in settings ui, so make selected the first one
68 | const bgIdx = profile.backgrounds.findIndex(bg => bg.selected) || 0
69 | profile.backgrounds.unshift(profile.backgrounds.splice(bgIdx, 1)[0])
70 | profile.version = 1
71 | }
72 | if (profile.version === 1) {
73 | profile.weather.units = 'metric'
74 | profile.version = 2
75 | }
76 | await writeJsonFile(p, profile)
77 | }
78 | }
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/lib/server/userconfig/types.d.ts:
--------------------------------------------------------------------------------
1 | import type { ParticlesName } from '$lib/backgrounds/particles/types'
2 |
3 | export interface BackgroundConfig {
4 | static_image: {
5 | source: 'upload' | 'web'
6 | upload_url: string // the id generated for the uploaded image
7 | web_url: string
8 | }
9 | random_image: {
10 | provider: 'unsplash' | 'reddit' | 'local'
11 | unsplash_query: ''
12 | subreddits: ''
13 | duration: 0
14 | }
15 | background: 'triangles' | 'static' | 'random'
16 | blur: false | 'dark' | 'light'
17 | dots: boolean
18 | particles: false | ParticlesName
19 | selected: boolean // backgrounds array now has only a single entry, so this is not in use
20 | }
21 |
22 | export interface UserConfig {
23 | version: number
24 | theme: 'system' | 'light' | 'dark'
25 | language: string | null
26 | weather: {
27 | zip_code: string | null
28 | country_code: string | null
29 | lon: number | null
30 | lat: number | null
31 | mode: 'zip' | 'lonlat'
32 | show: boolean
33 | units: 'metric' | 'imperial'
34 | }
35 | clock: { show: boolean }
36 | calendar: { show: boolean }
37 | searchbar: { show: boolean }
38 | dashboard: { show_settings_text: boolean }
39 | tiles: { layout: 'center' | 'wide'; position: 'top' | 'bottom' | 'split' }
40 | backgrounds: BackgroundConfig[]
41 | }
42 |
--------------------------------------------------------------------------------
/src/lib/server/weather.ts:
--------------------------------------------------------------------------------
1 | import { sysConfig } from './sysconfig'
2 | import cache from '$lib/server/httpcache'
3 | import { fetchTimeout } from '$lib/server/fetch'
4 | import type { UserConfig } from './userconfig/types'
5 | import type { CurrentWeather, WeatherForecast } from '../../routes/weather/types'
6 |
7 | async function buildSearchParams({ lang, userConfig }: { lang: string; userConfig: UserConfig }) {
8 | const apiKey = sysConfig.openweathermap_api_key
9 | if (!apiKey) throw new Error('weather error: no api key given')
10 | const conf = userConfig.weather
11 | const search = new URLSearchParams({ appid: apiKey, lang, units: userConfig.weather.units })
12 | if (conf.mode === 'zip' && conf.zip_code && conf.country_code) search.set('zip', `${conf.zip_code},${conf.country_code}`)
13 | else if (conf.mode === 'lonlat' && conf.lon && conf.lat) {
14 | search.set('lon', `${conf.lon}`)
15 | search.set('lat', `${conf.lat}`)
16 | } else {
17 | throw new Error('weather for user not configured')
18 | }
19 | return search.toString()
20 | }
21 |
22 | export async function queryWeatherForecast({ lang, userConfig }: { lang: string; userConfig: UserConfig }) {
23 | const isMetric = userConfig.weather.units === 'metric'
24 | const search = await buildSearchParams({ lang, userConfig })
25 | const url = 'https://api.openweathermap.org/data/2.5/forecast?' + search
26 | if (cache.has(url)) return cache.get(url)
27 | const res = await fetch(url)
28 | if (res.status === 200) {
29 | const data = await res.json()
30 | return cache.set(
31 | url,
32 | data.list.map(
33 | (itm: any) =>
34 | ({
35 | timestamp: itm.dt,
36 | wind_speed: Math.round(isMetric ? itm.wind.speed * 3.6 : itm.wind.speed), // metric: m/s → km/h, imperial: mph
37 | wind_gust: Math.round(isMetric ? itm.wind.gust * 3.6 : itm.wind.gust), // metric: m/s → km/h, imperial: mph
38 | weather_type: itm.weather[0].main,
39 | weather_text: itm.weather[0].description,
40 | weather_icon_url: 'https://openweathermap.org/img/wn/' + itm.weather[0].icon + '.png',
41 | visibility: itm.visibility === 10_000 ? true : itm.visibility,
42 | temp: Math.round(itm.main.temp),
43 | feels_like_temp: Math.round(itm.main.feels_like),
44 | humidity: Math.round(itm.main.humidity),
45 | pressure: Math.round(itm.main.pressure),
46 | units: userConfig.weather.units
47 | }) as WeatherForecast
48 | )
49 | )
50 | } else {
51 | throw new Error('weather error: ' + (await res.text()))
52 | }
53 | }
54 |
55 | export async function queryCurrentWeather({
56 | lang,
57 | userConfig,
58 | failfast
59 | }: {
60 | lang: string
61 | userConfig: UserConfig
62 | failfast: boolean
63 | }): Promise {
64 | const isMetric = userConfig.weather.units === 'metric'
65 | const search = await buildSearchParams({ lang, userConfig })
66 | const url = 'https://api.openweathermap.org/data/2.5/weather?' + search
67 | if (cache.has(url)) return cache.get(url)
68 | const res = await fetchTimeout(url, { failfast })
69 | if (res.status === 200) {
70 | const data = await res.json()
71 | return cache.set(url, {
72 | wind_speed: Math.round(isMetric ? data.wind.speed * 3.6 : data.wind.speed), // metric: m/s → km/h, imperial: mph
73 | wind_gust: Math.round(isMetric ? data.wind.gust * 3.6 : data.wind.gust), // metric: m/s → km/h, imperial: mph
74 | weather_type: data.weather[0].main,
75 | weather_text: data.weather[0].description,
76 | weather_icon_url: 'https://openweathermap.org/img/wn/' + data.weather[0].icon + '.png',
77 | visibility: data.visibility === 10_000 ? true : data.visibility,
78 | temp: Math.round(data.main.temp),
79 | feels_like_temp: Math.round(data.main.feels_like),
80 | sunrise_epoch: data.sys.sunrise,
81 | sunset_epoch: data.sys.sunset,
82 | units: userConfig.weather.units
83 | })
84 | } else {
85 | throw new Error('weather error: ' + (await res.text()))
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/lib/translations/de/calendar.json:
--------------------------------------------------------------------------------
1 | {
2 | "msg": {
3 | "missing-calendars": "Einige Kalender fehlen",
4 | "no-events": "Keine anstehenden Termine"
5 | },
6 | "yesterday": "gestern",
7 | "today": "heute",
8 | "tomorrow": "morgen",
9 | "from": "von",
10 | "to": "bis",
11 | "relevant-today": "heute relevant"
12 | }
13 |
--------------------------------------------------------------------------------
/src/lib/translations/de/clock.json:
--------------------------------------------------------------------------------
1 | {
2 | "clock": "Uhr",
3 | "continue": "Weiter",
4 | "pause": "Pause",
5 | "round": "Rundenzeit",
6 | "stop": "Stopp",
7 | "stopwatch": "Stoppuhr",
8 | "timer": "Timer"
9 | }
10 |
--------------------------------------------------------------------------------
/src/lib/translations/de/common.json:
--------------------------------------------------------------------------------
1 | {
2 | "close": "Schließen",
3 | "home": "Dashboard",
4 | "save": "Speichern",
5 | "settings": "Einstellungen",
6 | "weather": "Wetter"
7 | }
8 |
--------------------------------------------------------------------------------
/src/lib/translations/de/dashboard.json:
--------------------------------------------------------------------------------
1 | {
2 | "search": {
3 | "placeholder": "Suchen"
4 | },
5 | "menu": "Menü",
6 | "show-more": "Weiteres",
7 | "logo": "Logo"
8 | }
9 |
--------------------------------------------------------------------------------
/src/lib/translations/de/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "nav": {
3 | "background": "Hintergrund",
4 | "weather": "Wetter",
5 | "dashboard": "Dashboard",
6 | "system": "System",
7 | "admin": "Admin"
8 | },
9 | "bg": {
10 | "image": "Bild",
11 | "particleslist": {
12 | "": "Keine",
13 | "bubbles": "Blasen",
14 | "circles": "Kreise",
15 | "drizzle": "Nieseln",
16 | "leaves": "Herbstblätter",
17 | "rain": "Regen",
18 | "snow": "Schnee",
19 | "squares": "Quadrate",
20 | "stars": "Sterne",
21 | "triangles": "Dreiecke"
22 | },
23 | "particles": "Partikel",
24 | "no-blur": "Keine",
25 | "dark-blur": "Abdunkeln",
26 | "light-blur": "Aufhellen",
27 | "effects": "Effekte",
28 | "dot-matrix": "Gepunktet"
29 | },
30 | "dashboard": {
31 | "layout": "Layout",
32 | "layout-center": "Kacheln zentrieren",
33 | "layout-wide": "Kacheln ausbreiten",
34 | "position": "Position",
35 | "position-top": "Kacheln oben",
36 | "position-bottom": "Kacheln unten",
37 | "position-split": "Kacheln separat",
38 | "features": "Features"
39 | },
40 | "admin": {
41 | "reload-app": "Anwendung neuladen",
42 | "auth-data": "Authentifizierungsdaten",
43 | "about-app": "Über die Anwendung"
44 | },
45 | "msg": {
46 | "saved": "Einstellungen wurden gespeichert"
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/src/lib/translations/de/weather.json:
--------------------------------------------------------------------------------
1 | {
2 | "feelslike": "gefühlt",
3 | "gust": "Böen",
4 | "humidity": "Luftfeuchte",
5 | "temperature": "Temperatur",
6 | "visibility": "Blickweite",
7 | "pressure": "Luftdruck",
8 | "wind": "Wind",
9 | "metric": "metrisch",
10 | "imperial": "imperial"
11 | }
12 |
--------------------------------------------------------------------------------
/src/lib/translations/en/calendar.json:
--------------------------------------------------------------------------------
1 | {
2 | "msg": {
3 | "missing-calendars": "Some calendars missing",
4 | "no-events": "No upcoming events"
5 | },
6 | "yesterday": "yesterday",
7 | "today": "today",
8 | "tomorrow": "tomorrow",
9 | "from": "from",
10 | "to": "to",
11 | "relevant-today": "relevant today"
12 | }
13 |
--------------------------------------------------------------------------------
/src/lib/translations/en/clock.json:
--------------------------------------------------------------------------------
1 | {
2 | "clock": "Clock",
3 | "continue": "Continue",
4 | "pause": "Pause",
5 | "round": "Round",
6 | "stop": "Stop",
7 | "stopwatch": "Stopwatch",
8 | "timer": "Timer"
9 | }
10 |
--------------------------------------------------------------------------------
/src/lib/translations/en/common.json:
--------------------------------------------------------------------------------
1 | {
2 | "close": "Close",
3 | "home": "Home",
4 | "save": "Save",
5 | "settings": "Settings",
6 | "weather": "Weather"
7 | }
8 |
--------------------------------------------------------------------------------
/src/lib/translations/en/dashboard.json:
--------------------------------------------------------------------------------
1 | {
2 | "search": {
3 | "placeholder": "Search"
4 | },
5 | "menu": "Menu",
6 | "show-more": "Show more",
7 | "logo": "Logo"
8 | }
9 |
--------------------------------------------------------------------------------
/src/lib/translations/en/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "nav": {
3 | "background": "Background",
4 | "weather": "Weather",
5 | "dashboard": "Dashboard",
6 | "system": "System",
7 | "admin": "Admin"
8 | },
9 | "bg": {
10 | "image": "Image",
11 | "image-static": "static image",
12 | "image-random": "random images",
13 | "particleslist": {
14 | "": "none",
15 | "bubbles": "bubbles",
16 | "circles": "circles",
17 | "drizzle": "drizzle",
18 | "leaves": "leaves",
19 | "rain": "rain",
20 | "snow": "snow",
21 | "squares": "squares",
22 | "stars": "stars",
23 | "triangles": "triangles"
24 | },
25 | "particles": "Particles",
26 | "no-blur": "no blur",
27 | "dark-blur": "dark blur",
28 | "light-blur": "light blur",
29 | "effects": "Effects",
30 | "dot-matrix": "dot matrix"
31 | },
32 | "dashboard": {
33 | "layout": "Layout",
34 | "layout-center": "Center tiles on screen",
35 | "layout-wide": "Fill screen with tiles",
36 | "position": "Position",
37 | "position-top": "Tiles on top",
38 | "position-bottom": "Tiles on bottom",
39 | "position-split": "Tiles on extra page",
40 | "features": "Features"
41 | },
42 | "weather": {
43 | "from-zip-code": "from zip code",
44 | "from-geolocation": "from geolocation",
45 | "zip-code": "zip code",
46 | "country-code": "country code (2 letter)",
47 | "detect-geolocation": "Detect your current position",
48 | "no-geolocation": "Your browser doesn't support geolocation.",
49 | "geolocation-lonlat": "Configured Location",
50 | "geolocation-map": "Show on map",
51 | "geolocation-not-configured": "No position configured yet."
52 | },
53 | "admin": {
54 | "reload-app": "Reload application",
55 | "auth-data": "Your authentication data",
56 | "about-app": "About the application"
57 | },
58 | "msg": {
59 | "saved": "Settings saved"
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/src/lib/translations/en/weather.json:
--------------------------------------------------------------------------------
1 | {
2 | "feelslike": "feels like",
3 | "gust": "gust",
4 | "humidity": "humidity",
5 | "temperature": "temperature",
6 | "visibility": "visibility",
7 | "pressure": "pressure",
8 | "wind": "wind",
9 | "metric": "metric",
10 | "imperial": "imperial"
11 | }
12 |
--------------------------------------------------------------------------------
/src/lib/translations/es/calendar.json:
--------------------------------------------------------------------------------
1 | {
2 | "msg": {
3 | "missing-calendars": "Algunos calendarios falta",
4 | "no-events": "No hay eventos próximos"
5 | },
6 | "yesterday": "ayer",
7 | "today": "hoy",
8 | "tomorrow": "mañana",
9 | "from": "desde",
10 | "to": "hasta",
11 | "relevant-today": "relevante hoy"
12 | }
13 |
--------------------------------------------------------------------------------
/src/lib/translations/es/clock.json:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/src/lib/translations/es/common.json:
--------------------------------------------------------------------------------
1 | {
2 | "close": "cerrar",
3 | "home": "panel",
4 | "save": "guardar",
5 | "settings": "ajustes",
6 | "weather": "clima"
7 | }
8 |
--------------------------------------------------------------------------------
/src/lib/translations/es/dashboard.json:
--------------------------------------------------------------------------------
1 | {
2 | "search": {
3 | "placeholder": "Buscar"
4 | }
5 | }
6 |
--------------------------------------------------------------------------------
/src/lib/translations/es/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "nav": {
3 | "background": "fondo",
4 | "weather": "clima",
5 | "dashboard": "panel",
6 | "system": "sistema",
7 | "admin": "administración"
8 | },
9 | "bg": {
10 | "image": "Imagen",
11 | "image-static": "Imagen estática",
12 | "image-random": "Imagen aleatoria",
13 | "particleslist": {
14 | "": "sin",
15 | "bubbles": "burbujas",
16 | "circles": "círculos",
17 | "drizzle": "llovizna",
18 | "leaves": "hojas",
19 | "rain": "lluvia",
20 | "snow": "nieve",
21 | "squares": "cuadrícula",
22 | "stars": "estrellas",
23 | "triangles": "triangulos"
24 | },
25 | "particles": "partículas",
26 | "no-blur": "sin desenfoque",
27 | "dark-blur": "desenfoque oscuro",
28 | "light-blur": "desenfoque de luz",
29 | "effects": "efectos",
30 | "dot-matrix": "punteada"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/src/lib/translations/es/weather.json:
--------------------------------------------------------------------------------
1 | {}
2 |
--------------------------------------------------------------------------------
/src/lib/translations/index.ts:
--------------------------------------------------------------------------------
1 | import i18n, { type Config } from 'sveltekit-i18n'
2 |
3 | const config: Config = {
4 | fallbackLocale: 'en',
5 | initLocale: 'en',
6 |
7 | loaders: [
8 | { locale: 'en', key: 'calendar', routes: ['/', '/calendar'], loader: async () => (await import('./en/calendar.json')).default },
9 | { locale: 'en', key: 'clock', routes: [/^\/clock/], loader: async () => (await import('./en/clock.json')).default },
10 | { locale: 'en', key: 'common', loader: async () => (await import('./en/common.json')).default },
11 | { locale: 'en', key: 'dashboard', routes: ['/'], loader: async () => (await import('./en/dashboard.json')).default },
12 | { locale: 'en', key: 'settings', routes: [/^\/settings/], loader: async () => (await import('./en/settings.json')).default },
13 | { locale: 'en', key: 'weather', routes: ['/', '/weather', '/settings/weather'], loader: async () => (await import('./en/weather.json')).default },
14 |
15 | { locale: 'de', key: 'calendar', routes: ['/', '/calendar'], loader: async () => (await import('./de/calendar.json')).default },
16 | { locale: 'de', key: 'clock', routes: [/^\/clock/], loader: async () => (await import('./de/clock.json')).default },
17 | { locale: 'de', key: 'common', loader: async () => (await import('./de/common.json')).default },
18 | { locale: 'de', key: 'dashboard', routes: ['/'], loader: async () => (await import('./de/dashboard.json')).default },
19 | { locale: 'de', key: 'settings', routes: [/^\/settings/], loader: async () => (await import('./de/settings.json')).default },
20 | { locale: 'de', key: 'weather', routes: ['/', '/weather', '/settings/weather'], loader: async () => (await import('./de/weather.json')).default },
21 |
22 | { locale: 'es', key: 'calendar', routes: ['/', '/calendar'], loader: async () => (await import('./es/calendar.json')).default },
23 | { locale: 'es', key: 'clock', routes: [/^\/clock/], loader: async () => (await import('./es/clock.json')).default },
24 | { locale: 'es', key: 'common', loader: async () => (await import('./es/common.json')).default },
25 | { locale: 'es', key: 'dashboard', routes: ['/'], loader: async () => (await import('./es/dashboard.json')).default },
26 | { locale: 'es', key: 'settings', routes: [/^\/settings/], loader: async () => (await import('./es/settings.json')).default },
27 | { locale: 'es', key: 'weather', routes: ['/', '/weather', '/settings/weather'], loader: async () => (await import('./es/weather.json')).default },
28 |
29 | { locale: 'uk', key: 'calendar', routes: ['/', '/calendar'], loader: async () => (await import('./uk/calendar.json')).default },
30 | { locale: 'uk', key: 'clock', routes: [/^\/clock/], loader: async () => (await import('./uk/clock.json')).default },
31 | { locale: 'uk', key: 'common', loader: async () => (await import('./uk/common.json')).default },
32 | { locale: 'uk', key: 'dashboard', routes: ['/'], loader: async () => (await import('./uk/dashboard.json')).default },
33 | { locale: 'uk', key: 'settings', routes: [/^\/settings/], loader: async () => (await import('./uk/settings.json')).default },
34 | { locale: 'uk', key: 'weather', routes: ['/', '/weather', '/settings/weather'], loader: async () => (await import('./uk/weather.json')).default }
35 | ]
36 | }
37 |
38 | export const { t, loading, locales, locale, loadTranslations, setLocale, setRoute } = new i18n(config)
39 |
--------------------------------------------------------------------------------
/src/lib/translations/uk/calendar.json:
--------------------------------------------------------------------------------
1 | {
2 | "msg": {
3 | "missing-calendars": "Деякі календарі відсутні",
4 | "no-events": "Немає найближчих подій"
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/src/lib/translations/uk/clock.json:
--------------------------------------------------------------------------------
1 | {
2 | "clock": "Годинник",
3 | "continue": "Продовжити",
4 | "pause": "Пауза",
5 | "round": "Коло",
6 | "stop": "Стоп",
7 | "stopwatch": "Секундомір",
8 | "timer": "Таймер"
9 | }
10 |
--------------------------------------------------------------------------------
/src/lib/translations/uk/common.json:
--------------------------------------------------------------------------------
1 | {
2 | "close": "Закрити",
3 | "home": "Домівка",
4 | "save": "Зберегти",
5 | "settings": "Налаштування",
6 | "weather": "Погода"
7 | }
8 |
--------------------------------------------------------------------------------
/src/lib/translations/uk/dashboard.json:
--------------------------------------------------------------------------------
1 | {
2 | "search": {
3 | "placeholder": "Шукати"
4 | },
5 | "menu": "Меню",
6 | "show-more": "Показати більше",
7 | "logo": "Лого"
8 | }
9 |
--------------------------------------------------------------------------------
/src/lib/translations/uk/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "nav": {
3 | "background": "Фон",
4 | "weather": "Погода",
5 | "dashboard": "Панель",
6 | "system": "Система",
7 | "admin": "Адміністрування"
8 | },
9 | "bg": {
10 | "image": "Зображення",
11 | "image-static": "статичне зображення",
12 | "image-random": "випадове зображення",
13 | "particleslist": {
14 | "": "відсутні",
15 | "bubbles": "бульбашки",
16 | "circles": "кола",
17 | "drizzle": "краплі",
18 | "leaves": "листочки",
19 | "rain": "дощ",
20 | "snow": "сніг",
21 | "squares": "квадрати",
22 | "stars": "зірочки",
23 | "triangles": "трикутники"
24 | },
25 | "particles": "Часточки",
26 | "no-blur": "без розмиття",
27 | "dark-blur": "темне розмиття",
28 | "light-blur": "світле розмиття",
29 | "effects": "Еффекти",
30 | "dot-matrix": "сіточка"
31 | },
32 | "dashboard": {
33 | "layout": "Макет",
34 | "layout-center": "Відцентрувати плиточки на екрані",
35 | "layout-wide": "Заповнити екран плиточками",
36 | "position": "Розташування",
37 | "position-top": "Плиточки зверху",
38 | "position-bottom": "Плиточки знизу",
39 | "features": "Функції"
40 | },
41 | "weather": {
42 | "from-zip-code": "за допомогою поштового індексу",
43 | "from-geolocation": "за допомогою геолокації",
44 | "zip-code": "поштовий індекс",
45 | "country-code": "код країни (двох літерний код, наприклад UA)",
46 | "detect-geolocation": "Визначити поточне розташування",
47 | "no-geolocation": "Ваш браузер не підтримує геолокацію.",
48 | "geolocation-lonlat": "Налаштоване розташування",
49 | "geolocation-map": "Показати на мапі",
50 | "geolocation-not-configured": "Розташування не налаштоване."
51 | },
52 | "admin": {
53 | "reload-app": "Перезавантажити застосунок",
54 | "auth-data": "Ваші дані автенифікації",
55 | "about-app": "Про застосунок"
56 | },
57 | "msg": {
58 | "saved": "Налаштування збережено"
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/lib/translations/uk/weather.json:
--------------------------------------------------------------------------------
1 | {
2 | "feelslike": "відчувається як",
3 | "gust": "пориви вітру",
4 | "humidity": "вологість",
5 | "temperature": "температура",
6 | "visibility": "видимість",
7 | "pressure": "тиск",
8 | "wind": "вітер",
9 | "metric": "метрична",
10 | "imperial": "імперська"
11 | }
12 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * epoch in seconds
3 | */
4 | export function epoch() {
5 | return Math.floor(Date.now() / 1000)
6 | }
7 |
--------------------------------------------------------------------------------
/src/routes/+error.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 |
11 |
{$page.error?.message}
12 |
13 |
--------------------------------------------------------------------------------
/src/routes/+layout.server.ts:
--------------------------------------------------------------------------------
1 | import type { LayoutServerLoad } from './$types'
2 | import { queryCurrentWeather } from '$lib/server/weather'
3 | import { generateCurrentBgConfig, setBgImgCookie } from '$lib/server/background'
4 | import { loadTranslations } from '$lib/translations'
5 |
6 | export const load: LayoutServerLoad = async ({ url, cookies, locals }) => {
7 | const currentWeatherJob = (async () => {
8 | try {
9 | if (locals.sysConfig.openweathermap_api_key) return await queryCurrentWeather({ lang: locals.user.lang, userConfig: locals.userConfig, failfast: true })
10 | else return 'NOT_ENABLED'
11 | } catch (e) {
12 | if (e instanceof Error && e.message === 'weather for user not configured') return 'NOT_CONFIGURED'
13 | else return null
14 | }
15 | })()
16 |
17 | const { pathname } = url
18 | const translationsJob = loadTranslations(locals.user.lang, pathname)
19 |
20 | const background = await generateCurrentBgConfig({
21 | currentBgImgUrl: cookies.get('bgimg'),
22 | userConfig: locals.userConfig,
23 | failfast: true
24 | })
25 | setBgImgCookie(cookies, background.image)
26 |
27 | await translationsJob
28 |
29 | return {
30 | background, // loaded from here or /background on timeout
31 | currentWeather: await currentWeatherJob, // loaded from here or /weather/current on timeout
32 | userConfig: locals.userConfig,
33 | userLang: locals.user.lang,
34 | isAdmin: locals.user.isAdmin
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/routes/+layout.svelte:
--------------------------------------------------------------------------------
1 |
51 |
52 |
61 | {#if data.background.image?.url}
62 |
63 | {/if}
64 | {#if data.background.triangles}
65 |
66 | {/if}
67 | {#if data.background.blur === 'dark'}
68 |
69 | {:else if data.background.blur === 'light'}
70 |
71 | {/if}
72 | {#if data.background.dots}
73 |
74 | {/if}
75 | {#if data.background.particles}
76 |
77 | {/if}
78 |
79 |
80 |
81 |
--------------------------------------------------------------------------------
/src/routes/+layout.ts:
--------------------------------------------------------------------------------
1 | import { setLocale, setRoute } from '$lib/translations'
2 | import type { LayoutLoad } from './$types'
3 |
4 | export const load: LayoutLoad = async ({ data, url }) => {
5 | const { pathname } = url
6 |
7 | await setRoute(pathname)
8 | await setLocale(data.userLang)
9 |
10 | return data
11 | }
12 |
--------------------------------------------------------------------------------
/src/routes/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { getUserMessages, getUserSearchEngines, getUserSections } from '$lib/server/authz'
2 | import { queryCalendar } from '$lib/server/calendar'
3 | import type { PageServerLoad } from './$types'
4 |
5 | export const load: PageServerLoad = async ({ locals }) => {
6 | return {
7 | sections: getUserSections(locals.user),
8 | calendarEvents: await queryCalendar({ user: locals.user, failfast: true }), // loaded from here or /calendar/entries on timeout
9 | searchEngines: getUserSearchEngines(locals.user),
10 | messages: getUserMessages(locals.user)
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/routes/+page.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 | {$t('common.home')}
18 |
19 |
20 |
24 | {#if data.userConfig.searchbar.show && data.searchEngines?.length > 0}
25 |
28 | {/if}
29 |
30 | {#if data.userConfig.calendar.show && data.calendarEvents !== null}
31 |
39 | {/if}
40 |
41 | {#if data.messages && data.messages.length > 0}
42 |
45 | {/if}
46 |
47 | {#if data.userConfig.tiles.position === 'split'}
48 |
49 | container.scrollTo({ top: window.innerHeight, behavior: 'smooth' })}>
50 |
51 |
52 |
53 | {/if}
54 |
55 |
59 | {#if data.userConfig.tiles.position === 'split'}
60 |
61 | container.scrollTo({ top: 0, behavior: 'smooth' })}>
62 |
63 |
64 |
65 | {/if}
66 |
67 | {#each data.sections as section}
68 |
69 | {#if section.title || section.subtitle}
70 |
71 | {#if section.title}
72 |
{section.title}
73 | {/if}
74 | {#if section.subtitle}
75 | {section.subtitle}
76 | {/if}
77 |
78 | {/if}
79 |
80 | {#each section.tiles || [] as tile}
81 |
82 | {/each}
83 |
84 |
85 | {/each}
86 |
87 |
88 |
89 |
107 |
--------------------------------------------------------------------------------
/src/routes/Header.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
47 |
48 |
55 |
--------------------------------------------------------------------------------
/src/routes/TileFolder.svelte:
--------------------------------------------------------------------------------
1 |
32 |
33 |
34 |
35 | dispatch('close')}
39 | on:keydown={keydown}
40 | >
41 |
48 |
49 |
50 | {menu?.title || ''}
51 |
52 | {#if menu?.subtitle}
53 |
54 | {menu?.subtitle || ''}
55 |
56 | {/if}
57 |
58 |
6}>
59 | {#each menu?.tiles || [] as menuTile}
60 |
61 | {/each}
62 |
63 |
64 |
65 |
--------------------------------------------------------------------------------
/src/routes/background/+server.ts:
--------------------------------------------------------------------------------
1 | import { generateCurrentBgConfig, setBgImgCookie } from '$lib/server/background'
2 | import { json } from '@sveltejs/kit'
3 |
4 | export async function GET({ locals, cookies }) {
5 | const background = await generateCurrentBgConfig({ userConfig: locals.userConfig, failfast: false })
6 | setBgImgCookie(cookies, background.image)
7 | return json(background)
8 | }
9 |
--------------------------------------------------------------------------------
/src/routes/background/[slug]/+server.ts:
--------------------------------------------------------------------------------
1 | import { userBackgroundImgFilePath } from '$lib/server/userconfig'
2 | import fs from 'node:fs'
3 | import { lookup } from 'mrmime'
4 | import { isFile } from '$lib/server/fs'
5 | import { error } from '@sveltejs/kit'
6 |
7 | export async function GET({ params }) {
8 | const path = userBackgroundImgFilePath(params.slug)
9 | if (await isFile(path))
10 | return new Response(fs.createReadStream(path) as unknown as ReadableStream, {
11 | headers: { 'Content-Type': lookup(params.slug) || 'application/octet-stream' }
12 | })
13 | else error(404, 'file not found')
14 | }
15 |
--------------------------------------------------------------------------------
/src/routes/background/wallpaper/[...wallpaper]/+server.ts:
--------------------------------------------------------------------------------
1 | import fs from 'node:fs'
2 | import { lookup } from 'mrmime'
3 | import { isFile } from '$lib/server/fs'
4 | import { error } from '@sveltejs/kit'
5 | import path from 'node:path'
6 | import { env } from '$env/dynamic/private'
7 |
8 | export async function GET({ params, locals }) {
9 | const bgCfg = locals.userConfig.backgrounds[0]
10 | if (bgCfg.background === 'random' && bgCfg.random_image.provider === 'local') {
11 | const p = path.join(env.WALLPAPER_DIR!, path.normalize(params.wallpaper))
12 | if (p.startsWith(env.WALLPAPER_DIR!)) {
13 | if (await isFile(p))
14 | return new Response(fs.createReadStream(p) as unknown as ReadableStream, {
15 | headers: { 'Content-Type': lookup(params.wallpaper) || 'application/octet-stream' }
16 | })
17 | else error(404, 'file not found')
18 | } else error(400, 'invalid wallpaper path')
19 | } else error(403, 'wrong background image provider')
20 | }
21 |
--------------------------------------------------------------------------------
/src/routes/calendar/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { queryCalendar } from '$lib/server/calendar'
2 | import type { PageServerLoad } from './$types'
3 |
4 | export const load: PageServerLoad = async ({ locals }) => {
5 | return {
6 | calendarEvents: await queryCalendar({ user: locals.user, failfast: false })
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/src/routes/calendar/+page.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 | {#if data.calendarEvents?.errors}
13 |
14 |
15 | {$t('calendar.msg.missing-calendars')}
16 |
17 | {/if}
18 | {#if data.calendarEvents?.entries && data.calendarEvents.entries.length > 0}
19 |
20 | {:else}
21 |
22 |
23 | {$t('calendar.msg.no-events')}
24 |
25 | {/if}
26 |
27 |
--------------------------------------------------------------------------------
/src/routes/calendar/Widget.svelte:
--------------------------------------------------------------------------------
1 |
39 |
40 | {#if calendarEvents?.errors}
41 |
42 |
43 | {$t('calendar.msg.missing-calendars')}
44 |
45 | {/if}
46 | {#if calendarEvents?.entries?.length > 0}
47 |
48 | {:else if !calendarEvents?.errors}
49 |
50 |
51 | {$t('calendar.msg.no-events')}
52 |
53 | {/if}
54 |
--------------------------------------------------------------------------------
/src/routes/calendar/entries/+server.ts:
--------------------------------------------------------------------------------
1 | import { json } from '@sveltejs/kit'
2 | import { queryCalendar } from '$lib/server/calendar'
3 |
4 | export async function GET({ locals }) {
5 | return json(await queryCalendar({ user: locals.user, failfast: false }))
6 | }
7 |
--------------------------------------------------------------------------------
/src/routes/calendar/types.d.ts:
--------------------------------------------------------------------------------
1 | export interface CalendarEntry {
2 | dtstart: string
3 | dtend: string
4 | summary: string
5 | location: string
6 | description: string
7 | url?: string
8 | }
9 |
--------------------------------------------------------------------------------
/src/routes/clock/+layout.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 | {$t('clock.clock')}
11 |
12 |
13 |
14 |
36 |
37 |
38 |
39 |
44 |
--------------------------------------------------------------------------------
/src/routes/clock/+server.ts:
--------------------------------------------------------------------------------
1 | import { redirect } from '@sveltejs/kit'
2 |
3 | export async function GET() {
4 | redirect(307, '/clock/stopwatch')
5 | }
6 |
--------------------------------------------------------------------------------
/src/routes/clock/ClockFace.svelte:
--------------------------------------------------------------------------------
1 |
8 |
9 |
10 |
11 |
12 |
13 | {#if isRunning}
14 | {#if mode === 'countdown'}
15 |
27 | {/if}
28 | {#if mode === 'satellite'}
29 |
30 |
31 |
32 | {/if}
33 | {/if}
34 |
35 | {#if mode === 'satellite'}
36 |
37 |
38 |
39 |
40 | {/if}
41 |
42 |
43 | {text}
44 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/src/routes/clock/Widget.svelte:
--------------------------------------------------------------------------------
1 |
32 |
33 | {displayTime}
34 |
--------------------------------------------------------------------------------
/src/routes/clock/stopwatch/+page.svelte:
--------------------------------------------------------------------------------
1 |
50 |
51 |
52 |
53 |
54 |
55 | {#if !isRunning}
56 |
61 |
62 | {#if lapse !== null}
63 | Restart
64 | {:else}
65 | Start
66 | {/if}
67 |
68 | {:else}
69 |
74 |
75 | {#if isPaused}
76 | {$t('clock.continue')}
77 | {:else}
78 | {$t('clock.pause')}
79 | {/if}
80 |
81 | {/if}
82 |
88 |
89 | {$t('clock.round')}
90 |
91 |
97 |
98 | {$t('clock.stop')}
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 | {#each rounds as round, idx (round)}
107 |
111 |
112 | {formatTime(round)}
113 |
114 |
115 | {#if idx > 0}
116 | +{formatTime(round - rounds[idx - 1])}
117 | {/if}
118 |
119 |
120 | {/each}
121 |
122 |
123 |
124 |
125 |
132 |
--------------------------------------------------------------------------------
/src/routes/clock/timer/+page.svelte:
--------------------------------------------------------------------------------
1 |
67 |
68 | setTimeout(() => (isAlarmOn ? audio.play() : null), 250)}
71 | src="data:audio/wav;base64,UklGRl9vT19XQVZFZm10IBAAAAABAAEAQB8AAEA0AAABAAgAZGF0YU{Array(1000).join('123')}"
72 | >
73 |
74 |
75 |
76 |
77 |
78 |
79 | {#if isRunning || isAlarmOn}
80 |
81 | {#if !isAlarmOn}
82 |
90 | {#if isPaused}
91 |
92 | {:else}
93 |
94 | {/if}
95 |
96 | {/if}
97 |
105 |
106 |
107 |
108 | {:else}
109 |
110 |
111 |
:
112 |
hh++} />
113 | :
114 | mm++} />
115 |
124 |
125 |
126 |
127 | {/if}
128 |
129 |
146 |
--------------------------------------------------------------------------------
/src/routes/clock/timer/NumberInput.svelte:
--------------------------------------------------------------------------------
1 |
39 |
40 |
41 |
49 |
50 |
51 |
66 |
74 |
75 |
76 |
77 |
--------------------------------------------------------------------------------
/src/routes/clock/utils.ts:
--------------------------------------------------------------------------------
1 | function zeroPadded(number: number) {
2 | return number >= 10 ? number.toString() : `0${number}`
3 | }
4 |
5 | function lastDigit(number: number) {
6 | return number.toString()[number.toString().length - 1]
7 | }
8 |
9 | export function formatTime(millisecs: number) {
10 | const hh = zeroPadded(Math.floor(millisecs / 1000 / 60 / 60))
11 | const mm = zeroPadded(Math.floor(millisecs / 1000 / 60) % 60)
12 | const ss = zeroPadded(Math.floor(millisecs / 1000) % 60)
13 | const t = lastDigit(Math.floor(millisecs / 100))
14 | if (hh === '00') return `${mm}:${ss}.${t}`
15 | else return `${hh}:${mm}:${ss}`
16 | }
17 |
--------------------------------------------------------------------------------
/src/routes/healthcheck/+server.ts:
--------------------------------------------------------------------------------
1 | import { error, json, type RequestHandler } from '@sveltejs/kit'
2 | import { env } from '$env/dynamic/private'
3 | import { canWrite, availableSpace, isDir } from '$lib/server/fs'
4 |
5 | export const GET: RequestHandler = async () => {
6 | if (!(await isDir(env.LOGOS_DIR!))) error(500, 'logos directory does not exist')
7 |
8 | if (!(await canWrite(env.USERS_DIR!))) error(500, 'users data directory is not writable')
9 |
10 | if ((await availableSpace(env.USERS_DIR!)) < 25 * 1024 ** 2) error(500, 'less than 25 MiB storage available for user data')
11 |
12 | return json('ok')
13 | }
14 |
--------------------------------------------------------------------------------
/src/routes/logo/[slug]/+server.ts:
--------------------------------------------------------------------------------
1 | import { dev } from '$app/environment'
2 | import { error, redirect } from '@sveltejs/kit'
3 | import fs from 'node:fs'
4 | import { opendir } from 'node:fs/promises'
5 | import path from 'node:path'
6 | import { lookup } from 'mrmime'
7 | import { env } from '$env/dynamic/private'
8 |
9 | async function getFilename({ fspath, filename, uapath }: { fspath: string; filename: string; uapath?: string }) {
10 | try {
11 | for await (const e of await opendir(fspath)) {
12 | const p = path.parse(e.name)
13 | if (e.isFile() && (p.base === filename || p.name === filename)) return path.join(uapath || fspath, p.base)
14 | }
15 | } catch (e) {
16 | console.error(e)
17 | }
18 | }
19 |
20 | export async function GET({ params }) {
21 | const filename = path.basename(params.slug)
22 |
23 | const job1 = getFilename({ fspath: env.LOGOS_DIR!, filename })
24 | const job2 = getFilename({ fspath: dev ? 'static/fallback-logos' : 'client/fallback-logos', uapath: '/fallback-logos', filename })
25 |
26 | const path1 = await job1
27 | if (path1)
28 | return new Response(fs.createReadStream(path1) as unknown as ReadableStream, { headers: { 'Content-Type': lookup(path1) || 'application/octet-stream' } })
29 | const path2 = await job2
30 | if (path2) redirect(307, path2)
31 | error(404, 'file not found')
32 | }
33 |
--------------------------------------------------------------------------------
/src/routes/messages/Widget.svelte:
--------------------------------------------------------------------------------
1 |
13 |
14 | {#each messages as msg}
15 | {#if !hiddenMsgs.includes(msg.html)}
16 |
24 | (hiddenMsgs = [...hiddenMsgs, msg.html])}
27 | title={$t('common.close')}
28 | class="absolute -top-2 -right-2 flex w-4 items-center justify-center rounded-full bg-gray-800 hover:scale-125 hover:bg-red-800"
29 | >
30 |
31 |
32 |
33 | {@html msg.html}
34 |
35 | {/if}
36 | {/each}
37 |
--------------------------------------------------------------------------------
/src/routes/messages/messages.css:
--------------------------------------------------------------------------------
1 | @reference "tailwindcss";
2 |
3 | .message a,
4 | .message a:visited {
5 | text-decoration: none;
6 | @apply text-blue-700 dark:text-blue-500;
7 | }
8 |
9 | .message p {
10 | font-size: 1rem;
11 | margin-bottom: 0.3rem;
12 | }
13 |
14 | .message h1,
15 | .message h2,
16 | .message h3,
17 | .message h4 {
18 | margin: 1.414rem 0 0.5rem;
19 | font-weight: inherit;
20 | line-height: 1.42;
21 | }
22 |
23 | .message h1 {
24 | margin-top: 0;
25 | font-size: 3.998rem;
26 | }
27 |
28 | .message h2 {
29 | font-size: 2.827rem;
30 | }
31 |
32 | .message h3 {
33 | font-size: 1.999rem;
34 | }
35 |
36 | .message h4 {
37 | font-size: 1.414rem;
38 | }
39 |
40 | .message h5 {
41 | font-size: 1.121rem;
42 | }
43 |
44 | .message h6 {
45 | font-size: 0.88rem;
46 | }
47 |
48 | .message small {
49 | font-size: 0.707em;
50 | }
51 |
52 | .message img,
53 | .message video,
54 | .message svg {
55 | max-width: 100%;
56 | }
57 |
58 | .message ul {
59 | list-style-type: disc;
60 | list-style-position: inside;
61 | }
62 |
63 | .message ol {
64 | list-style-type: decimal;
65 | list-style-position: inside;
66 | }
67 |
68 | .message code {
69 | font-family: monospace;
70 | letter-spacing: -1px;
71 | }
72 |
73 | .message hr {
74 | @apply my-1 border-gray-500 dark:border-gray-400;
75 | }
76 |
--------------------------------------------------------------------------------
/src/routes/search/+server.ts:
--------------------------------------------------------------------------------
1 | import { error, json } from '@sveltejs/kit'
2 | import { getUserSearchEngines } from '$lib/server/authz'
3 |
4 | export async function GET({ url, locals }) {
5 | const autocompleteUrl = url.searchParams.get('autocomplete_url')
6 | const searchTerm = url.searchParams.get('query')
7 | if (!autocompleteUrl || !searchTerm) {
8 | error(422)
9 | }
10 |
11 | const userSearchEngines = getUserSearchEngines(locals.user)
12 | if (!userSearchEngines.some(engine => engine.autocomplete_url && engine.autocomplete_url === autocompleteUrl)) {
13 | error(403, 'user not allowed to use specified search engine') // prevent SSRF
14 | }
15 |
16 | const autoCompUrl = new URL(autocompleteUrl)
17 | const params = autoCompUrl.searchParams
18 | params.set('q', searchTerm)
19 |
20 | const response = await fetch(autoCompUrl, {
21 | headers: {
22 | Pragma: 'no-cache',
23 | 'Cache-Control': 'no-cache'
24 | }
25 | })
26 | if (!response.ok || response.status === 204) error(504, 'search provider error: ' + (await response.text()))
27 | const resbody: any = (await response.json()) as unknown
28 | if (resbody.suggestions) {
29 | return json(resbody.suggestions.map((suggestion: { text: string }) => suggestion.text))
30 | } else if (Array.isArray(resbody) && resbody.length === 2 && resbody[0] === searchTerm) {
31 | return json(resbody[1])
32 | } else if (Array.isArray(resbody) && resbody.length > 0 && resbody[0].phrase) {
33 | return json(resbody.map(res => res.phrase))
34 | } else if (Array.isArray(resbody) && resbody.every(item => typeof item === 'string')) {
35 | return json(resbody)
36 | } else {
37 | error(500, 'search provider response format not implemented')
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/routes/settings/+layout.server.ts:
--------------------------------------------------------------------------------
1 | import type { LayoutServerLoad } from './$types'
2 |
3 | export const load: LayoutServerLoad = async ({ locals }) => {
4 | return {
5 | isWeatherProviderConfigured: !!locals.sysConfig.openweathermap_api_key
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/src/routes/settings/+layout.svelte:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 | {$t('common.settings')}
14 |
15 |
16 |
80 |
81 |
88 |
--------------------------------------------------------------------------------
/src/routes/settings/+server.ts:
--------------------------------------------------------------------------------
1 | import { redirect } from '@sveltejs/kit'
2 |
3 | export async function GET() {
4 | redirect(307, '/settings/background')
5 | }
6 |
--------------------------------------------------------------------------------
/src/routes/settings/Message.svelte:
--------------------------------------------------------------------------------
1 |
9 |
10 | {#if text}
11 |
28 | {#if kind === 'info'}
29 |
30 | {:else if kind === 'success'}
31 |
32 | {:else if kind === 'error'}
33 |
34 | {/if}
35 |
36 | {#key text}
37 |
38 | {text}
39 |
40 | {/key}
41 |
42 | {/if}
43 |
--------------------------------------------------------------------------------
/src/routes/settings/SaveButton.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
11 |
12 | {$t('common.save')}
13 |
14 |
--------------------------------------------------------------------------------
/src/routes/settings/admin/+page.server.ts:
--------------------------------------------------------------------------------
1 | import type { Actions, PageServerLoad } from './$types'
2 | import { reloadSysConfig } from '$lib/server/sysconfig'
3 | import { error } from '@sveltejs/kit'
4 | import { reloadAllUsersConfig } from '$lib/server/userconfig'
5 | import cache from '$lib/server/httpcache'
6 |
7 | export const load: PageServerLoad = async ({ locals }) => {
8 | return {
9 | userinfo: locals.user,
10 | appinfo: locals.sysConfig.appInfo
11 | }
12 | }
13 |
14 | export const actions: Actions = {
15 | reload: async ({ locals }) => {
16 | if (locals.user.isAdmin) {
17 | await reloadSysConfig()
18 | await reloadAllUsersConfig()
19 | await cache.clear()
20 | return { message: 'Reload successful' }
21 | } else {
22 | error(403, 'user is not admin')
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/routes/settings/admin/+page.svelte:
--------------------------------------------------------------------------------
1 |
12 |
13 |
25 |
26 |
27 |
{$t('settings.admin.auth-data')}
28 |
29 |
30 | UserID: {data.userinfo?.userid}
31 | Username: {data.userinfo?.username}
32 | Email: {data.userinfo?.email}
33 | Groups: {data.userinfo?.groups?.sort().join(', ')}
34 | Is admin: {data.userinfo?.isAdmin}
35 |
36 |
37 |
{$t('settings.admin.about-app')}
38 |
39 |
40 | Version: {data.appinfo.version}
41 | Build Date: {data.appinfo.buildDate}
42 |
43 |
44 |
45 |
46 |
53 |
--------------------------------------------------------------------------------
/src/routes/settings/background/+page.server.ts:
--------------------------------------------------------------------------------
1 | import type { Actions, PageServerLoad } from './$types'
2 | import { saveUserConfig } from '../utils'
3 | import { getParticlesList } from '$lib/backgrounds/particles'
4 | import { hasLocalBgImgs } from '$lib/backgrounds/random'
5 |
6 | export const load: PageServerLoad = async () => {
7 | return {
8 | particleList: getParticlesList(),
9 | hasLocalBgImgs: await hasLocalBgImgs()
10 | }
11 | }
12 |
13 | export const actions: Actions = {
14 | 'reload-random-background-image': async ({ cookies }) => {
15 | cookies.delete('bgimg', { path: '/' })
16 | },
17 | save: async ({ locals, request, cookies }) => {
18 | const job = saveUserConfig({ locals, request })
19 | cookies.delete('bgimg', { path: '/' })
20 | return await job
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/src/routes/settings/dashboard/+page.server.ts:
--------------------------------------------------------------------------------
1 | import type { Actions } from '@sveltejs/kit'
2 | import { saveUserConfig } from '../utils'
3 |
4 | export const actions: Actions = {
5 | save: saveUserConfig
6 | }
7 |
--------------------------------------------------------------------------------
/src/routes/settings/dashboard/+page.svelte:
--------------------------------------------------------------------------------
1 |
26 |
27 |
28 |
29 |
84 |
85 |
96 |
--------------------------------------------------------------------------------
/src/routes/settings/dashboard/TilesVisualizer.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 | {#if mode === 'center' || mode === 'wide'}
7 |
8 |
9 |
10 | {#each [0, 0, 0, 0, 0, 0, 0, 0, 0, 0] as _}
11 |
12 | {/each}
13 |
14 |
15 | {:else}
16 |
21 | {#if mode === 'split'}
22 |
23 |
24 | {/if}
25 |
26 |
27 | {#each [0, 0, 0, 0, 0, 0, 0, 0] as _}
28 |
29 | {/each}
30 |
31 |
32 | {/if}
33 |
--------------------------------------------------------------------------------
/src/routes/settings/dashboard/Toggle.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/routes/settings/system/+page.server.ts:
--------------------------------------------------------------------------------
1 | import type { Actions } from '@sveltejs/kit'
2 | import { saveUserConfig } from '../utils'
3 |
4 | export const actions: Actions = {
5 | save: saveUserConfig
6 | }
7 |
--------------------------------------------------------------------------------
/src/routes/settings/system/+page.svelte:
--------------------------------------------------------------------------------
1 |
31 |
32 |
33 |
34 |
35 |
36 | Theme
37 |
41 | Auto
42 | Light
43 | Dark
44 |
45 |
46 |
47 | Language
48 |
94 |
95 | Current language:
96 | {data.userLang}
97 |
98 |
99 |
100 |
101 |
102 | Open Source Licenses
104 |
105 |
106 |
109 |
--------------------------------------------------------------------------------
/src/routes/settings/utils.ts:
--------------------------------------------------------------------------------
1 | import { error } from '@sveltejs/kit'
2 | import { setUserConfig, userBackgroundImgFilePath } from '$lib/server/userconfig'
3 | import { genRandomId } from '$lib/server/random'
4 | import type { UserConfig } from '$lib/server/userconfig/types'
5 | import path from 'node:path'
6 | import { writeFile, unlink } from 'node:fs/promises'
7 | import { t } from '$lib/translations'
8 |
9 | export const saveUserConfig = async ({ locals, request }: { locals: App.Locals; request: Request }) => {
10 | const data = await request.formData()
11 | const userConfigStr = data.get('userConfig')
12 | if (typeof userConfigStr !== 'string') error(422)
13 | const userConfig: UserConfig = JSON.parse(userConfigStr)
14 |
15 | if (data.has('bgImg')) {
16 | const oldImgId = locals.userConfig.backgrounds[0].static_image.upload_url
17 | if (oldImgId)
18 | try {
19 | await unlink(userBackgroundImgFilePath(oldImgId))
20 | } catch (e) {
21 | console.error('could not delete old background image:', oldImgId, e)
22 | }
23 | const bgImg = (await data.get('bgImg')) as File
24 | const newImgId = genRandomId() + path.extname(bgImg.name)
25 | await writeFile(userBackgroundImgFilePath(newImgId), bgImg.stream() as unknown as NodeJS.ReadableStream)
26 | userConfig.backgrounds[0].static_image.upload_url = newImgId
27 | }
28 | await setUserConfig(locals.user.userid, userConfig)
29 | return { message: t.get('settings.msg.saved') }
30 | }
31 |
--------------------------------------------------------------------------------
/src/routes/settings/weather/+page.server.ts:
--------------------------------------------------------------------------------
1 | import type { Actions } from '@sveltejs/kit'
2 | import { saveUserConfig } from '../utils'
3 |
4 | export const actions: Actions = {
5 | save: saveUserConfig
6 | }
7 |
--------------------------------------------------------------------------------
/src/routes/sverdle/+layout.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/src/routes/sverdle/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { fail } from '@sveltejs/kit'
2 | import { Game } from './game'
3 | import type { PageServerLoad, Actions } from './$types'
4 |
5 | export const load: PageServerLoad = (({ cookies }) => {
6 | const game = new Game(cookies.get('sverdle'))
7 |
8 | return {
9 | /**
10 | * The player's guessed words so far
11 | */
12 | guesses: game.guesses,
13 |
14 | /**
15 | * An array of strings like '__x_c' corresponding to the guesses, where 'x' means
16 | * an exact match, and 'c' means a close match (right letter, wrong place)
17 | */
18 | answers: game.answers,
19 |
20 | /**
21 | * The correct answer, revealed if the game is over
22 | */
23 | answer: game.answers.length >= 6 ? game.answer : null
24 | }
25 | }) satisfies PageServerLoad
26 |
27 | export const actions = {
28 | /**
29 | * Modify game state in reaction to a keypress. If client-side JavaScript
30 | * is available, this will happen in the browser instead of here
31 | */
32 | update: async ({ request, cookies }) => {
33 | const game = new Game(cookies.get('sverdle'))
34 |
35 | const data = await request.formData()
36 | const key = data.get('key')
37 |
38 | const i = game.answers.length
39 |
40 | if (key === 'backspace') {
41 | game.guesses[i] = game.guesses[i].slice(0, -1)
42 | } else {
43 | game.guesses[i] += key
44 | }
45 |
46 | cookies.set('sverdle', game.toString(), { path: '/sverdle' })
47 | },
48 |
49 | /**
50 | * Modify game state in reaction to a guessed word. This logic always runs on
51 | * the server, so that people can't cheat by peeking at the JavaScript
52 | */
53 | enter: async ({ request, cookies }) => {
54 | const game = new Game(cookies.get('sverdle'))
55 |
56 | const data = await request.formData()
57 | const guess = data.getAll('guess') as string[]
58 |
59 | if (!game.enter(guess)) {
60 | return fail(400, { badGuess: true })
61 | }
62 |
63 | cookies.set('sverdle', game.toString(), { path: '/sverdle' })
64 | },
65 |
66 | restart: async ({ cookies }) => {
67 | cookies.delete('sverdle', { path: '/sverdle' })
68 | }
69 | } satisfies Actions
70 |
--------------------------------------------------------------------------------
/src/routes/sverdle/game.ts:
--------------------------------------------------------------------------------
1 | import { words, allowed } from './words.server'
2 |
3 | export class Game {
4 | index: number
5 | guesses: string[]
6 | answers: string[]
7 | answer: string
8 |
9 | /**
10 | * Create a game object from the player's cookie, or initialise a new game
11 | */
12 | constructor(serialized: string | undefined = undefined) {
13 | if (serialized) {
14 | const [index, guesses, answers] = serialized.split('-')
15 |
16 | this.index = +index
17 | this.guesses = guesses ? guesses.split(' ') : []
18 | this.answers = answers ? answers.split(' ') : []
19 | } else {
20 | this.index = Math.floor(Math.random() * words.length)
21 | this.guesses = ['', '', '', '', '', '']
22 | this.answers = []
23 | }
24 |
25 | this.answer = words[this.index]
26 | }
27 |
28 | /**
29 | * Update game state based on a guess of a five-letter word. Returns
30 | * true if the guess was valid, false otherwise
31 | */
32 | enter(letters: string[]) {
33 | const word = letters.join('')
34 | const valid = allowed.has(word)
35 |
36 | if (!valid) return false
37 |
38 | this.guesses[this.answers.length] = word
39 |
40 | const available = Array.from(this.answer)
41 | const answer = Array(5).fill('_')
42 |
43 | // first, find exact matches
44 | for (let i = 0; i < 5; i += 1) {
45 | if (letters[i] === available[i]) {
46 | answer[i] = 'x'
47 | available[i] = ' '
48 | }
49 | }
50 |
51 | // then find close matches (this has to happen
52 | // in a second step, otherwise an early close
53 | // match can prevent a later exact match)
54 | for (let i = 0; i < 5; i += 1) {
55 | if (answer[i] === '_') {
56 | const index = available.indexOf(letters[i])
57 | if (index !== -1) {
58 | answer[i] = 'c'
59 | available[index] = ' '
60 | }
61 | }
62 | }
63 |
64 | this.answers.push(answer.join(''))
65 |
66 | return true
67 | }
68 |
69 | /**
70 | * Serialize game state so it can be set as a cookie
71 | */
72 | toString() {
73 | return `${this.index}-${this.guesses.join(' ')}-${this.answers.join(' ')}`
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/routes/sverdle/how-to-play/+page.svelte:
--------------------------------------------------------------------------------
1 |
2 | How to play Sverdle
3 |
4 |
5 |
6 |
7 |
How to play Sverdle
8 |
9 |
10 | Sverdle is a clone of Wordle , the word guessing game. To play, enter a five-letter English
11 | word. For example:
12 |
13 |
14 |
15 | r
16 | i
17 | t
18 | z
19 | y
20 |
21 |
22 |
23 | The y is in the right place. r and
24 | t
25 | are the right letters, but in the wrong place. The other letters are wrong, and can be discarded. Let's make another guess:
26 |
27 |
28 |
29 | p
30 | a
31 | r
32 | t
33 | y
34 |
35 |
36 |
This time we guessed right! You have six guesses to get the word.
37 |
38 |
Unlike the original Wordle, Sverdle runs on the server instead of in the browser, making it impossible to cheat.
39 |
40 |
41 |
97 |
--------------------------------------------------------------------------------
/src/routes/weather/+page.server.ts:
--------------------------------------------------------------------------------
1 | import { queryCurrentWeather, queryWeatherForecast } from '$lib/server/weather'
2 | import type { PageServerLoad } from './$types'
3 |
4 | export const load: PageServerLoad = async ({ locals }) => {
5 | return {
6 | weatherForecast: await queryWeatherForecast({ lang: locals.user.lang, userConfig: locals.userConfig }),
7 | currentWeather: await queryCurrentWeather({ lang: locals.user.lang, userConfig: locals.userConfig, failfast: false })
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/src/routes/weather/LineChart.svelte:
--------------------------------------------------------------------------------
1 |
26 |
27 |
28 |
29 | {#each timestamps as ts, idx}
30 | {#if isNewDay(ts, timestamps[idx - 1])}
31 |
32 | {new Date(ts * 1000).toLocaleString(userLang, { day: '2-digit' })}
33 |
34 | {/if}
35 | {/each}
36 |
37 | {#if selectedIdx !== null}
38 |
39 | {/if}
40 |
41 | {#each [1, Math.floor(diaH / 2), diaH - 1] as y}
42 |
43 | {/each}
44 |
45 | {valMax}{unit}
46 | {valMin + Math.round((valMax - valMin) / 2)}{unit}
47 | {valMin}{unit}
48 |
49 |
50 | {source1.legend}
51 | {#if source2}
52 | {source2.legend}
53 | {/if}
54 |
55 | `${padL + idx * entryW},${diaH - ((e - valMin) * diaH) / (valMax - valMin)}`).join(' ')}
61 | >
62 | {#if source2}
63 | `${padL + idx * entryW},${diaH - ((e - valMin) * diaH) / (valMax - valMin)}`).join(' ')}
69 | >
70 | {/if}
71 |
72 |
--------------------------------------------------------------------------------
/src/routes/weather/Widget.svelte:
--------------------------------------------------------------------------------
1 |
21 |
22 | {#if weather}
23 | {@const tempUnit = { metric: '°C', imperial: '°F' }[weather.units]}
24 | {@const speedUnit = { metric: 'km/h', imperial: 'mph' }[weather.units]}
25 |
29 |
30 |
31 |
32 | {weather.temp}{tempUnit}
33 | {#if weather.temp !== weather.feels_like_temp}
34 |
35 | [ {weather.feels_like_temp}{tempUnit}]
36 |
37 | {/if}
38 |
39 |
40 | {weather.weather_text}
41 |
42 |
43 |
44 | {:else}
45 |
46 | {/if}
47 |
--------------------------------------------------------------------------------
/src/routes/weather/current/+server.ts:
--------------------------------------------------------------------------------
1 | import { queryCurrentWeather } from '$lib/server/weather'
2 | import { error, json } from '@sveltejs/kit'
3 |
4 | export async function GET({ locals }) {
5 | if (locals.sysConfig.openweathermap_api_key) {
6 | return json(await queryCurrentWeather({ lang: locals.user.lang, userConfig: locals.userConfig, failfast: false }))
7 | } else {
8 | error(500, 'weather provider not configured')
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/routes/weather/types.d.ts:
--------------------------------------------------------------------------------
1 | export type WeatherConditions =
2 | | 'Thunderstorm'
3 | | 'Drizzle'
4 | | 'Rain'
5 | | 'Snow'
6 | | 'Mist'
7 | | 'Smoke'
8 | | 'Haze'
9 | | 'Dust'
10 | | 'Fog'
11 | | 'Sand'
12 | | 'Ash'
13 | | 'Squall'
14 | | 'Tornado'
15 | | 'Clear'
16 | | 'Clouds'
17 |
18 | interface BaseWeather {
19 | weather_type: WeatherConditions
20 | weather_text: string
21 | weather_icon_url: string
22 |
23 | temp: number
24 | feels_like_temp: number
25 |
26 | wind_speed: number
27 | wind_gust: number
28 | visibility: number | true
29 |
30 | units: 'metric' | 'imperial'
31 | }
32 |
33 | export interface CurrentWeather extends BaseWeather {
34 | sunrise_epoch: number
35 | sunset_epoch: number
36 | }
37 |
38 | export interface WeatherForecast extends BaseWeather {
39 | timestamp: number
40 |
41 | humidity: number
42 | pressure: number
43 | }
44 |
--------------------------------------------------------------------------------
/src/types.d.ts:
--------------------------------------------------------------------------------
1 | // missing declarations
2 | declare module '@victorioberra/trianglify-browser/dist/trianglify.bundle'
3 | declare module 'ical.js'
4 |
--------------------------------------------------------------------------------
/static/fallback-logos/adguard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/adguard.png
--------------------------------------------------------------------------------
/static/fallback-logos/authelia.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/authelia.png
--------------------------------------------------------------------------------
/static/fallback-logos/cryptpad.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/static/fallback-logos/cyberchef.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/cyberchef.png
--------------------------------------------------------------------------------
/static/fallback-logos/docker-registry.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/docker-registry.png
--------------------------------------------------------------------------------
/static/fallback-logos/drawio.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/fallback-logos/drone.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/static/fallback-logos/filebrowser.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/static/fallback-logos/gitea.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/gitea.png
--------------------------------------------------------------------------------
/static/fallback-logos/gitlab.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Group
5 | Created with Sketch.
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/static/fallback-logos/goaccess.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/fallback-logos/hedgedoc.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/hedgedoc.png
--------------------------------------------------------------------------------
/static/fallback-logos/influxdb.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/influxdb.png
--------------------------------------------------------------------------------
/static/fallback-logos/invidious.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/fallback-logos/jellyfin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/jellyfin.png
--------------------------------------------------------------------------------
/static/fallback-logos/juiceshop.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/juiceshop.png
--------------------------------------------------------------------------------
/static/fallback-logos/languagetool.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/languagetool.png
--------------------------------------------------------------------------------
/static/fallback-logos/librespeed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/librespeed.png
--------------------------------------------------------------------------------
/static/fallback-logos/libretranslate.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | image/svg+xml
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/static/fallback-logos/linkwarden.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/linkwarden.png
--------------------------------------------------------------------------------
/static/fallback-logos/mealie.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/mealie.png
--------------------------------------------------------------------------------
/static/fallback-logos/miniflux.svg:
--------------------------------------------------------------------------------
1 | icon
--------------------------------------------------------------------------------
/static/fallback-logos/myspeed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/myspeed.png
--------------------------------------------------------------------------------
/static/fallback-logos/navidrome.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/navidrome.png
--------------------------------------------------------------------------------
/static/fallback-logos/nextcloud.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/fallback-logos/nitter.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/fallback-logos/pgadmin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/pgadmin.png
--------------------------------------------------------------------------------
/static/fallback-logos/pihole.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/fallback-logos/portainer.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/static/fallback-logos/postgres.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/postgres.png
--------------------------------------------------------------------------------
/static/fallback-logos/psitransfer.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/psitransfer.png
--------------------------------------------------------------------------------
/static/fallback-logos/radicale.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/static/fallback-logos/rssbridge.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/rssbridge.png
--------------------------------------------------------------------------------
/static/fallback-logos/screego.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/screego.png
--------------------------------------------------------------------------------
/static/fallback-logos/snapdrop.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/snapdrop.png
--------------------------------------------------------------------------------
/static/fallback-logos/syncthing.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/static/fallback-logos/vscode.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/static/fallback-logos/whoogle.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/fallback-logos/whoogle.png
--------------------------------------------------------------------------------
/static/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/knrdl/hubleys-dashboard/b2af6a80ca5385d8f81e7b1fd8d0e194490f122b/static/favicon.png
--------------------------------------------------------------------------------
/static/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow: /
3 |
--------------------------------------------------------------------------------
/svelte.config.js:
--------------------------------------------------------------------------------
1 | import adapter from '@sveltejs/adapter-node'
2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
3 |
4 | /** @type {import('@sveltejs/kit').Config} */
5 | const config = {
6 | // Consult https://github.com/sveltejs/svelte-preprocess
7 | // for more information about preprocessors
8 | preprocess: vitePreprocess({
9 | postcss: true
10 | }),
11 |
12 | kit: {
13 | adapter: adapter({ precompress: true }),
14 | csp: {
15 | directives: {
16 | 'script-src': ['self'],
17 | 'connect-src': ['self'],
18 | 'manifest-src': ['self'],
19 | 'base-uri': ['self'],
20 | 'object-src': ['none'],
21 | },
22 | }
23 | }
24 | }
25 |
26 | export default config
27 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./.svelte-kit/tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true,
5 | "checkJs": true,
6 | "esModuleInterop": true,
7 | "forceConsistentCasingInFileNames": true,
8 | "resolveJsonModule": true,
9 | "skipLibCheck": true,
10 | "sourceMap": true,
11 | "strict": true,
12 | "moduleResolution": "bundler"
13 | }
14 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
15 | //
16 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
17 | // from the referenced tsconfig.json - TypeScript does not merge them in
18 | }
19 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { sveltekit } from '@sveltejs/kit/vite'
2 | import { defineConfig } from 'vite'
3 | import tailwindcss from "@tailwindcss/vite"
4 |
5 | export default defineConfig({
6 | plugins: [tailwindcss(),sveltekit(), ],
7 | server: {
8 | host: '0.0.0.0'
9 | },
10 | ssr: {
11 | noExternal: ['tsparticles', 'tsparticles-slim', 'tsparticles-engine', 'svelte-particles', '@tsparticles/svelte'] // add all tsparticles libraries here, they're not made for SSR, they're client only
12 | }
13 | })
14 |
--------------------------------------------------------------------------------