├── .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 | Particles thumbnail 10 | {/await} 11 | {:else if particlesName === 'circles'} 12 | {#await import('./thumbnails/circles.png') then thumbnail} 13 | Particles thumbnail 14 | {/await} 15 | {:else if particlesName === 'drizzle'} 16 | {#await import('./thumbnails/drizzle.png') then thumbnail} 17 | Particles thumbnail 18 | {/await} 19 | {:else if particlesName === 'leaves'} 20 | {#await import('./thumbnails/leaves.png') then thumbnail} 21 | Particles thumbnail 22 | {/await} 23 | {:else if particlesName === 'rain'} 24 | {#await import('./thumbnails/rain.png') then thumbnail} 25 | Particles thumbnail 26 | {/await} 27 | {:else if particlesName === 'snow'} 28 | {#await import('./thumbnails/snow.png') then thumbnail} 29 | Particles thumbnail 30 | {/await} 31 | {:else if particlesName === 'squares'} 32 | {#await import('./thumbnails/squares.png') then thumbnail} 33 | Particles thumbnail 34 | {/await} 35 | {:else if particlesName === 'stars'} 36 | {#await import('./thumbnails/stars.png') then thumbnail} 37 | Particles thumbnail 38 | {/await} 39 | {:else if particlesName === 'triangles'} 40 | {#await import('./thumbnails/triangles.png') then thumbnail} 41 | Particles thumbnail 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 |
26 | 27 |
28 | {/if} 29 | 30 | {#if data.userConfig.calendar.show && data.calendarEvents !== null} 31 |
32 | 36 | 37 | 38 |
39 | {/if} 40 | 41 | {#if data.messages && data.messages.length > 0} 42 |
43 | 44 |
45 | {/if} 46 | 47 | {#if data.userConfig.tiles.position === 'split'} 48 |
49 | 52 |
53 | {/if} 54 | 55 |
59 | {#if data.userConfig.tiles.position === 'split'} 60 |
61 | 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 |
14 | {#if data.userConfig.weather.show && data.currentWeather !== 'NOT_ENABLED'} 15 | {#if data.currentWeather === 'NOT_CONFIGURED'} 16 | 17 | 18 | {$t('common.weather')} 19 | 20 | {:else if typeof data.currentWeather !== 'string'} 21 | 22 | 23 | 24 | {/if} 25 | {/if} 26 | {#if $page.url.pathname === '/'} 27 | 28 | 29 | {#if data.userConfig.dashboard.show_settings_text} 30 | {$t('common.settings')} 31 | {/if} 32 | 33 | {:else} 34 | 35 | 36 | {#if data.userConfig.dashboard.show_settings_text} 37 | {$t('common.home')} 38 | {/if} 39 | 40 | {/if} 41 | {#if data.userConfig.clock.show} 42 | 43 | 44 | 45 | {/if} 46 |
47 | 48 | 55 | -------------------------------------------------------------------------------- /src/routes/TileFolder.svelte: -------------------------------------------------------------------------------- 1 | 32 | 33 | 34 | 35 | 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 | 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 | 68 | {:else} 69 | 81 | {/if} 82 | 91 | 100 |
101 |
102 | 103 |
104 | 105 | 106 | {#each rounds as round, idx (round)} 107 | 111 | 114 | 119 | 120 | {/each} 121 | 122 |
112 | {formatTime(round)} 113 | 115 | {#if idx > 0} 116 | +{formatTime(round - rounds[idx - 1])} 117 | {/if} 118 |
123 |
124 | 125 | 132 | -------------------------------------------------------------------------------- /src/routes/clock/timer/+page.svelte: -------------------------------------------------------------------------------- 1 | 67 | 68 | 74 | 75 |
76 | 77 |
78 | 79 | {#if isRunning || isAlarmOn} 80 |
81 | {#if !isAlarmOn} 82 | 96 | {/if} 97 | 107 |
108 | {:else} 109 |
110 | 111 |
:
112 | hh++} /> 113 |
:
114 | mm++} /> 115 | 126 |
127 | {/if} 128 | 129 | 146 | -------------------------------------------------------------------------------- /src/routes/clock/timer/NumberInput.svelte: -------------------------------------------------------------------------------- 1 | 39 | 40 |
41 | 51 | 66 | 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 | 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 |
20 | 78 | 79 |
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 | 42 | {/if} 43 | -------------------------------------------------------------------------------- /src/routes/settings/SaveButton.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 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 |
14 | 15 | 19 |
    20 |
  • Reloads system configuration in config.yml
  • 21 |
  • Reloads user configurations
  • 22 |
  • Clears HTTP caches
  • 23 |
24 | 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 |
30 |
{$t('settings.dashboard.layout')}
31 |
32 | 39 | 46 |
47 | 48 |
{$t('settings.dashboard.position')}
49 |
50 | 55 | 56 | 61 | 62 | 67 |
68 | 69 |
{$t('settings.dashboard.features')}
70 |
71 |
72 | Show Calendar 73 | Show Clock 74 | Show Searchbar 75 | Show Weather 76 |
77 | Show Settings text on dashboard 78 |
79 |
80 |
81 | 82 |
83 |
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 | 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 | 46 | 47 |

Language

48 |
    51 |
  • 52 |
    53 | 64 |
    65 |
  • 66 |
  • 67 |
    68 | 79 | 80 | {#if data.userConfig.language !== null} 81 | 90 | {/if} 91 |
    92 |
  • 93 |
94 |

95 | Current language: 96 | {data.userLang} 97 |

98 | 99 |

100 | 101 | 102 | Open Source Licenses 104 |

105 | 106 |
107 | 108 | 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 | {weather.weather_text} 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 | --------------------------------------------------------------------------------