mirror of
https://github.com/bewcloud/bewcloud.git
synced 2026-03-11 08:54:49 +00:00
Compare commits
60 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f871b72ad | ||
|
|
f50423028b | ||
|
|
69a916d709 | ||
|
|
0d20b4a337 | ||
|
|
917649b97a | ||
|
|
5d394045dd | ||
|
|
264d4da6e5 | ||
|
|
ec334fb8fa | ||
|
|
bd141bf9dc | ||
|
|
1aca444b22 | ||
|
|
6d5ee7b53c | ||
|
|
6ef42d902c | ||
|
|
c26cae625e | ||
|
|
770db3a605 | ||
|
|
290cf6ea4c | ||
|
|
fb2a7d5cce | ||
|
|
bfd4851098 | ||
|
|
0a7e03326f | ||
|
|
3ffd3328a1 | ||
|
|
777a26f492 | ||
|
|
69995c422e | ||
|
|
54852181ab | ||
|
|
01837e4966 | ||
|
|
f64751956a | ||
|
|
c9c5364ca9 | ||
|
|
a0935dd8b9 | ||
|
|
d547948865 | ||
|
|
624fdb69f4 | ||
|
|
05cae80c12 | ||
|
|
dcac5d8c44 | ||
|
|
a68bdba4b5 | ||
|
|
cfa21e6089 | ||
|
|
87d6904261 | ||
|
|
e44a7a61e6 | ||
|
|
829189d0b3 | ||
|
|
31855f4802 | ||
|
|
d86e65475c | ||
|
|
3fdda5b34e | ||
|
|
6f7c534e59 | ||
|
|
8e1b9d1d70 | ||
|
|
86721d8877 | ||
|
|
b2dda31c51 | ||
|
|
6280228759 | ||
|
|
8d78e1f25c | ||
|
|
c4a5166e3b | ||
|
|
c81ef77370 | ||
|
|
1dcbf529a3 | ||
|
|
adde693585 | ||
|
|
577fe087f2 | ||
|
|
6734e9557b | ||
|
|
ba2103afa9 | ||
|
|
7f81d2a0b5 | ||
|
|
49dbc724c8 | ||
|
|
24944de0f6 | ||
|
|
15dcc8803d | ||
|
|
f14c40d05d | ||
|
|
47f443c300 | ||
|
|
02d1d3e6fa | ||
|
|
263cdf544a | ||
|
|
c950e34c36 |
464 changed files with 39205 additions and 7371 deletions
|
|
@ -1,43 +0,0 @@
|
|||
spec:
|
||||
name: bewcloud
|
||||
envs:
|
||||
- key: BASE_URL
|
||||
scope: RUN_AND_BUILD_TIME
|
||||
value: ${app.PUBLIC_URL}
|
||||
services:
|
||||
- name: app
|
||||
dockerfile_path: Dockerfile
|
||||
git:
|
||||
branch: main
|
||||
http_port: 8000
|
||||
instance_count: 1
|
||||
instance_size_slug: basic-xs
|
||||
routes:
|
||||
- path: /
|
||||
health_check:
|
||||
http_path: /
|
||||
source_dir: /
|
||||
envs:
|
||||
- key: POSTGRESQL_HOST
|
||||
scope: RUN_AND_BUILD_TIME
|
||||
value: ${db.HOSTNAME}
|
||||
- key: POSTGRESQL_USER
|
||||
scope: RUN_AND_BUILD_TIME
|
||||
value: ${db.USERNAME}
|
||||
- key: POSTGRESQL_PASSWORD
|
||||
scope: RUN_AND_BUILD_TIME
|
||||
value: ${db.PASSWORD}
|
||||
- key: POSTGRESQL_DBNAME
|
||||
scope: RUN_AND_BUILD_TIME
|
||||
value: ${db.DATABASE}
|
||||
- key: POSTGRESQL_PORT
|
||||
scope: RUN_AND_BUILD_TIME
|
||||
value: ${db.PORT}
|
||||
- key: POSTGRESQL_CAFILE
|
||||
scope: RUN_AND_BUILD_TIME
|
||||
value: ''
|
||||
databases:
|
||||
- name: db
|
||||
engine: PG
|
||||
production: false
|
||||
version: '17'
|
||||
|
|
@ -1,14 +1,11 @@
|
|||
.do
|
||||
.github
|
||||
.git
|
||||
.gitignore
|
||||
docker-compose*
|
||||
Dockerfile
|
||||
render.yaml
|
||||
LICENSE
|
||||
README.md
|
||||
.env.sample
|
||||
node_modules
|
||||
_fresh
|
||||
data-files
|
||||
bewcloud.config.sample.ts
|
||||
|
|
|
|||
2
.dvmrc
2
.dvmrc
|
|
@ -1 +1 @@
|
|||
2.4.4
|
||||
2.6.10
|
||||
|
|
|
|||
|
|
@ -16,5 +16,5 @@ MFA_SALT="fake" # optional, if you want to enable multi-factor authentication
|
|||
OIDC_CLIENT_ID="fake" # optional, if you want to enable SSO (Single Sign-On)
|
||||
OIDC_CLIENT_SECRET="fake" # optional, if you want to enable SSO (Single Sign-On)
|
||||
|
||||
SMTP_USERNAME="fake" # optional, if you want to enable signup email verification or multi-factor authentication via email
|
||||
SMTP_PASSWORD="fake" # optional, if you want to enable signup email verification or multi-factor authentication via email
|
||||
#SMTP_USERNAME="" # optional, if you want to use signup email verification or multi-factor with an email service requiring authentication
|
||||
#SMTP_PASSWORD="" # optional, if you want to use signup email verification or multi-factor with an email service requiring authentication
|
||||
|
|
|
|||
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
|
|
@ -1,7 +1,7 @@
|
|||
github: [bewcloud, BrunoBernardino]
|
||||
custom:
|
||||
[
|
||||
'https://donate.stripe.com/bIYeWBbw00Ape5iaFi',
|
||||
'https://payment-links.mollie.com/payment/wUS9dvewvjEPvseZVHEi5',
|
||||
'https://paypal.me/brunobernardino',
|
||||
'https://gist.github.com/BrunoBernardino/ff5b54c13dd96ac7f9fee6fbfd825b09',
|
||||
]
|
||||
|
|
|
|||
2
.github/workflows/build-docker-image.yml
vendored
2
.github/workflows/build-docker-image.yml
vendored
|
|
@ -22,7 +22,7 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
|
|
|
|||
2
.github/workflows/deploy.yml
vendored
2
.github/workflows/deploy.yml
vendored
|
|
@ -10,7 +10,7 @@ jobs:
|
|||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Configure SSH
|
||||
run: |
|
||||
mkdir -p ~/.ssh/
|
||||
|
|
|
|||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
|
|
@ -6,7 +6,7 @@ jobs:
|
|||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- uses: denoland/setup-deno@v2
|
||||
with:
|
||||
deno-version-file: .dvmrc
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,6 +1,3 @@
|
|||
# Fresh build directory
|
||||
_fresh/
|
||||
|
||||
# npm dependencies
|
||||
node_modules/
|
||||
|
||||
|
|
|
|||
18
Dockerfile
18
Dockerfile
|
|
@ -1,25 +1,25 @@
|
|||
FROM denoland/deno:ubuntu-2.4.4
|
||||
FROM denoland/deno:ubuntu-2.6.10
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
RUN apt-get update && apt-get install -y make
|
||||
RUN apt-get update && apt-get install -y make zip coreutils
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# These steps will be re-run upon each file change in your working directory:
|
||||
ADD . /app
|
||||
|
||||
RUN rm -fr node_modules _fresh
|
||||
# Prepare for any npm modules required "on the fly"
|
||||
RUN mkdir -p /app/node_modules/.deno
|
||||
|
||||
# Build fresh
|
||||
RUN deno task build
|
||||
|
||||
RUN chown -R deno:deno /app /deno-dir
|
||||
RUN chown -R deno:deno /app
|
||||
|
||||
# Prefer not to run as root.
|
||||
USER deno
|
||||
|
||||
# Build frontend components and CSS
|
||||
RUN deno task build
|
||||
|
||||
# Compile the main app so that it doesn't need to be compiled each startup/entry.
|
||||
RUN deno cache --reload main.ts
|
||||
|
||||
CMD ["run", "--allow-all", "main.ts"]
|
||||
CMD ["task", "preview"]
|
||||
|
|
|
|||
221
FAQ.md
Normal file
221
FAQ.md
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
# bewCloud - FAQ (Frequently Asked Questions)
|
||||
|
||||
## How does Contacts/CardDav and Calendar/CalDav work?
|
||||
|
||||
CalDav/CardDav is now available since [v2.3.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.3.0), using [Radicale](https://radicale.org/v3.html) via Docker, which is already _very_ efficient (and battle-tested). The "Contacts" client for CardDav is available since [v2.4.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.3.0) and the "Calendar" client for CalDav is available since [v2.5.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.5.0). [Check this tag/release for custom-made server code where it was all mostly working, except for many edge cases, if you're interested](https://github.com/bewcloud/bewcloud/releases/tag/v0.0.1-self-made-carddav-caldav).
|
||||
|
||||
In order to share a calendar, you can either have a shared user, or you can symlink the calendar to the user's own calendar (simply `ln -s /<absolute-path-to-data-radicale>/collections/collection-root/<owner-user-id>/<calendar-to-share> /<absolute-path-to-data-radicale>/collections/collection-root/<user-id-to-share-with>/`).
|
||||
|
||||
> [!NOTE]
|
||||
> If you're running radicale with docker, the symlink needs to point to the container's directory, usually starting with `/data` if you didn't change the `radicale-config/config`, otherwise the container will fail to load the linked directory.
|
||||
|
||||
## How does private file sharing work?
|
||||
|
||||
Public file sharing is now possible since [v2.2.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.2.0). [Check this PR for advanced sharing with internal and external users, with read and write access that was being done and almost working, if you're interested](https://github.com/bewcloud/bewcloud/pull/4). I ditched all that complexity for simply using [symlinks](https://en.wikipedia.org/wiki/Symbolic_link) for internal sharing, as it served my use case (I have multiple data backups and trust the people I provide accounts to, with the symlinks).
|
||||
|
||||
You can simply `ln -s /<absolute-path-to-data-files>/<owner-user-id>/<directory-to-share> /<absolute-path-to-data-files>/<user-id-to-share-with>/` to create a shared directory between two users, and the same directory can have different names, now.
|
||||
|
||||
> [!NOTE]
|
||||
> If you're running the app with docker, the symlink needs to point to the container's directory, usually starting with `/app` if you didn't change the `Dockerfile`, otherwise the container will fail to load the linked directory.
|
||||
|
||||
## How can I use .env for configuration?
|
||||
|
||||
During [v1](https://github.com/bewcloud/bewcloud/releases/tag/v1.0.0), bewCloud was entirely configured with a `.env` file, but since [v2](https://github.com/bewcloud/bewcloud/releases/tag/v2.0.0) it was swapped to being used exclusively for "secrets", and having a more robust `bewcloud.config.ts` file for configuration. While it's unlikely `.env`-only configuration will be supported again in the future, the advantage of a `bewcloud.config.ts` file is that it's more dynamic and powerful, which means you can "hack" your way into using a `.env` file for configuration, like how it was suggested [in this comment](https://github.com/bewcloud/bewcloud/issues/90#issuecomment-3450344972). It's copied below for reference, and it's a bit outdated, but should serve as a good starting point, and you can make a PR to update it:
|
||||
|
||||
> [!NOTE]
|
||||
> This is not recommended and should only be done if you know what you're doing.
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
bewcloud.config.ts
|
||||
</summary>
|
||||
|
||||
```ts
|
||||
import { Config, OptionalApp, PartialDeep } from './lib/types.ts';
|
||||
|
||||
// Check the Config type for all the possible options and instructions.
|
||||
function requireValue<T>(value: T | undefined, key: string): T {
|
||||
if (value === undefined || value === '') {
|
||||
throw new Error(`Environment variable ${key} is required but not set`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getEnvString(key: string, defaultValue: string): string {
|
||||
return Deno.env.get(key) ?? defaultValue;
|
||||
}
|
||||
|
||||
function getEnvBoolean(key: string, defaultValue: boolean): boolean {
|
||||
const value = Deno.env.get(key);
|
||||
if (value === undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
return value.toLowerCase() === 'true' || value === '1';
|
||||
}
|
||||
|
||||
function getEnvNumber(key: string, defaultValue: number): number {
|
||||
const value = Deno.env.get(key);
|
||||
if (value === undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
const parsed = parseInt(value, 10);
|
||||
return isNaN(parsed) ? defaultValue : parsed;
|
||||
}
|
||||
|
||||
function getEnvStringArray(key: string, defaultValue: string[]): string[] {
|
||||
const value = Deno.env.get(key);
|
||||
if (value === undefined) {
|
||||
return defaultValue;
|
||||
}
|
||||
return value
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0);
|
||||
}
|
||||
const config: PartialDeep<Config> = {
|
||||
auth: {
|
||||
baseUrl: getEnvString('BEWCLOUD_AUTH_BASE_URL', 'http://localhost:8000'),
|
||||
allowSignups: getEnvBoolean('BEWCLOUD_AUTH_ALLOW_SIGNUPS', false),
|
||||
enableEmailVerification: getEnvBoolean(
|
||||
'BEWCLOUD_AUTH_ENABLE_EMAIL_VERIFICATION',
|
||||
false,
|
||||
),
|
||||
enableForeverSignup: getEnvBoolean(
|
||||
'BEWCLOUD_AUTH_ENABLE_FOREVER_SIGNUP',
|
||||
true,
|
||||
),
|
||||
enableMultiFactor: getEnvBoolean(
|
||||
'BEWCLOUD_AUTH_ENABLE_MULTI_FACTOR',
|
||||
false,
|
||||
),
|
||||
allowedCookieDomains: getEnvStringArray(
|
||||
'BEWCLOUD_AUTH_ALLOWED_COOKIE_DOMAINS',
|
||||
[],
|
||||
),
|
||||
skipCookieDomainSecurity: getEnvBoolean(
|
||||
'BEWCLOUD_AUTH_SKIP_COOKIE_DOMAIN_SECURITY',
|
||||
false,
|
||||
),
|
||||
enableSingleSignOn: getEnvBoolean(
|
||||
'BEWCLOUD_AUTH_ENABLE_SINGLE_SIGN_ON',
|
||||
false,
|
||||
),
|
||||
singleSignOnUrl: getEnvString('BEWCLOUD_AUTH_SINGLE_SIGN_ON_URL', ''),
|
||||
singleSignOnEmailAttribute: getEnvString(
|
||||
'BEWCLOUD_AUTH_SINGLE_SIGN_ON_EMAIL_ATTRIBUTE',
|
||||
'email',
|
||||
),
|
||||
singleSignOnScopes: getEnvStringArray(
|
||||
'BEWCLOUD_AUTH_SINGLE_SIGN_ON_SCOPES',
|
||||
['openid', 'email'],
|
||||
),
|
||||
},
|
||||
files: {
|
||||
rootPath: getEnvString('BEWCLOUD_FILES_ROOT_PATH', 'data-files'),
|
||||
allowPublicSharing: getEnvBoolean(
|
||||
'BEWCLOUD_FILES_ALLOW_PUBLIC_SHARING',
|
||||
false,
|
||||
),
|
||||
},
|
||||
core: {
|
||||
enabledApps: getEnvStringArray('BEWCLOUD_CORE_ENABLED_APPS', [
|
||||
'dashboard',
|
||||
'files',
|
||||
'news',
|
||||
'notes',
|
||||
'photos',
|
||||
'expenses',
|
||||
]) as OptionalApp[],
|
||||
},
|
||||
visuals: {
|
||||
title: getEnvString('BEWCLOUD_VISUALS_TITLE', ''),
|
||||
description: getEnvString('BEWCLOUD_VISUALS_DESCRIPTION', ''),
|
||||
helpEmail: getEnvString('BEWCLOUD_VISUALS_HELP_EMAIL', 'help@bewcloud.com'),
|
||||
},
|
||||
email: {
|
||||
from: getEnvString('BEWCLOUD_EMAIL_FROM', 'help@bewcloud.com'),
|
||||
host: getEnvString('BEWCLOUD_EMAIL_HOST', 'localhost'),
|
||||
port: getEnvNumber('BEWCLOUD_EMAIL_PORT', 465),
|
||||
},
|
||||
contacts: {
|
||||
enableCardDavServer: getEnvBoolean(
|
||||
'BEWCLOUD_CONTACTS_ENABLE_CARDDAV_SERVER',
|
||||
true,
|
||||
),
|
||||
cardDavUrl: getEnvString(
|
||||
'BEWCLOUD_CONTACTS_CARDDAV_URL',
|
||||
'http://127.0.0.1:5232',
|
||||
),
|
||||
},
|
||||
calendar: {
|
||||
enableCalDavServer: getEnvBoolean(
|
||||
'BEWCLOUD_CALENDAR_ENABLE_CALDAV_SERVER',
|
||||
true,
|
||||
),
|
||||
calDavUrl: getEnvString(
|
||||
'BEWCLOUD_CALENDAR_CALDAV_URL',
|
||||
'http://127.0.0.1:5232',
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
Append the following to your `.env` file
|
||||
|
||||
<details>
|
||||
<summary> .env </summary>
|
||||
|
||||
```env
|
||||
# Auth Configuration
|
||||
BEWCLOUD_AUTH_BASE_URL=http://localhost:8000
|
||||
BEWCLOUD_AUTH_ALLOW_SIGNUPS=false
|
||||
BEWCLOUD_AUTH_ENABLE_EMAIL_VERIFICATION=false
|
||||
BEWCLOUD_AUTH_ENABLE_FOREVER_SIGNUP=true
|
||||
BEWCLOUD_AUTH_ENABLE_MULTI_FACTOR=false
|
||||
# Comma-separated list of allowed cookie domains
|
||||
BEWCLOUD_AUTH_ALLOWED_COOKIE_DOMAINS=
|
||||
BEWCLOUD_AUTH_SKIP_COOKIE_DOMAIN_SECURITY=false
|
||||
|
||||
# Single Sign-On Configuration
|
||||
BEWCLOUD_AUTH_ENABLE_SINGLE_SIGN_ON=false
|
||||
BEWCLOUD_AUTH_SINGLE_SIGN_ON_URL=
|
||||
BEWCLOUD_AUTH_SINGLE_SIGN_ON_EMAIL_ATTRIBUTE=email
|
||||
# Comma-separated list of scopes
|
||||
BEWCLOUD_AUTH_SINGLE_SIGN_ON_SCOPES=openid,email
|
||||
|
||||
# Files Configuration
|
||||
BEWCLOUD_FILES_ROOT_PATH=data-files
|
||||
BEWCLOUD_FILES_ALLOW_PUBLIC_SHARING=false
|
||||
|
||||
# Core Configuration
|
||||
# Comma-separated list of enabled apps (dashboard and files cannot be disabled)
|
||||
BEWCLOUD_CORE_ENABLED_APPS=news,notes,photos,expenses,contacts,calendar
|
||||
|
||||
# Visuals Configuration
|
||||
BEWCLOUD_VISUALS_TITLE=
|
||||
BEWCLOUD_VISUALS_DESCRIPTION=
|
||||
BEWCLOUD_VISUALS_HELP_EMAIL=help@bewcloud.com
|
||||
|
||||
# Email/SMTP Configuration
|
||||
BEWCLOUD_EMAIL_FROM=help@bewcloud.com
|
||||
BEWCLOUD_EMAIL_HOST=localhost
|
||||
BEWCLOUD_EMAIL_PORT=465
|
||||
|
||||
# Contacts Configuration
|
||||
BEWCLOUD_CONTACTS_ENABLE_CARDDAV_SERVER=true
|
||||
BEWCLOUD_CONTACTS_CARDDAV_URL=http://127.0.0.1:5232
|
||||
|
||||
# Calendar Configuration
|
||||
BEWCLOUD_CALENDAR_ENABLE_CALDAV_SERVER=true
|
||||
BEWCLOUD_CALENDAR_CALDAV_URL=http://127.0.0.1:5232
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Wasn't this made with Fresh?
|
||||
|
||||
Up until [v4.0.0](https://github.com/bewcloud/bewcloud/releases/tag/v4.0.0), bewCloud was made with [Fresh](https://fresh.deno.dev/), but as per [#141](https://github.com/bewcloud/bewcloud/issues/141) it has since been rewritten in standard Deno to use a more web-standards-based backend, easier to maintain and extend, without requiring breaking updates.
|
||||
38
Makefile
38
Makefile
|
|
@ -1,3 +1,5 @@
|
|||
SHELL := /bin/bash
|
||||
|
||||
.PHONY: start
|
||||
start:
|
||||
deno task start
|
||||
|
|
@ -13,16 +15,40 @@ test:
|
|||
|
||||
.PHONY: build
|
||||
build:
|
||||
deno task build
|
||||
make download-frontend-imports
|
||||
make build-babel
|
||||
make build-tailwind
|
||||
|
||||
.PHONY: download-frontend-imports
|
||||
download-frontend-imports:
|
||||
deno task download-frontend-imports
|
||||
|
||||
.PHONY: build-babel
|
||||
build-babel:
|
||||
deno run --allow-env --allow-ffi --allow-sys --allow-read --allow-write=public/components npm:@babel/cli@7.28.6/babel ./components --out-dir ./public/components --extensions ".ts,.tsx"
|
||||
|
||||
.PHONY: watch-babel
|
||||
watch-babel:
|
||||
deno run --allow-env --allow-ffi --allow-sys --allow-read --allow-write=public/components npm:@babel/cli@7.28.6/babel ./components --out-dir ./public/components --extensions ".ts,.tsx" --watch
|
||||
|
||||
.PHONY: migrate-db
|
||||
migrate-db:
|
||||
deno run --allow-net --allow-read --allow-env migrate-db.ts
|
||||
|
||||
.PHONY: crons/cleanup
|
||||
crons/cleanup:
|
||||
deno run --allow-net --allow-read --allow-env crons/cleanup.ts
|
||||
deno task migrate-db
|
||||
|
||||
.PHONY: exec-db
|
||||
exec-db:
|
||||
docker exec -it -u postgres $(shell basename $(CURDIR))-postgresql-1 psql
|
||||
|
||||
.PHONY: build-tailwind
|
||||
build-tailwind:
|
||||
deno install --allow-scripts npm:tailwindcss@4.2.0 npm:@tailwindcss/cli@4.2.0
|
||||
deno run --allow-env --allow-read --allow-sys --allow-ffi --vendor --unstable-detect-cjs --allow-write=public/css,/var/folders --allow-scripts npm:@tailwindcss/cli@4.2.0 -i ./public/css/tailwind-input.css -o ./public/css/tailwind.css
|
||||
|
||||
.PHONY: watch-tailwind
|
||||
watch-tailwind:
|
||||
deno install --allow-scripts npm:tailwindcss@4.2.0 npm:@tailwindcss/cli@4.2.0
|
||||
deno run --allow-env --allow-read --allow-sys --allow-ffi --vendor --unstable-detect-cjs --allow-write=public/css,/var/folders --allow-scripts npm:@tailwindcss/cli@4.2.0 -w -i ./public/css/tailwind-input.css -o ./public/css/tailwind.css
|
||||
|
||||
.PHONY: preview
|
||||
preview:
|
||||
deno task preview
|
||||
|
|
|
|||
102
README.md
102
README.md
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
[](https://github.com/bewcloud/bewcloud/actions?workflow=Run+Tests)
|
||||
|
||||
This is the [bewCloud app](https://bewcloud.com) built using [Fresh](https://fresh.deno.dev) and deployed using [docker compose](https://docs.docker.com/compose/).
|
||||
This is the [bewCloud app](https://bewcloud.com) built with [Deno](https://deno.land/) and deployed using [docker compose](https://docs.docker.com/compose/).
|
||||
|
||||
If you're looking for the desktop sync app, it's at [`bewcloud-desktop`](https://github.com/bewcloud/bewcloud-desktop).
|
||||
|
||||
|
|
@ -10,36 +10,43 @@ If you're looking for the mobile app, it's at [`bewcloud-mobile`](https://github
|
|||
|
||||
## Self-host it!
|
||||
|
||||
[](https://cloud.digitalocean.com/apps/new?repo=https://github.com/bewcloud/bewcloud)
|
||||
[-51a4fb?style=for-the-badge)](https://payment-links.mollie.com/payment/AiEHa5EpD6wkZN5r6Vs3c)
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/bewcloud/bewcloud)
|
||||
|
||||
[-51a4fb?style=for-the-badge)](https://buy.stripe.com/eVa01HgQk0Ap0eseVz)
|
||||
|
||||
[-51a4fb?style=for-the-badge)](https://buy.stripe.com/fZu8wOb5RfIydj56FA1gs0J)
|
||||
|
||||
Or on your own machine:
|
||||
|
||||
Download/copy [`docker-compose.yml`](/docker-compose.yml), [`.env.sample`](/.env.sample) as `.env`, [`bewcloud.config.sample.ts`](/bewcloud.config.sample.ts) as `bewcloud.config.ts`, and [`radicale-config/config`](/radicale-config/config) as `radicale-config/config` (if you're using CalDav/CardDav).
|
||||
|
||||
> [!NOTE]
|
||||
> `1993:1993` below comes from deno's [docker image](https://github.com/denoland/deno_docker/blob/2abfe921484bdc79d11c7187a9d7b59537457c31/ubuntu.dockerfile#L20-L22) where `1993` is the default user id in it. It might change in the future since I don't control it.
|
||||
Or, to run on your own machine, start with these commands:
|
||||
|
||||
```sh
|
||||
$ mkdir data-files data-radicale # local directories for storing user-uploaded files and radicale data
|
||||
$ sudo chown -R 1993:1993 data-files # solves permission-related issues in the container with uploading files
|
||||
$ docker compose up -d # makes the app available at http://localhost:8000
|
||||
$ docker compose run --rm website bash -c "cd /app && make migrate-db" # initializes/updates the database (only needs to be executed the first time and on any data updates)
|
||||
mkdir data-files data-radicale radicale-config # local directories for storing user-uploaded files, radicale data, and radicale config (these last two are necessary only if you're using CalDav/CardDav/Contacts)
|
||||
```
|
||||
|
||||
Alternatively, check the [Development section below](#development).
|
||||
Now, download/copy the following configuration files (and tweak their contents as necessary, though no changes should yield a working — but very unsafe — setup):
|
||||
|
||||
- [`docker-compose.yml`](/docker-compose.yml)
|
||||
- [`.env.sample`](/.env.sample) and save it as `.env`
|
||||
- [`bewcloud.config.sample.ts`](/bewcloud.config.sample.ts) and save it as `bewcloud.config.ts`
|
||||
- [`radicale-config/config`](/radicale-config/config) and save it as `radicale-config/config` (necessary only if you're using CalDav/CardDav/Contacts)
|
||||
|
||||
Finally, run these commands:
|
||||
|
||||
```sh
|
||||
docker compose up -d # makes the app available at http://localhost:8000
|
||||
docker compose run --rm website bash -c "cd /app && make migrate-db" # initializes/updates the database (only needs to be executed the first time and on any data updates)
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> If you run into permission issues, you can try running `sudo chown -R 1993:1993 data-files` to fix them.
|
||||
>
|
||||
> `1993:1993` above comes from deno's [docker image](https://github.com/denoland/deno_docker/blob/2abfe921484bdc79d11c7187a9d7b59537457c31/ubuntu.dockerfile#L20-L22) where `1993` is the default user id in it. It might change in the future since I don't control it.
|
||||
|
||||
If you're interested in building/contributing (or just running the app locally), check the [Development section below](#development).
|
||||
|
||||
See the [Community Links](#community-links) section for alternative ways of running bewCloud yourself.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Even with signups disabled (`config.auth.allowSignups=false`), the first signup will work and become an admin.
|
||||
|
||||
## Sponsors
|
||||
|
||||
These are the amazing entities or individuals who are sponsoring this project for this current month. If you'd like to show up here, [check the GitHub Sponsors page](https://github.com/sponsors/bewcloud) or [make a donation](https://donate.stripe.com/bIYeWBbw00Ape5iaFi) above $50 ($100 to show up on the website)!
|
||||
These are the amazing entities or individuals who are sponsoring this project for this current month. If you'd like to show up here, [check the GitHub Sponsors page](https://github.com/sponsors/bewcloud) or [make a donation](https://payment-links.mollie.com/payment/wUS9dvewvjEPvseZVHEi5) above $50 ($100 to show up on the website)!
|
||||
|
||||
<p align="center" width="100%">
|
||||
<a href="https://nlnet.nl/project/bewCloud/" title="NLnet Foundation">
|
||||
|
|
@ -61,26 +68,27 @@ These are the amazing entities or individuals who are sponsoring this project fo
|
|||
### Commands
|
||||
|
||||
```sh
|
||||
$ docker compose -f docker-compose.dev.yml up # (optional) runs docker with postgres, locally
|
||||
$ make migrate-db # runs any missing database migrations
|
||||
$ make start # runs the app
|
||||
$ make format # formats the code
|
||||
$ make test # runs tests
|
||||
docker compose -f docker-compose.dev.yml up # (optional) runs docker with postgres, locally
|
||||
make migrate-db # runs any missing database migrations
|
||||
make start # runs the app in development mode (watches for CSS file changes and recompiles the CSS)
|
||||
make format # (optional) formats the code (if you're interested in contributing)
|
||||
make test # (optional) runs tests (if you're interested in contributing)
|
||||
make build # (optional) generates CSS for production, if you've made changes
|
||||
```
|
||||
|
||||
### Other less-used commands
|
||||
### Other less-used commands (mostly for development)
|
||||
|
||||
```sh
|
||||
$ make exec-db # runs psql inside the postgres container, useful for running direct development queries like `DROP DATABASE "bewcloud"; CREATE DATABASE "bewcloud";`
|
||||
$ make build # generates all static files for production deploy
|
||||
make preview # runs the app in production mode (serves the app from the built files)
|
||||
make exec-db # runs psql inside the postgres container, useful for running direct development queries like `DROP DATABASE "bewcloud"; CREATE DATABASE "bewcloud";`
|
||||
```
|
||||
|
||||
## Structure
|
||||
## File/Directory Structure
|
||||
|
||||
- Routes defined at `routes/`.
|
||||
- Static files are defined at `static/`.
|
||||
- Frontend-only components are defined at `components/`.
|
||||
- Isomorphic components are defined at `islands/`.
|
||||
- Backend routes are defined at `routes.ts`
|
||||
- Publicly-available files are defined at `public/`
|
||||
- Pages are defined at `pages/`.
|
||||
- JSX/TSX components are defined at `components/`.
|
||||
- Cron jobs are defined at `crons/`.
|
||||
- Reusable bits of code are defined at `lib/`.
|
||||
- Database migrations are defined at `db-migrations/`.
|
||||
|
|
@ -89,24 +97,20 @@ $ make build # generates all static files for production deploy
|
|||
|
||||
Just push to the `main` branch.
|
||||
|
||||
## How does Contacts/CardDav and Calendar/CalDav work?
|
||||
## FAQ (Frequently Asked Questions)
|
||||
|
||||
CalDav/CardDav is now available since [v2.3.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.3.0), using [Radicale](https://radicale.org/v3.html) via Docker, which is already _very_ efficient (and battle-tested). The client for CardDav is available since [v2.4.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.3.0) and for CalDav is not yet implemented. [Check this tag/release for custom-made server and clients where it was all mostly working, except for many edge cases](https://github.com/bewcloud/bewcloud/releases/tag/v0.0.1-self-made-carddav-caldav).
|
||||
|
||||
In order to share a calendar, you can either have a shared user, or you can symlink the calendar to the user's own calendar (simply `ln -s /<absolute-path-to-data-radicale>/collections/collection-root/<owner-user-id>/<calendar-to-share> /<absolute-path-to-data-radicale>/collections/collection-root/<user-id-to-share-with>/`).
|
||||
|
||||
> [!NOTE]
|
||||
> If you're running radicale with docker, the symlink needs to point to the container's directory, usually starting with `/data` if you didn't change the `radicale-config/config`, otherwise the container will fail to load the linked directory.
|
||||
|
||||
## How does private file sharing work?
|
||||
|
||||
Public file sharing is now possible since [v2.2.0](https://github.com/bewcloud/bewcloud/releases/tag/v2.2.0). [Check this PR for advanced sharing with internal and external users, with read and write access that was being done and almost working](https://github.com/bewcloud/bewcloud/pull/4). I ditched all that complexity for simply using [symlinks](https://en.wikipedia.org/wiki/Symbolic_link) for internal sharing, as it served my use case (I have multiple data backups and trust the people I provide accounts to, with the symlinks).
|
||||
|
||||
You can simply `ln -s /<absolute-path-to-data-files>/<owner-user-id>/<directory-to-share> /<absolute-path-to-data-files>/<user-id-to-share-with>/` to create a shared directory between two users, and the same directory can have different names, now.
|
||||
|
||||
> [!NOTE]
|
||||
> If you're running the app with docker, the symlink needs to point to the container's directory, usually starting with `/app` if you didn't change the `Dockerfile`, otherwise the container will fail to load the linked directory.
|
||||
[Check the FAQ](/FAQ.md) for answers to common questions, like private calendar and file sharing, or `.env`-based configuration.
|
||||
|
||||
## How does it look?
|
||||
|
||||
[Check the website](https://bewcloud.com) for screenshots or [the YouTube channel](https://www.youtube.com/@bewCloud) for 1-minute demos.
|
||||
|
||||
## Community Links
|
||||
|
||||
These are not officially endorsed, but are alternative ways of running bewCloud.
|
||||
|
||||
- [`bewcloud-nixos`](https://codeberg.org/ntninja/bewcloud-nixos/) by [@ntninja](https://codeberg.org/ntninja/) exposes bewCloud as an easy-to-use NixOS integration as an alternative to using Docker or running the app locally.
|
||||
- For installation, please see the [README](https://codeberg.org/ntninja/bewcloud-nixos/src/branch/main/README.md).
|
||||
- [`bewcloud-helm`](https://github.com/loboda4450/charts/tree/main/charts/bewcloud) by [@loboda4450](https://github.com/loboda4450/) exposes bewCloud as a Helm chart.
|
||||
- For installation, please see the [README](https://github.com/loboda4450/charts/blob/main/charts/bewcloud/README.md)
|
||||
- Supports automatic migrations, Radicale installation (as a dependency chart) and streamlined, fully yaml-based configuration.
|
||||
|
|
|
|||
56
babel.config.js
Normal file
56
babel.config.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Converts local TSX imports to JS imports, used in the components
|
||||
const rewriteLocalTsxImports = () => {
|
||||
const isLocal = (value) => typeof value === 'string' && (value.startsWith('./') || value.startsWith('../'));
|
||||
const isComponent = (value) => typeof value === 'string' && value.startsWith('/components/');
|
||||
|
||||
const rewrite = (source) => {
|
||||
if (source && isLocal(source.value) && source.value.endsWith('.tsx')) {
|
||||
source.value = source.value.replace('.tsx', '.js');
|
||||
}
|
||||
|
||||
if (source && isComponent(source.value) && source.value.endsWith('.tsx')) {
|
||||
source.value = source.value.replace('/components/', '/public/components/').replace('.tsx', '.js');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
visitor: {
|
||||
ImportDeclaration(path) {
|
||||
rewrite(path.node.source);
|
||||
},
|
||||
ExportAllDeclaration(path) {
|
||||
rewrite(path.node.source);
|
||||
},
|
||||
ExportNamedDeclaration(path) {
|
||||
if (path.node.source) rewrite(path.node.source);
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const presets = [
|
||||
['@babel/preset-react'],
|
||||
['@babel/preset-typescript', { jsxPragma: 'h' }],
|
||||
];
|
||||
|
||||
const plugins = [
|
||||
rewriteLocalTsxImports,
|
||||
[
|
||||
'@babel/plugin-transform-react-jsx',
|
||||
{
|
||||
'pragma': 'h',
|
||||
'pragmaFrag': 'Fragment',
|
||||
'jsxImportSource': 'preact',
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
export default {
|
||||
sourceType: 'module',
|
||||
targets: '> 0.5%, not dead',
|
||||
presets,
|
||||
plugins,
|
||||
comments: false,
|
||||
compact: false,
|
||||
minified: false,
|
||||
};
|
||||
|
|
@ -3,7 +3,7 @@ import { Config, PartialDeep } from './lib/types.ts';
|
|||
/** Check the Config type for all the possible options and instructions. */
|
||||
const config: PartialDeep<Config> = {
|
||||
auth: {
|
||||
baseUrl: 'http://localhost:8000', // The base URL of the application you use to access the app, i.e. "http://localhost:8000" or "https://cloud.example.com" (SSO redirect, if enabled, will be this + /oidc/callback, so "https://cloud.example.com/oidc/callback")
|
||||
baseUrl: 'http://localhost:8000', // The base URL of the application you use to access the app, i.e. "http://localhost:8000" or "https://cloud.example.com" (note authentication won't work without https:// except for localhost; SSO redirect, if enabled, will be this + /oidc/callback, so "https://cloud.example.com/oidc/callback")
|
||||
allowSignups: false, // If true, anyone can sign up for an account. Note that it's always possible to sign up for the first user, and they will be an admin
|
||||
enableEmailVerification: false, // If true, email verification will be required for signups (using SMTP settings below)
|
||||
enableForeverSignup: true, // If true, all signups become active for 100 years
|
||||
|
|
@ -11,6 +11,7 @@ const config: PartialDeep<Config> = {
|
|||
// allowedCookieDomains: ['example.com', 'example.net'], // Can be set to allow more than the baseUrl's domain for session cookies
|
||||
// skipCookieDomainSecurity: true, // If true, the cookie domain will not be strictly set and checked against. This skipping slightly reduces security, but is usually necessary for reverse proxies like Cloudflare Tunnel
|
||||
// enableSingleSignOn: false, // If true, single sign-on will be enabled
|
||||
// allowSignupsViaSingleSignOn: false, // If true, signups via single sign-on will be allowed, overriding allowSignups
|
||||
// singleSignOnUrl: '', // The Discovery URL (AKA Issuer) of the identity/single sign-on provider
|
||||
// singleSignOnEmailAttribute: 'email', // The attribute to prefer as email of the identity/single sign-on provider
|
||||
// singleSignOnScopes: ['openid', 'email'], // The scopes to request from the identity/single sign-on provider
|
||||
|
|
@ -18,9 +19,12 @@ const config: PartialDeep<Config> = {
|
|||
// files: {
|
||||
// rootPath: 'data-files',
|
||||
// allowPublicSharing: false, // If true, public file sharing will be allowed (still requires a user to enable sharing for a given file or directory)
|
||||
// allowDirectoryDownloads: false, // If true, directories can be downloaded as zip files
|
||||
// maxUploadSizeInMegabytes: 100, // The maximum upload size in megabytes. Overrides the core.maxRequestSizeInMegabytes setting on /dav and /api/files/upload endpoints.
|
||||
// },
|
||||
// core: {
|
||||
// enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts], // dashboard and files cannot be disabled
|
||||
// enabledApps: ['dashboard', 'files', 'news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'], // The apps to show, in order of appearance in the header. The first app will be the default one shown after logging in. At least one is required.
|
||||
// maxRequestSizeInMegabytes: 12, // The maximum request size in megabytes.
|
||||
// },
|
||||
// visuals: {
|
||||
// title: 'My own cloud',
|
||||
|
|
@ -31,14 +35,16 @@ const config: PartialDeep<Config> = {
|
|||
// from: 'help@bewcloud.com',
|
||||
// host: 'localhost',
|
||||
// port: 465,
|
||||
// tlsMode: 'auto', // "auto" means "immediate" on port 465, "starttls" otherwise.
|
||||
// tlsVerify: true, // Whether to verify the TLS certificate. If a string is used the hostname will be verified using that name.
|
||||
// },
|
||||
// contacts: {
|
||||
// enableCardDavServer: true,
|
||||
// cardDavUrl: 'http://127.0.0.1:5232',
|
||||
// cardDavUrl: 'http://radicale:5232',
|
||||
// },
|
||||
// calendar: {
|
||||
// enableCalDavServer: true,
|
||||
// calDavUrl: 'http://127.0.0.1:5232',
|
||||
// calDavUrl: 'http://radicale:5232',
|
||||
// },
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { Head } from 'fresh/runtime.ts';
|
||||
|
||||
import { OptionalApp, User } from '/lib/types.ts';
|
||||
import { capitalizeWord } from '/public/ts/utils/misc.ts';
|
||||
|
||||
interface Data {
|
||||
route: string;
|
||||
user?: User;
|
||||
user?: User | null;
|
||||
enabledApps: OptionalApp[];
|
||||
}
|
||||
|
||||
|
|
@ -23,50 +22,14 @@ export default function Header({ route, user, enabledApps }: Data) {
|
|||
|
||||
const iconWidthAndHeightInPixels = 20;
|
||||
|
||||
const potentialMenuItems: (MenuItem | null)[] = [
|
||||
{
|
||||
url: '/dashboard',
|
||||
label: 'Dashboard',
|
||||
},
|
||||
enabledApps.includes('news')
|
||||
? {
|
||||
url: '/news',
|
||||
label: 'News',
|
||||
}
|
||||
: null,
|
||||
{
|
||||
url: '/files',
|
||||
label: 'Files',
|
||||
},
|
||||
enabledApps.includes('notes')
|
||||
? {
|
||||
url: '/notes',
|
||||
label: 'Notes',
|
||||
}
|
||||
: null,
|
||||
enabledApps.includes('photos')
|
||||
? {
|
||||
url: '/photos',
|
||||
label: 'Photos',
|
||||
}
|
||||
: null,
|
||||
enabledApps.includes('expenses')
|
||||
? {
|
||||
url: '/expenses',
|
||||
label: 'Expenses',
|
||||
}
|
||||
: null,
|
||||
enabledApps.includes('contacts')
|
||||
? {
|
||||
url: '/contacts',
|
||||
label: 'Contacts',
|
||||
}
|
||||
: null,
|
||||
];
|
||||
const potentialMenuItems: (MenuItem | null)[] = enabledApps.map((app) => ({
|
||||
url: `/${app}`,
|
||||
label: capitalizeWord(app),
|
||||
}));
|
||||
|
||||
const menuItems = potentialMenuItems.filter(Boolean) as MenuItem[];
|
||||
|
||||
if (user) {
|
||||
if (user && !route.startsWith('/file-share')) {
|
||||
const activeMenu = menuItems.find((menu) => route.startsWith(menu.url));
|
||||
|
||||
let pageLabel = activeMenu?.label || '404 - Page not found';
|
||||
|
|
@ -87,18 +50,23 @@ export default function Header({ route, user, enabledApps }: Data) {
|
|||
pageLabel = 'Contacts';
|
||||
}
|
||||
|
||||
if (route.startsWith('/calendar')) {
|
||||
pageLabel = 'Calendar';
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{pageLabel} - bewCloud</title>
|
||||
</Head>
|
||||
<nav class='bg-slate-950'>
|
||||
<div class='mx-auto max-w-7xl px-4 sm:px-6 lg:px-8'>
|
||||
<div class='flex h-16 items-center justify-between'>
|
||||
<div class='flex items-center'>
|
||||
<div class='flex-shrink-0'>
|
||||
<div class='shrink-0'>
|
||||
<a href='/'>
|
||||
<img class='h-12 w-12 drop-shadow-md' src='/images/logomark.svg' alt='a stylized blue cloud' />
|
||||
<img
|
||||
class='h-12 w-12 drop-shadow-md'
|
||||
src='/public/images/logomark.svg'
|
||||
alt='a stylized blue cloud'
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<div class='hidden md:block'>
|
||||
|
|
@ -106,7 +74,7 @@ export default function Header({ route, user, enabledApps }: Data) {
|
|||
{menuItems.map((menu) => (
|
||||
<a href={menu.url} class={route.startsWith(menu.url) ? activeClass : defaultClass}>
|
||||
<img
|
||||
src={`/images${menu.url}${'.svg'}`}
|
||||
src={`/public/images${menu.url}${'.svg'}`}
|
||||
alt={menu.label}
|
||||
title={menu.label}
|
||||
width={iconWidthAndHeightInPixels}
|
||||
|
|
@ -127,7 +95,7 @@ export default function Header({ route, user, enabledApps }: Data) {
|
|||
class={route.startsWith('/settings') ? activeClass : defaultClass}
|
||||
>
|
||||
<img
|
||||
src='/images/settings.svg'
|
||||
src='/public/images/settings.svg'
|
||||
alt='Settings'
|
||||
title='Settings'
|
||||
width={iconWidthAndHeightInPixels}
|
||||
|
|
@ -140,7 +108,7 @@ export default function Header({ route, user, enabledApps }: Data) {
|
|||
class={defaultClass}
|
||||
>
|
||||
<img
|
||||
src='/images/logout.svg'
|
||||
src='/public/images/logout.svg'
|
||||
alt='Logout'
|
||||
title='Logout'
|
||||
width={iconWidthAndHeightInPixels}
|
||||
|
|
@ -176,11 +144,11 @@ export default function Header({ route, user, enabledApps }: Data) {
|
|||
}
|
||||
|
||||
return (
|
||||
<header class='px-4 pt-8 pb-2 max-w-screen-md mx-auto flex flex-col items-center justify-center'>
|
||||
<header class='px-4 pt-8 pb-2 max-w-3xl mx-auto flex flex-col items-center justify-center'>
|
||||
<a href='/'>
|
||||
<img
|
||||
class='mt-6 mb-2 drop-shadow-md'
|
||||
src='/images/logo-white.svg'
|
||||
src='/public/images/logo-white.svg'
|
||||
width='250'
|
||||
height='50'
|
||||
alt='the bewCloud logo: a stylized logo'
|
||||
|
|
|
|||
25
components/Loading.ts
Normal file
25
components/Loading.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { html } from '/public/ts/utils/misc.ts';
|
||||
|
||||
export default function Loading() {
|
||||
return html`
|
||||
<section role="status" id="loading" class="flex justify-center items-center">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
class="w-8 h-8 text-slate-700 animate-spin fill-sky-400"
|
||||
viewBox="0 0 100 101"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
|
||||
fill="currentFill"
|
||||
/>
|
||||
</svg>
|
||||
<span class="sr-only">Loading...</span>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
295
components/Settings.ts
Normal file
295
components/Settings.ts
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
import { FormField, generateFieldHtml, getFormDataField } from '/public/ts/utils/form.ts';
|
||||
import { convertObjectToFormData, currencyMap, escapeHtml, html } from '/public/ts/utils/misc.ts';
|
||||
import { SupportedCurrencySymbol, User } from '/lib/types.ts';
|
||||
import { getEnabledMultiFactorAuthMethodsFromUser } from '/public/ts/utils/multi-factor-auth.ts';
|
||||
import { getTimeZones } from '/public/ts/utils/calendar.ts';
|
||||
import Loading from '/components/Loading.ts';
|
||||
|
||||
interface SettingsProps {
|
||||
formData: Record<string, any>;
|
||||
error?: {
|
||||
title: string;
|
||||
message: string;
|
||||
};
|
||||
notice?: {
|
||||
title: string;
|
||||
message: string;
|
||||
};
|
||||
currency?: SupportedCurrencySymbol;
|
||||
timezoneId?: string;
|
||||
isExpensesAppEnabled: boolean;
|
||||
isMultiFactorAuthEnabled: boolean;
|
||||
isCalendarAppEnabled: boolean;
|
||||
helpEmail: string;
|
||||
user: {
|
||||
extra: Pick<User['extra'], 'multi_factor_auth_methods'>;
|
||||
};
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| 'change-email'
|
||||
| 'verify-change-email'
|
||||
| 'change-password'
|
||||
| 'change-dav-password'
|
||||
| 'delete-account'
|
||||
| 'change-currency'
|
||||
| 'change-timezone';
|
||||
|
||||
export const actionWords = new Map<Action, string>([
|
||||
['change-email', 'change email'],
|
||||
['verify-change-email', 'change email'],
|
||||
['change-password', 'change password'],
|
||||
['change-dav-password', 'change WebDav password'],
|
||||
['delete-account', 'delete account'],
|
||||
['change-currency', 'change currency'],
|
||||
['change-timezone', 'change timezone'],
|
||||
]);
|
||||
|
||||
function formFields(action: Action, formData: FormData, currency?: SupportedCurrencySymbol, timezoneId?: string) {
|
||||
const fields: FormField[] = [
|
||||
{
|
||||
name: 'action',
|
||||
label: '',
|
||||
type: 'hidden',
|
||||
value: action,
|
||||
overrideValue: action,
|
||||
required: true,
|
||||
readOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (action === 'change-email') {
|
||||
fields.push({
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
type: 'email',
|
||||
placeholder: 'jane.doe@example.com',
|
||||
value: getFormDataField(formData, 'email'),
|
||||
required: true,
|
||||
});
|
||||
} else if (action === 'verify-change-email') {
|
||||
fields.push({
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
type: 'email',
|
||||
placeholder: 'jane.doe@example.com',
|
||||
value: getFormDataField(formData, 'email'),
|
||||
required: true,
|
||||
}, {
|
||||
name: 'verification-code',
|
||||
label: 'Verification Code',
|
||||
description: `The verification code to validate your new email.`,
|
||||
type: 'text',
|
||||
placeholder: '000000',
|
||||
required: true,
|
||||
});
|
||||
} else if (action === 'change-password') {
|
||||
fields.push({
|
||||
name: 'current-password',
|
||||
label: 'Current Password',
|
||||
type: 'password',
|
||||
placeholder: 'super-SECRET-passphrase',
|
||||
required: true,
|
||||
}, {
|
||||
name: 'new-password',
|
||||
label: 'New Password',
|
||||
type: 'password',
|
||||
placeholder: 'super-SECRET-passphrase',
|
||||
required: true,
|
||||
});
|
||||
} else if (action === 'change-dav-password') {
|
||||
fields.push({
|
||||
name: 'new-dav-password',
|
||||
label: 'New WebDav Password',
|
||||
type: 'password',
|
||||
placeholder: 'super-SECRET-passphrase',
|
||||
required: true,
|
||||
description: 'Alternative password used for WebDav access and/or HTTP Basic Auth.',
|
||||
});
|
||||
} else if (action === 'delete-account') {
|
||||
fields.push({
|
||||
name: 'current-password',
|
||||
label: 'Password',
|
||||
type: 'password',
|
||||
placeholder: 'super-SECRET-passphrase',
|
||||
description: 'You need to input your password in order to delete your account.',
|
||||
required: true,
|
||||
});
|
||||
} else if (action === 'change-currency') {
|
||||
fields.push({
|
||||
name: 'currency',
|
||||
label: 'Currency',
|
||||
type: 'select',
|
||||
options: Array.from(currencyMap.keys()).map((currencySymbol) => ({
|
||||
value: currencySymbol,
|
||||
label: `${currencySymbol} (${currencyMap.get(currencySymbol)})`,
|
||||
})),
|
||||
value: getFormDataField(formData, 'currency') || currency,
|
||||
required: true,
|
||||
});
|
||||
} else if (action === 'change-timezone') {
|
||||
const timezones = getTimeZones();
|
||||
|
||||
fields.push({
|
||||
name: 'timezone',
|
||||
label: 'Timezone',
|
||||
type: 'select',
|
||||
options: timezones.map((timezone) => ({
|
||||
value: timezone.id,
|
||||
label: timezone.label,
|
||||
})),
|
||||
value: getFormDataField(formData, 'timezone') || timezoneId,
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
export default function Settings(
|
||||
{
|
||||
formData: formDataObject,
|
||||
error,
|
||||
notice,
|
||||
currency,
|
||||
timezoneId,
|
||||
isExpensesAppEnabled,
|
||||
isMultiFactorAuthEnabled,
|
||||
isCalendarAppEnabled,
|
||||
helpEmail,
|
||||
user,
|
||||
}: SettingsProps,
|
||||
): string {
|
||||
const formData = convertObjectToFormData(formDataObject);
|
||||
|
||||
const multiFactorAuthMethods = getEnabledMultiFactorAuthMethodsFromUser(user);
|
||||
|
||||
return html`
|
||||
<section class="mx-auto max-w-7xl my-8">
|
||||
${error
|
||||
? html`
|
||||
<section class="notification-error">
|
||||
<h3>${escapeHtml(error.title)}</h3>
|
||||
<p>${escapeHtml(error.message)}</p>
|
||||
</section>
|
||||
`
|
||||
: ''} ${notice
|
||||
? html`
|
||||
<section class="notification-success">
|
||||
<h3>${escapeHtml(notice.title)}</h3>
|
||||
<p>${escapeHtml(notice.message)}</p>
|
||||
</section>
|
||||
`
|
||||
: ''}
|
||||
|
||||
<h2 class="text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96">Change your email</h2>
|
||||
|
||||
<form method="POST" class="mb-12">
|
||||
${formFields('change-email', formData).map((field) => generateFieldHtml(field, formData)).join('')}
|
||||
<section class="flex justify-end mt-8 mb-4">
|
||||
<button class="button-secondary" type="submit">Change email</button>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<h2 class="text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96">Change your password</h2>
|
||||
|
||||
<form method="POST" class="mb-12">
|
||||
${formFields('change-password', formData).map((field) => generateFieldHtml(field, formData)).join('')}
|
||||
<section class="flex justify-end mt-8 mb-4">
|
||||
<button class="button-secondary" type="submit">Change password</button>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<h2 class="text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96">Change your WebDav password</h2>
|
||||
|
||||
<form method="POST" class="mb-12">
|
||||
${formFields('change-dav-password', formData).map((field) => generateFieldHtml(field, formData)).join('')}
|
||||
<section class="flex justify-end mt-8 mb-4">
|
||||
<button class="button-secondary" type="submit">Change WebDav password</button>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
${isExpensesAppEnabled
|
||||
? html`
|
||||
<h2 class="text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96">Change your currency</h2>
|
||||
<p class="text-left mt-2 mb-6 px-4 max-w-3xl mx-auto lg:min-w-96">
|
||||
This is only used in the expenses app, visually. It changes nothing about the stored data or values.
|
||||
</p>
|
||||
|
||||
<form method="POST" class="mb-12">
|
||||
${formFields('change-currency', formData, currency, timezoneId).map((field) =>
|
||||
generateFieldHtml(field, formData)
|
||||
).join('')}
|
||||
<section class="flex justify-end mt-8 mb-4">
|
||||
<button class="button-secondary" type="submit">Change currency</button>
|
||||
</section>
|
||||
</form>
|
||||
`
|
||||
: ''} ${isCalendarAppEnabled
|
||||
? html`
|
||||
<h2 class="text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96">Change your timezone</h2>
|
||||
<p class="text-left mt-2 mb-6 px-4 max-w-3xl mx-auto lg:min-w-96">
|
||||
This is only used in the calendar app.
|
||||
</p>
|
||||
|
||||
<form method="POST" class="mb-12">
|
||||
${formFields('change-timezone', formData, currency, timezoneId).map((field) =>
|
||||
generateFieldHtml(field, formData)
|
||||
).join('')}
|
||||
<section class="flex justify-end mt-8 mb-4">
|
||||
<button class="button-secondary" type="submit">Change timezone</button>
|
||||
</section>
|
||||
</form>
|
||||
`
|
||||
: ''} ${isMultiFactorAuthEnabled
|
||||
? html`
|
||||
<section id="multi-factor-auth-settings">
|
||||
${Loading()}
|
||||
</section>
|
||||
`
|
||||
: ''}
|
||||
|
||||
<h2 class="text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96">Delete your account</h2>
|
||||
<p class="text-left mt-2 mb-6 px-4 max-w-3xl mx-auto lg:min-w-96">
|
||||
Deleting your account is instant and deletes all your data. ${helpEmail !== ''
|
||||
? html`
|
||||
If you need help, please <a href="${`mailto:${helpEmail}`}">reach out</a>.
|
||||
`
|
||||
: ''}
|
||||
</p>
|
||||
|
||||
<form method="POST" class="mb-12">
|
||||
${formFields('delete-account', formData).map((field) => generateFieldHtml(field, formData)).join('')}
|
||||
<section class="flex justify-end mt-8 mb-4">
|
||||
<button class="button-danger" type="submit">Delete account</button>
|
||||
</section>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<script type="module">
|
||||
import { h, render } from 'preact';
|
||||
|
||||
// Imported files need some preact globals to work
|
||||
window.h = h;
|
||||
|
||||
import MultiFactorAuthSettings from '/public/components/auth/MultiFactorAuthSettings.js';
|
||||
|
||||
const multiFactorAuthSettingsElement = document.getElementById('multi-factor-auth-settings');
|
||||
|
||||
if (multiFactorAuthSettingsElement) {
|
||||
const multiFactorAuthSettingsApp = h(MultiFactorAuthSettings, {
|
||||
methods: ${JSON.stringify(multiFactorAuthMethods.map((method) => ({
|
||||
type: method.type,
|
||||
id: method.id,
|
||||
name: method.name,
|
||||
enabled: method.enabled,
|
||||
backupCodesCount: method.metadata.totp?.hashed_backup_codes?.length,
|
||||
})))},
|
||||
});
|
||||
|
||||
render(multiFactorAuthSettingsApp, multiFactorAuthSettingsElement);
|
||||
|
||||
document.getElementById('loading')?.remove();
|
||||
}
|
||||
</script>
|
||||
`;
|
||||
}
|
||||
|
|
@ -2,30 +2,21 @@ import { useSignal } from '@preact/signals';
|
|||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
|
||||
import { MultiFactorAuthMethodType } from '/lib/types.ts';
|
||||
import {
|
||||
RequestBody as PasskeySetupBeginRequestBody,
|
||||
ResponseBody as PasskeySetupBeginResponseBody,
|
||||
} from '/routes/api/auth/multi-factor/passkey/setup-begin.ts';
|
||||
import { ResponseBody as PasskeySetupBeginResponseBody } from '/pages/api/auth/multi-factor/passkey/setup-begin.ts';
|
||||
import {
|
||||
RequestBody as PasskeySetupCompleteRequestBody,
|
||||
ResponseBody as PasskeySetupCompleteResponseBody,
|
||||
} from '/routes/api/auth/multi-factor/passkey/setup-complete.ts';
|
||||
import {
|
||||
RequestBody as TOTPSetupRequestBody,
|
||||
ResponseBody as TOTPSetupResponseBody,
|
||||
} from '/routes/api/auth/multi-factor/totp/setup.ts';
|
||||
import {
|
||||
RequestBody as EmailSetupRequestBody,
|
||||
ResponseBody as EmailSetupResponseBody,
|
||||
} from '/routes/api/auth/multi-factor/email/setup.ts';
|
||||
} from '/pages/api/auth/multi-factor/passkey/setup-complete.ts';
|
||||
import { ResponseBody as TOTPSetupResponseBody } from '/pages/api/auth/multi-factor/totp/setup.ts';
|
||||
import { ResponseBody as EmailSetupResponseBody } from '/pages/api/auth/multi-factor/email/setup.ts';
|
||||
import {
|
||||
RequestBody as MultiFactorAuthEnableRequestBody,
|
||||
ResponseBody as MultiFactorAuthEnableResponseBody,
|
||||
} from '/routes/api/auth/multi-factor/enable.ts';
|
||||
} from '/pages/api/auth/multi-factor/enable.ts';
|
||||
import {
|
||||
RequestBody as MultiFactorAuthDisableRequestBody,
|
||||
ResponseBody as MultiFactorAuthDisableResponseBody,
|
||||
} from '/routes/api/auth/multi-factor/disable.ts';
|
||||
} from '/pages/api/auth/multi-factor/disable.ts';
|
||||
|
||||
interface MultiFactorAuthMethod {
|
||||
type: MultiFactorAuthMethodType;
|
||||
|
|
@ -84,11 +75,9 @@ export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSett
|
|||
const hasMultiFactorAuthEnabled = enabledMethods.length > 0;
|
||||
|
||||
const setupPasskey = async () => {
|
||||
const beginRequestBody: PasskeySetupBeginRequestBody = {};
|
||||
|
||||
const beginResponse = await fetch('/api/auth/multi-factor/passkey/setup-begin', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(beginRequestBody),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!beginResponse.ok) {
|
||||
|
|
@ -135,11 +124,9 @@ export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSett
|
|||
};
|
||||
|
||||
const setupTOTP = async () => {
|
||||
const requestBody: TOTPSetupRequestBody = {};
|
||||
|
||||
const response = await fetch('/api/auth/multi-factor/totp/setup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -164,11 +151,9 @@ export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSett
|
|||
};
|
||||
|
||||
const setupEmail = async () => {
|
||||
const requestBody: EmailSetupRequestBody = {};
|
||||
|
||||
const response = await fetch('/api/auth/multi-factor/email/setup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -321,11 +306,11 @@ export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSett
|
|||
|
||||
return (
|
||||
<section class='mb-16'>
|
||||
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>
|
||||
<h2 class='text-2xl mb-4 text-left px-4 max-w-3xl mx-auto lg:min-w-96'>
|
||||
Multi-Factor Authentication (MFA)
|
||||
</h2>
|
||||
|
||||
<section class='px-4 max-w-screen-md mx-auto lg:min-w-96'>
|
||||
<section class='px-4 max-w-3xl mx-auto lg:min-w-96'>
|
||||
{error.value
|
||||
? (
|
||||
<section class='notification-error mb-4'>
|
||||
|
|
@ -391,8 +376,8 @@ export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSett
|
|||
<p class='mb-4'>
|
||||
1. Scan this QR code with your authenticator app (Aegis Authenticator, Google Authenticator, etc.):
|
||||
</p>
|
||||
<section class='flex justify-center mb-4'>
|
||||
<img src={setupData.value.qrCodeUrl} alt='TOTP QR Code' class='border' />
|
||||
<section class='flex justify-center mb-4 max-w-sm mx-auto'>
|
||||
<img src={setupData.value.qrCodeUrl} alt='TOTP QR Code' class='border-8 border-white' />
|
||||
</section>
|
||||
<p class='text-sm text-gray-400 mb-4'>
|
||||
Or manually enter this secret:{' '}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { MultiFactorAuthMethodType } from '/lib/types.ts';
|
||||
import PasswordlessPasskeyLogin from '/islands/auth/PasswordlessPasskeyLogin.tsx';
|
||||
import PasswordlessPasskeyLogin from '/components/auth/PasswordlessPasskeyLogin.tsx';
|
||||
|
||||
interface MultiFactorAuthVerifyFormProps {
|
||||
email: string;
|
||||
|
|
@ -16,7 +16,7 @@ export default function MultiFactorAuthVerifyForm(
|
|||
const hasEmail = availableMethods.includes('email');
|
||||
|
||||
return (
|
||||
<section class='max-w-md w-full mb-12'>
|
||||
<section class='max-w-md w-full mb-12 mx-auto'>
|
||||
<section class='mb-6'>
|
||||
<h2 class='mt-6 text-center text-3xl font-extrabold text-white'>
|
||||
Multi-Factor Authentication
|
||||
|
|
|
|||
29
components/auth/PasswordlessPasskeyLogin.tsx
Normal file
29
components/auth/PasswordlessPasskeyLogin.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
export interface PasswordlessPasskeyLoginProps {
|
||||
email?: string;
|
||||
redirectUrl?: string;
|
||||
}
|
||||
|
||||
export default function PasswordlessPasskeyLogin({ email, redirectUrl }: PasswordlessPasskeyLoginProps) {
|
||||
return (
|
||||
<>
|
||||
<section class='space-y-4'>
|
||||
<section class='flex justify-center mt-2 mb-4'>
|
||||
<button
|
||||
id='passwordless-passkey-login-button'
|
||||
type='button'
|
||||
class='button-secondary'
|
||||
data-email={email}
|
||||
data-redirect-url={redirectUrl}
|
||||
>
|
||||
Login with Passkey
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class='notification-error hidden' id='passwordless-passkey-login-error'></section>
|
||||
</section>
|
||||
|
||||
<script src='/public/js/simplewebauthn.js'></script>
|
||||
<script type='module' src='/public/ts/passwordless-passkey-login.ts'></script>
|
||||
</>
|
||||
);
|
||||
}
|
||||
163
components/calendar/AddEventModal.tsx
Normal file
163
components/calendar/AddEventModal.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||
|
||||
interface AddEventModalProps {
|
||||
isOpen: boolean;
|
||||
initialStartDate?: Date;
|
||||
initiallyAllDay?: boolean;
|
||||
calendars: Calendar[];
|
||||
onClickSave: (newEvent: CalendarEvent) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AddEventModal(
|
||||
{ isOpen, initialStartDate, initiallyAllDay, calendars, onClickSave, onClose }: AddEventModalProps,
|
||||
) {
|
||||
const newEvent = useSignal<CalendarEvent | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
newEvent.value = null;
|
||||
} else {
|
||||
const startDate = new Date(initialStartDate || new Date());
|
||||
|
||||
startDate.setUTCMinutes(0);
|
||||
startDate.setUTCSeconds(0);
|
||||
startDate.setUTCMilliseconds(0);
|
||||
|
||||
const endDate = new Date(startDate);
|
||||
endDate.setUTCHours(startDate.getUTCHours() + 1);
|
||||
|
||||
if (initiallyAllDay) {
|
||||
startDate.setUTCHours(9);
|
||||
endDate.setUTCHours(18);
|
||||
}
|
||||
|
||||
newEvent.value = {
|
||||
uid: 'new',
|
||||
url: '',
|
||||
title: '',
|
||||
calendarId: calendars[0]!.uid!,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
isAllDay: initiallyAllDay || false,
|
||||
organizerEmail: '',
|
||||
transparency: 'opaque',
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class={`fixed ${
|
||||
newEvent.value ? 'block' : 'hidden'
|
||||
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 min-w-96 max-w-lg bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg overflow-y-scroll max-h-[80%]`}
|
||||
>
|
||||
<h1 class='text-2xl font-semibold my-5'>New Event</h1>
|
||||
<section class='py-5 my-2 border-y border-slate-500'>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='event_title'>Title</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='text'
|
||||
name='event_title'
|
||||
id='event_title'
|
||||
value={newEvent.value?.title || ''}
|
||||
onInput={(event) => newEvent.value = { ...newEvent.value!, title: event.currentTarget.value }}
|
||||
placeholder='Dentist'
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='event_calendar'>Calendar</label>
|
||||
<section class='flex items-center justify-between'>
|
||||
<select
|
||||
class='input-field mr-2 w-5/6!'
|
||||
name='event_calendar'
|
||||
id='event_calendar'
|
||||
value={newEvent.value?.calendarId || ''}
|
||||
onChange={(event) => newEvent.value = { ...newEvent.value!, calendarId: event.currentTarget.value }}
|
||||
>
|
||||
{calendars.map((calendar) => (
|
||||
<option key={calendar.uid} value={calendar.uid}>{calendar.displayName}</option>
|
||||
))}
|
||||
</select>
|
||||
<span
|
||||
class={`w-5 h-5 block rounded-full`}
|
||||
style={{
|
||||
backgroundColor: calendars.find((calendar) => calendar.uid === newEvent.value?.calendarId)
|
||||
?.calendarColor,
|
||||
}}
|
||||
title={calendars.find((calendar) => calendar.uid === newEvent.value?.calendarId)?.calendarColor}
|
||||
>
|
||||
</span>
|
||||
</section>
|
||||
</fieldset>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='event_start_date'>Start date</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='datetime-local'
|
||||
name='event_start_date'
|
||||
id='event_start_date'
|
||||
value={newEvent.value?.startDate ? new Date(newEvent.value.startDate).toISOString().substring(0, 16) : ''}
|
||||
onInput={(event) =>
|
||||
newEvent.value = { ...newEvent.value!, startDate: new Date(event.currentTarget.value) }}
|
||||
/>
|
||||
<aside class='text-sm text-slate-400 p-2 '>
|
||||
Dates are set in the default calendar timezone, controlled by Radicale.
|
||||
</aside>
|
||||
</fieldset>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='event_end_date'>End date</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='datetime-local'
|
||||
name='event_end_date'
|
||||
id='event_end_date'
|
||||
value={newEvent.value?.endDate ? new Date(newEvent.value.endDate).toISOString().substring(0, 16) : ''}
|
||||
onInput={(event) => newEvent.value = { ...newEvent.value!, endDate: new Date(event.currentTarget.value) }}
|
||||
/>
|
||||
<aside class='text-sm text-slate-400 p-2 '>
|
||||
Dates are set in the default calendar timezone, controlled by Radicale.
|
||||
</aside>
|
||||
</fieldset>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='event_is_all_day'>All-day?</label>
|
||||
<input
|
||||
type='checkbox'
|
||||
name='event_is_all_day'
|
||||
id='event_is_all_day'
|
||||
value='true'
|
||||
checked={newEvent.value?.isAllDay}
|
||||
onChange={(event) => newEvent.value = { ...newEvent.value!, isAllDay: event.currentTarget.checked }}
|
||||
/>
|
||||
</fieldset>
|
||||
</section>
|
||||
<footer class='flex justify-between'>
|
||||
<button
|
||||
type='button'
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickSave(newEvent.value!)}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
229
components/calendar/CalendarViewDay.tsx
Normal file
229
components/calendar/CalendarViewDay.tsx
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||
import { getCalendarEventStyle } from '/public/ts/utils/calendar.ts';
|
||||
|
||||
interface CalendarViewDayProps {
|
||||
startDate: Date;
|
||||
visibleCalendars: Calendar[];
|
||||
calendarEvents: CalendarEvent[];
|
||||
onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void;
|
||||
onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
|
||||
timezoneId: string;
|
||||
}
|
||||
|
||||
export default function CalendarViewDay(
|
||||
{ startDate, visibleCalendars, calendarEvents, onClickAddEvent, onClickOpenEvent, timezoneId }: CalendarViewDayProps,
|
||||
) {
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
|
||||
const hourFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: timezoneId, // Calendar dates are parsed are stored without timezone info, so we need to force to a specific one so it's consistent across db, server, and client
|
||||
});
|
||||
const dayFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: timezoneId, // Calendar dates are parsed without timezone info, so we need to force to a specific one so it's consistent across db, server, and client
|
||||
});
|
||||
|
||||
const allDayEvents: CalendarEvent[] = calendarEvents.filter((calendarEvent) => {
|
||||
if (!calendarEvent.isAllDay) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startDayDate = new Date(startDate);
|
||||
const endDayDate = new Date(startDate);
|
||||
endDayDate.setUTCHours(23);
|
||||
endDayDate.setUTCMinutes(59);
|
||||
endDayDate.setUTCSeconds(59);
|
||||
endDayDate.setUTCMilliseconds(999);
|
||||
|
||||
const eventStartDate = new Date(calendarEvent.startDate);
|
||||
const eventEndDate = new Date(calendarEvent.endDate);
|
||||
|
||||
// Event starts and ends on this day
|
||||
if (eventStartDate >= startDayDate && eventEndDate <= endDayDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends after this day
|
||||
if (eventStartDate <= startDayDate && eventEndDate >= endDayDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts on and ends after this day
|
||||
if (
|
||||
eventStartDate >= startDayDate && eventStartDate <= endDayDate && eventEndDate >= endDayDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends on this day
|
||||
if (
|
||||
eventStartDate <= startDayDate && eventEndDate >= startDayDate && eventEndDate <= endDayDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const hours: { date: Date; isCurrentHour: boolean }[] = Array.from({ length: 24 }).map((_, index) => {
|
||||
const hourNumber = index;
|
||||
|
||||
const date = new Date(startDate);
|
||||
date.setUTCHours(hourNumber);
|
||||
|
||||
const shortIsoDate = date.toISOString().substring(0, 10);
|
||||
|
||||
const isCurrentHour = shortIsoDate === today && new Date().getUTCHours() === hourNumber;
|
||||
|
||||
return {
|
||||
date,
|
||||
isCurrentHour,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<section class='shadow-md flex flex-auto flex-col rounded-md'>
|
||||
<section class='border-b border-slate-500 bg-slate-700 text-center text-base font-semibold text-white flex-none rounded-t-md'>
|
||||
<div class='flex justify-center bg-gray-900 py-2 rounded-t-md'>
|
||||
<span>{dayFormat.format(startDate)}</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class='flex bg-slate-500 text-sm text-white flex-auto rounded-b-md'>
|
||||
<section class='w-full rounded-b-md'>
|
||||
{allDayEvents.length > 0
|
||||
? (
|
||||
<section
|
||||
class={`relative bg-slate-700 min-h-16 px-3 py-2 text-slate-100 border-b border-b-slate-600`}
|
||||
>
|
||||
<time
|
||||
datetime={new Date(startDate).toISOString().substring(0, 10)}
|
||||
onClick={() => onClickAddEvent(new Date(startDate), true)}
|
||||
class='cursor-pointer'
|
||||
title='Add a new all-day event'
|
||||
>
|
||||
All-day
|
||||
</time>
|
||||
<ol class='mt-2'>
|
||||
{allDayEvents.map((calendarEvent) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href='javascript:void(0);'
|
||||
class={`flex px-2 py-2 rounded-md hover:no-underline hover:opacity-60`}
|
||||
style={getCalendarEventStyle(calendarEvent, visibleCalendars)}
|
||||
onClick={() => onClickOpenEvent(calendarEvent)}
|
||||
>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{calendarEvent.title}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{hours.map((hour, hourIndex) => {
|
||||
const shortIsoDate = hour.date.toISOString().substring(0, 10);
|
||||
|
||||
const startHourDate = new Date(shortIsoDate);
|
||||
startHourDate.setUTCHours(hour.date.getUTCHours());
|
||||
const endHourDate = new Date(shortIsoDate);
|
||||
endHourDate.setUTCHours(hour.date.getUTCHours());
|
||||
endHourDate.setUTCMinutes(59);
|
||||
endHourDate.setUTCSeconds(59);
|
||||
endHourDate.setUTCMilliseconds(999);
|
||||
|
||||
const isLastHour = hourIndex === 23;
|
||||
|
||||
const hourEvents = calendarEvents.filter((calendarEvent) => {
|
||||
if (calendarEvent.isAllDay) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const eventStartDate = new Date(calendarEvent.startDate);
|
||||
const eventEndDate = new Date(calendarEvent.endDate);
|
||||
eventEndDate.setUTCSeconds(eventEndDate.getUTCSeconds() - 1); // Take one second back so events don't bleed into the next hour
|
||||
|
||||
// Event starts and ends on this hour
|
||||
if (eventStartDate >= startHourDate && eventEndDate <= endHourDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends after this hour
|
||||
if (eventStartDate <= startHourDate && eventEndDate >= endHourDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts on and ends after this hour
|
||||
if (
|
||||
eventStartDate >= startHourDate && eventStartDate <= endHourDate && eventEndDate >= endHourDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends on this hour
|
||||
if (
|
||||
eventStartDate <= startHourDate && eventEndDate >= startHourDate && eventEndDate <= endHourDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
class={`relative ${hour.isCurrentHour ? 'bg-slate-600' : 'bg-slate-700'} ${
|
||||
hourIndex <= 6 ? 'min-h-8' : 'min-h-16'
|
||||
} px-3 py-2 ${hour.isCurrentHour ? '' : 'text-slate-100'} ${
|
||||
isLastHour ? 'rounded-b-md' : ''
|
||||
} border-b border-b-slate-600`}
|
||||
>
|
||||
<time
|
||||
datetime={startHourDate.toISOString()}
|
||||
onClick={() => onClickAddEvent(startHourDate)}
|
||||
class='cursor-pointer'
|
||||
title='Add a new event'
|
||||
>
|
||||
{hourFormat.format(startHourDate)}
|
||||
</time>
|
||||
{hourEvents.length > 0
|
||||
? (
|
||||
<ol class='mt-2'>
|
||||
{hourEvents.map((hourEvent) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href='javascript:void(0);'
|
||||
class={`flex px-2 py-2 rounded-md hover:no-underline hover:opacity-60`}
|
||||
style={getCalendarEventStyle(hourEvent, visibleCalendars)}
|
||||
onClick={() => onClickOpenEvent(hourEvent)}
|
||||
>
|
||||
<time
|
||||
datetime={new Date(hourEvent.startDate).toISOString()}
|
||||
class='mr-2 flex-none text-slate-100 block'
|
||||
>
|
||||
{hourFormat.format(new Date(hourEvent.startDate))}
|
||||
</time>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{hourEvent.title}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
: null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
166
components/calendar/CalendarViewMonth.tsx
Normal file
166
components/calendar/CalendarViewMonth.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||
import { getCalendarEventStyle, getWeeksForMonth } from '/public/ts/utils/calendar.ts';
|
||||
|
||||
interface CalendarViewWeekProps {
|
||||
startDate: Date;
|
||||
visibleCalendars: Calendar[];
|
||||
calendarEvents: CalendarEvent[];
|
||||
onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void;
|
||||
onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
|
||||
timezoneId: string;
|
||||
}
|
||||
|
||||
export default function CalendarViewWeek(
|
||||
{ startDate, visibleCalendars, calendarEvents, onClickAddEvent, onClickOpenEvent, timezoneId }: CalendarViewWeekProps,
|
||||
) {
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
|
||||
const hourFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: timezoneId, // Calendar dates are parsed without timezone info, so we need to force to a specific one so it's consistent across db, server, and client
|
||||
});
|
||||
|
||||
const weeks = getWeeksForMonth(new Date(startDate));
|
||||
|
||||
return (
|
||||
<section class='shadow-md flex flex-auto flex-col rounded-md'>
|
||||
<section class='grid grid-cols-7 gap-px border-b border-slate-500 bg-slate-700 text-center text-xs font-semibold text-white flex-none rounded-t-md'>
|
||||
<div class='flex justify-center bg-gray-900 py-2 rounded-tl-md'>
|
||||
<span>Mon</span>
|
||||
</div>
|
||||
<div class='flex justify-center bg-gray-900 py-2'>
|
||||
<span>Tue</span>
|
||||
</div>
|
||||
<div class='flex justify-center bg-gray-900 py-2'>
|
||||
<span>Wed</span>
|
||||
</div>
|
||||
<div class='flex justify-center bg-gray-900 py-2'>
|
||||
<span>Thu</span>
|
||||
</div>
|
||||
<div class='flex justify-center bg-gray-900 py-2'>
|
||||
<span>Fri</span>
|
||||
</div>
|
||||
<div class='flex justify-center bg-gray-900 py-2'>
|
||||
<span>Sat</span>
|
||||
</div>
|
||||
<div class='flex justify-center bg-gray-900 py-2 rounded-tr-md'>
|
||||
<span>Sun</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class='flex bg-slate-500 text-xs text-white flex-auto rounded-b-md'>
|
||||
<section class='w-full grid grid-cols-7 grid-rows-5 gap-px rounded-b-md'>
|
||||
{weeks.map((week, weekIndex) =>
|
||||
week.map((day, dayIndex) => {
|
||||
const shortIsoDate = day.date.toISOString().substring(0, 10);
|
||||
|
||||
const startDayDate = new Date(shortIsoDate);
|
||||
const endDayDate = new Date(shortIsoDate);
|
||||
endDayDate.setUTCHours(23);
|
||||
endDayDate.setUTCMinutes(59);
|
||||
endDayDate.setUTCSeconds(59);
|
||||
endDayDate.setUTCMilliseconds(999);
|
||||
|
||||
const isBottomLeftDay = weekIndex === weeks.length - 1 && dayIndex === 0;
|
||||
const isBottomRightDay = weekIndex === weeks.length - 1 && dayIndex === week.length - 1;
|
||||
|
||||
const isToday = today === shortIsoDate;
|
||||
|
||||
const dayEvents = calendarEvents.filter((calendarEvent) => {
|
||||
const eventStartDate = new Date(calendarEvent.startDate);
|
||||
const eventEndDate = new Date(calendarEvent.endDate);
|
||||
|
||||
// Event starts and ends on this day
|
||||
if (eventStartDate >= startDayDate && eventEndDate <= endDayDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends after this day
|
||||
if (eventStartDate <= startDayDate && eventEndDate >= endDayDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts on and ends after this day
|
||||
if (
|
||||
eventStartDate >= startDayDate && eventStartDate <= endDayDate && eventEndDate >= endDayDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends on this day
|
||||
if (
|
||||
eventStartDate <= startDayDate && eventEndDate >= startDayDate && eventEndDate <= endDayDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
class={`relative ${day.isSameMonth ? 'bg-slate-600' : 'bg-slate-700'} min-h-16 px-3 py-2 ${
|
||||
day.isSameMonth ? '' : 'text-slate-100'
|
||||
} ${isBottomLeftDay ? 'rounded-bl-md' : ''} ${isBottomRightDay ? 'rounded-br-md' : ''}`}
|
||||
>
|
||||
<time
|
||||
datetime={shortIsoDate}
|
||||
class={`cursor-pointer ${
|
||||
isToday ? 'flex h-6 w-6 items-center justify-center rounded-full bg-[#51A4FB] font-semibold' : ''
|
||||
}`}
|
||||
onClick={() => onClickAddEvent(new Date(`${shortIsoDate}T09:00`))}
|
||||
title='Add a new event'
|
||||
>
|
||||
{day.date.getUTCDate()}
|
||||
</time>
|
||||
{dayEvents.length > 0
|
||||
? (
|
||||
<ol class='mt-2'>
|
||||
{[...dayEvents].slice(0, 2).map((dayEvent) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href='javascript:void(0);'
|
||||
class={`flex px-2 py-1 rounded-md hover:no-underline hover:opacity-60`}
|
||||
style={getCalendarEventStyle(dayEvent, visibleCalendars)}
|
||||
onClick={() => onClickOpenEvent(dayEvent)}
|
||||
>
|
||||
<time
|
||||
datetime={new Date(dayEvent.startDate).toISOString()}
|
||||
class='mr-2 flex-none text-slate-100 block'
|
||||
>
|
||||
{hourFormat.format(new Date(dayEvent.startDate))}
|
||||
</time>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{dayEvent.title}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
{dayEvents.length > 2
|
||||
? (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href={`/calendar/view=day&startDate=${shortIsoDate}`}
|
||||
class='flex bg-gray-700 px-2 py-1 rounded-md hover:no-underline hover:opacity-60'
|
||||
target='_blank'
|
||||
>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
...{dayEvents.length - 2} more event{dayEvents.length - 2 === 1 ? '' : 's'}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
)
|
||||
: null}
|
||||
</ol>
|
||||
)
|
||||
: null}
|
||||
</section>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
225
components/calendar/CalendarViewWeek.tsx
Normal file
225
components/calendar/CalendarViewWeek.tsx
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||
import { getCalendarEventStyle, getDaysForWeek } from '/public/ts/utils/calendar.ts';
|
||||
|
||||
interface CalendarViewWeekProps {
|
||||
startDate: Date;
|
||||
visibleCalendars: Calendar[];
|
||||
calendarEvents: CalendarEvent[];
|
||||
onClickAddEvent: (startDate?: Date, isAllDay?: boolean) => void;
|
||||
onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
|
||||
timezoneId: string;
|
||||
}
|
||||
|
||||
export default function CalendarViewWeek(
|
||||
{ startDate, visibleCalendars, calendarEvents, onClickAddEvent, onClickOpenEvent, timezoneId }: CalendarViewWeekProps,
|
||||
) {
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
|
||||
const hourFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: timezoneId, // Calendar dates are parsed without timezone info, so we need to force to a specific one so it's consistent across db, server, and client
|
||||
});
|
||||
const weekDayFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: '2-digit',
|
||||
timeZone: timezoneId, // Calendar dates are parsed without timezone info, so we need to force to a specific one so it's consistent across db, server, and client
|
||||
});
|
||||
|
||||
const days = getDaysForWeek(new Date(startDate));
|
||||
|
||||
return (
|
||||
<section class='shadow-md flex flex-auto flex-col rounded-md'>
|
||||
<section class='w-full grid gap-px grid-flow-col rounded-md text-white text-xs bg-slate-600 calendar-week-view-days'>
|
||||
{days.map((day, dayIndex) => {
|
||||
const allDayEvents: CalendarEvent[] = calendarEvents.filter((calendarEvent) => {
|
||||
if (!calendarEvent.isAllDay) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startDayDate = new Date(day.date);
|
||||
const endDayDate = new Date(day.date);
|
||||
endDayDate.setUTCHours(23);
|
||||
endDayDate.setUTCMinutes(59);
|
||||
endDayDate.setUTCSeconds(59);
|
||||
endDayDate.setUTCMilliseconds(999);
|
||||
|
||||
const eventStartDate = new Date(calendarEvent.startDate);
|
||||
const eventEndDate = new Date(calendarEvent.endDate);
|
||||
|
||||
// Event starts and ends on this day
|
||||
if (eventStartDate >= startDayDate && eventEndDate <= endDayDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends after this day
|
||||
if (eventStartDate <= startDayDate && eventEndDate >= endDayDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts on and ends after this day
|
||||
if (
|
||||
eventStartDate >= startDayDate && eventStartDate <= endDayDate && eventEndDate >= endDayDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends on this day
|
||||
if (
|
||||
eventStartDate <= startDayDate && eventEndDate >= startDayDate && eventEndDate <= endDayDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const isFirstDay = dayIndex === 0;
|
||||
const isLastDay = dayIndex === 6;
|
||||
const isToday = new Date(day.date).toISOString().substring(0, 10) === today;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
class={`flex justify-center ${isToday ? 'bg-[#51A4FB]' : 'bg-gray-900'} py-2 ${
|
||||
isFirstDay ? 'rounded-tl-md' : ''
|
||||
} ${isLastDay ? 'rounded-tr-md' : ''} text-center text-xs font-semibold text-white`}
|
||||
>
|
||||
<span>{weekDayFormat.format(day.date)}</span>
|
||||
</section>
|
||||
<section
|
||||
class={`relative bg-slate-700 min-h-8 px-3 py-2 text-slate-100`}
|
||||
>
|
||||
<time
|
||||
datetime={new Date(startDate).toISOString().substring(0, 10)}
|
||||
onClick={() => onClickAddEvent(new Date(startDate), true)}
|
||||
class='cursor-pointer'
|
||||
title='Add a new all-day event'
|
||||
>
|
||||
All-day
|
||||
</time>
|
||||
{allDayEvents.length > 0
|
||||
? (
|
||||
<ol class='mt-2'>
|
||||
{allDayEvents.map((calendarEvent) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href='javascript:void(0);'
|
||||
class={`flex px-2 py-2 rounded-md hover:no-underline hover:opacity-60`}
|
||||
style={getCalendarEventStyle(calendarEvent, visibleCalendars)}
|
||||
onClick={() => onClickOpenEvent(calendarEvent)}
|
||||
>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{calendarEvent.title}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
: null}
|
||||
</section>
|
||||
{day.hours.map((hour, hourIndex) => {
|
||||
const shortIsoDate = hour.date.toISOString().substring(0, 10);
|
||||
|
||||
const startHourDate = new Date(shortIsoDate);
|
||||
startHourDate.setUTCHours(hour.date.getUTCHours());
|
||||
const endHourDate = new Date(shortIsoDate);
|
||||
endHourDate.setUTCHours(hour.date.getUTCHours());
|
||||
endHourDate.setUTCMinutes(59);
|
||||
endHourDate.setUTCSeconds(59);
|
||||
endHourDate.setUTCMilliseconds(999);
|
||||
|
||||
const isLastHourOfFirstDay = hourIndex === 23 && dayIndex === 0;
|
||||
const isLastHourOfLastDay = hourIndex === 23 && dayIndex === 6;
|
||||
|
||||
const hourEvents = calendarEvents.filter((calendarEvent) => {
|
||||
if (calendarEvent.isAllDay) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const eventStartDate = new Date(calendarEvent.startDate);
|
||||
const eventEndDate = new Date(calendarEvent.endDate);
|
||||
eventEndDate.setUTCSeconds(eventEndDate.getUTCSeconds() - 1); // Take one second back so events don't bleed into the next hour
|
||||
|
||||
// Event starts and ends on this hour
|
||||
if (eventStartDate >= startHourDate && eventEndDate <= endHourDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends after this hour
|
||||
if (eventStartDate <= startHourDate && eventEndDate >= endHourDate) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts on and ends after this hour
|
||||
if (
|
||||
eventStartDate >= startHourDate && eventStartDate <= endHourDate &&
|
||||
eventEndDate >= endHourDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Event starts before and ends on this hour
|
||||
if (
|
||||
eventStartDate <= startHourDate && eventEndDate >= startHourDate &&
|
||||
eventEndDate <= endHourDate
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return (
|
||||
<section
|
||||
class={`relative ${hour.isCurrentHour ? 'bg-slate-600' : 'bg-slate-700'} px-3 py-2 ${
|
||||
hour.isCurrentHour ? '' : 'text-slate-100'
|
||||
} ${isLastHourOfFirstDay ? 'rounded-bl-md' : ''} ${isLastHourOfLastDay ? 'rounded-br-md' : ''}`}
|
||||
>
|
||||
<time
|
||||
datetime={startHourDate.toISOString()}
|
||||
onClick={() => onClickAddEvent(startHourDate)}
|
||||
class='cursor-pointer'
|
||||
title='Add a new event'
|
||||
>
|
||||
{hourFormat.format(startHourDate)}
|
||||
</time>
|
||||
{hourEvents.length > 0
|
||||
? (
|
||||
<ol class='mt-2'>
|
||||
{hourEvents.map((hourEvent) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href='javascript:void(0);'
|
||||
class={`flex px-2 py-2 rounded-md hover:no-underline hover:opacity-60`}
|
||||
style={getCalendarEventStyle(hourEvent, visibleCalendars)}
|
||||
onClick={() => onClickOpenEvent(hourEvent)}
|
||||
>
|
||||
<time
|
||||
datetime={new Date(hourEvent.startDate).toISOString()}
|
||||
class='mr-2 flex-none text-slate-100 block'
|
||||
>
|
||||
{hourFormat.format(new Date(hourEvent.startDate))}
|
||||
</time>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{hourEvent.title}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
: null}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
329
components/calendar/Calendars.tsx
Normal file
329
components/calendar/Calendars.tsx
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Calendar } from '/lib/models/calendar.ts';
|
||||
import { CALENDAR_COLOR_OPTIONS, getColorAsHex } from '/public/ts/utils/calendar.ts';
|
||||
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/pages/api/calendar/add.ts';
|
||||
import { RequestBody as UpdateRequestBody, ResponseBody as UpdateResponseBody } from '/pages/api/calendar/update.ts';
|
||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/pages/api/calendar/delete.ts';
|
||||
|
||||
interface CalendarsProps {
|
||||
initialCalendars: Calendar[];
|
||||
}
|
||||
|
||||
export default function Calendars({ initialCalendars }: CalendarsProps) {
|
||||
const isAdding = useSignal<boolean>(false);
|
||||
const isDeleting = useSignal<boolean>(false);
|
||||
const isSaving = useSignal<boolean>(false);
|
||||
const calendars = useSignal<Calendar[]>(initialCalendars);
|
||||
const openCalendar = useSignal<Calendar | null>(null);
|
||||
|
||||
async function onClickAddCalendar() {
|
||||
if (isAdding.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = (prompt(`What's the **name** for the new calendar?`) || '').trim();
|
||||
|
||||
if (!name) {
|
||||
alert('A name is required for a new calendar!');
|
||||
return;
|
||||
}
|
||||
|
||||
isAdding.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: AddRequestBody = { name };
|
||||
const response = await fetch(`/api/calendar/add`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to add calendar! ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as AddResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to add calendar!');
|
||||
}
|
||||
|
||||
calendars.value = [...result.newCalendars];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isAdding.value = false;
|
||||
}
|
||||
|
||||
async function onClickDeleteCalendar(calendarId: string) {
|
||||
if (confirm('Are you sure you want to delete this calendar and all its events?')) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteRequestBody = { calendarId };
|
||||
const response = await fetch(`/api/calendar/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete calendar! ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as DeleteResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete calendar!');
|
||||
}
|
||||
|
||||
calendars.value = [...result.newCalendars];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onClickSaveOpenCalendar() {
|
||||
if (isSaving.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!openCalendar.value?.uid) {
|
||||
alert('A calendar is required to update one!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!openCalendar.value?.displayName) {
|
||||
alert('A name is required to update the calendar!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!openCalendar.value?.calendarColor) {
|
||||
alert('A color is required to update the calendar!');
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: UpdateRequestBody = {
|
||||
id: openCalendar.value.uid!,
|
||||
name: openCalendar.value.displayName!,
|
||||
color: openCalendar.value.calendarColor!,
|
||||
isVisible: openCalendar.value.isVisible!,
|
||||
};
|
||||
const response = await fetch(`/api/calendar/update`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as UpdateResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to update calendar!');
|
||||
}
|
||||
|
||||
calendars.value = [...result.newCalendars];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isSaving.value = false;
|
||||
openCalendar.value = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section class='flex flex-row items-center justify-between mb-4'>
|
||||
<a href='/calendar' class='mr-2'>View calendar</a>
|
||||
<section class='flex items-center'>
|
||||
<button
|
||||
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2'
|
||||
type='button'
|
||||
title='Add new calendar'
|
||||
onClick={() => onClickAddCalendar()}
|
||||
>
|
||||
<img
|
||||
src='/public/images/add.svg'
|
||||
alt='Add new calendar'
|
||||
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class='mx-auto max-w-7xl my-8'>
|
||||
<table class='w-full border-collapse bg-gray-900 text-left text-sm text-white shadow-sm rounded-md'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope='col' class='px-6 py-4 font-medium'>Name</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium'>Color</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium'>Visible?</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium w-20'></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class='divide-y divide-slate-600 border-t border-slate-600'>
|
||||
{calendars.value.map((calendar) => (
|
||||
<tr class='bg-slate-700 hover:bg-slate-600 group'>
|
||||
<td class='flex gap-3 px-6 py-4 font-medium'>
|
||||
{calendar.displayName}
|
||||
</td>
|
||||
<td class='px-6 py-4 text-slate-200'>
|
||||
<span
|
||||
class={`w-5 h-5 inline-block rounded-full cursor-pointer`}
|
||||
title={calendar.calendarColor}
|
||||
style={{ backgroundColor: calendar.calendarColor }}
|
||||
onClick={() => openCalendar.value = { ...calendar }}
|
||||
>
|
||||
</span>
|
||||
</td>
|
||||
<td class='px-6 py-4'>
|
||||
{calendar.isVisible ? 'Yes' : 'No'}
|
||||
</td>
|
||||
<td class='px-6 py-4'>
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100'
|
||||
onClick={() => onClickDeleteCalendar(calendar.uid!)}
|
||||
>
|
||||
<img
|
||||
src='/public/images/delete.svg'
|
||||
class='red drop-shadow-md'
|
||||
width={24}
|
||||
height={24}
|
||||
alt='Delete calendar'
|
||||
title='Delete calendar'
|
||||
/>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{calendars.value.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td class='flex gap-3 px-6 py-4 font-normal' colspan={4}>
|
||||
<div class='text-md'>
|
||||
<div class='font-medium text-slate-400'>No calendars to show</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
: null}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<span
|
||||
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
|
||||
>
|
||||
{isDeleting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isSaving.value
|
||||
? (
|
||||
<>
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{!isDeleting.value && !isSaving.value ? <> </> : null}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class={`fixed ${openCalendar.value ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class={`fixed ${
|
||||
openCalendar.value ? 'block' : 'hidden'
|
||||
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg`}
|
||||
>
|
||||
<h1 class='text-2xl font-semibold my-5'>Edit Calendar</h1>
|
||||
<section class='py-5 my-2 border-y border-slate-500'>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='calendar_name'>Name</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='text'
|
||||
name='calendar_name'
|
||||
id='calendar_name'
|
||||
value={openCalendar.value?.displayName || ''}
|
||||
onInput={(event) =>
|
||||
openCalendar.value = { ...openCalendar.value!, displayName: event.currentTarget.value }}
|
||||
placeholder='Personal'
|
||||
/>
|
||||
</fieldset>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='calendar_color'>Color</label>
|
||||
<section class='flex items-center justify-between'>
|
||||
<select
|
||||
class='input-field mr-2 w-5/6!'
|
||||
name='calendar_color'
|
||||
id='calendar_color'
|
||||
value={openCalendar.value?.calendarColor || ''}
|
||||
onChange={(event) =>
|
||||
openCalendar.value = { ...openCalendar.value!, calendarColor: event.currentTarget.value }}
|
||||
>
|
||||
{CALENDAR_COLOR_OPTIONS.map((color) => (
|
||||
<option
|
||||
key={color}
|
||||
value={getColorAsHex(color)}
|
||||
selected={openCalendar.value?.calendarColor === getColorAsHex(color)}
|
||||
>
|
||||
{color}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span
|
||||
class={`w-5 h-5 block rounded-full`}
|
||||
style={{ backgroundColor: openCalendar.value?.calendarColor }}
|
||||
title={openCalendar.value?.calendarColor}
|
||||
>
|
||||
</span>
|
||||
</section>
|
||||
</fieldset>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='calendar_is_visible'>Visible?</label>
|
||||
<input
|
||||
type='checkbox'
|
||||
name='calendar_is_visible'
|
||||
id='calendar_is_visible'
|
||||
value='true'
|
||||
checked={openCalendar.value?.isVisible}
|
||||
onChange={(event) =>
|
||||
openCalendar.value = { ...openCalendar.value!, isVisible: event.currentTarget.checked }}
|
||||
/>
|
||||
</fieldset>
|
||||
</section>
|
||||
<footer class='flex justify-between'>
|
||||
<button
|
||||
type='button'
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickSaveOpenCalendar()}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => openCalendar.value = null}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
86
components/calendar/ImportEventsModal.tsx
Normal file
86
components/calendar/ImportEventsModal.tsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { Calendar } from '/lib/models/calendar.ts';
|
||||
|
||||
interface ImportEventsModalProps {
|
||||
isOpen: boolean;
|
||||
calendars: Calendar[];
|
||||
onClickImport: (calendarId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ImportEventsModal(
|
||||
{ isOpen, calendars, onClickImport, onClose }: ImportEventsModalProps,
|
||||
) {
|
||||
const newCalendarId = useSignal<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
newCalendarId.value = null;
|
||||
} else {
|
||||
newCalendarId.value = calendars[0]!.uid!;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class={`fixed ${
|
||||
newCalendarId.value ? 'block' : 'hidden'
|
||||
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg overflow-y-scroll max-h-[80%]`}
|
||||
>
|
||||
<h1 class='text-2xl font-semibold my-5'>Import Events</h1>
|
||||
<section class='py-5 my-2 border-y border-slate-500'>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='event_calendar'>Calendar</label>
|
||||
<section class='flex items-center justify-between'>
|
||||
<select
|
||||
class='input-field mr-2 w-5/6!'
|
||||
name='event_calendar'
|
||||
id='event_calendar'
|
||||
value={newCalendarId.value || ''}
|
||||
onChange={(event) => {
|
||||
newCalendarId.value = event.currentTarget.value;
|
||||
}}
|
||||
>
|
||||
{calendars.map((calendar) => (
|
||||
<option key={calendar.uid} value={calendar.uid}>{calendar.displayName}</option>
|
||||
))}
|
||||
</select>
|
||||
<span
|
||||
class={`w-5 h-5 block rounded-full`}
|
||||
style={{
|
||||
backgroundColor: calendars.find((calendar) => calendar.uid === newCalendarId.value)?.calendarColor,
|
||||
}}
|
||||
title={calendars.find((calendar) => calendar.uid === newCalendarId.value)?.calendarColor}
|
||||
>
|
||||
</span>
|
||||
</section>
|
||||
</fieldset>
|
||||
</section>
|
||||
<footer class='flex justify-between'>
|
||||
<button
|
||||
type='button'
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickImport(newCalendarId.value!)}
|
||||
>
|
||||
Choose File
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
654
components/calendar/MainCalendar.tsx
Normal file
654
components/calendar/MainCalendar.tsx
Normal file
|
|
@ -0,0 +1,654 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||
import { capitalizeWord } from '/public/ts/utils/misc.ts';
|
||||
import { generateVCalendar } from '/public/ts/utils/calendar.ts';
|
||||
import {
|
||||
RequestBody as ExportRequestBody,
|
||||
ResponseBody as ExportResponseBody,
|
||||
} from '/pages/api/calendar/export-events.ts';
|
||||
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/pages/api/calendar/add-event.ts';
|
||||
import {
|
||||
RequestBody as DeleteRequestBody,
|
||||
ResponseBody as DeleteResponseBody,
|
||||
} from '/pages/api/calendar/delete-event.ts';
|
||||
import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/pages/api/calendar/import.ts';
|
||||
import CalendarViewDay from './CalendarViewDay.tsx';
|
||||
import CalendarViewWeek from './CalendarViewWeek.tsx';
|
||||
import CalendarViewMonth from './CalendarViewMonth.tsx';
|
||||
import AddEventModal from './AddEventModal.tsx';
|
||||
import ViewEventModal from './ViewEventModal.tsx';
|
||||
import SearchEvents from './SearchEvents.tsx';
|
||||
import ImportEventsModal from './ImportEventsModal.tsx';
|
||||
|
||||
interface MainCalendarProps {
|
||||
initialCalendars: Calendar[];
|
||||
initialCalendarEvents: CalendarEvent[];
|
||||
view: 'day' | 'week' | 'month';
|
||||
startDate: string;
|
||||
baseUrl: string;
|
||||
timezoneId: string;
|
||||
timezoneUtcOffset: number;
|
||||
}
|
||||
|
||||
export default function MainCalendar(
|
||||
{ initialCalendars, initialCalendarEvents, view, startDate, baseUrl, timezoneId }: MainCalendarProps,
|
||||
) {
|
||||
const isAdding = useSignal<boolean>(false);
|
||||
const isDeleting = useSignal<boolean>(false);
|
||||
const isExporting = useSignal<boolean>(false);
|
||||
const isImporting = useSignal<boolean>(false);
|
||||
const calendars = useSignal<Calendar[]>(initialCalendars);
|
||||
const isViewOptionsDropdownOpen = useSignal<boolean>(false);
|
||||
const isImportExportOptionsDropdownOpen = useSignal<boolean>(false);
|
||||
const calendarEvents = useSignal<CalendarEvent[]>(initialCalendarEvents);
|
||||
const openEventModal = useSignal<
|
||||
{ isOpen: boolean; calendar?: typeof initialCalendars[number]; calendarEvent?: CalendarEvent }
|
||||
>({ isOpen: false });
|
||||
const newEventModal = useSignal<{ isOpen: boolean; initialStartDate?: Date; initiallyAllDay?: boolean }>({
|
||||
isOpen: false,
|
||||
});
|
||||
const openImportModal = useSignal<
|
||||
{ isOpen: boolean }
|
||||
>({ isOpen: false });
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
timeZone: timezoneId, // Calendar dates are parsed without timezone info, so we need to force to a specific one so it's consistent across db, server, and client
|
||||
});
|
||||
const today = new Date().toISOString().substring(0, 10);
|
||||
|
||||
const visibleCalendars = calendars.value.filter((calendar) => calendar.isVisible);
|
||||
|
||||
function onClickAddEvent(startDate = new Date(), isAllDay = false) {
|
||||
if (newEventModal.value.isOpen) {
|
||||
newEventModal.value = {
|
||||
isOpen: false,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (calendars.value.length === 0) {
|
||||
alert('You need to create a calendar first!');
|
||||
return;
|
||||
}
|
||||
|
||||
newEventModal.value = {
|
||||
isOpen: true,
|
||||
initialStartDate: startDate,
|
||||
initiallyAllDay: isAllDay,
|
||||
};
|
||||
}
|
||||
|
||||
async function onClickSaveNewEvent(newEvent: CalendarEvent) {
|
||||
if (isAdding.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
isAdding.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: AddRequestBody = {
|
||||
calendarIds: visibleCalendars.map((calendar) => calendar.uid!),
|
||||
calendarView: view,
|
||||
calendarStartDate: startDate,
|
||||
calendarId: newEvent.calendarId,
|
||||
title: newEvent.title,
|
||||
startDate: new Date(newEvent.startDate).toISOString(),
|
||||
endDate: new Date(newEvent.endDate).toISOString(),
|
||||
isAllDay: newEvent.isAllDay,
|
||||
};
|
||||
const response = await fetch(`/api/calendar/add-event`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as AddResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to add event!');
|
||||
}
|
||||
|
||||
calendarEvents.value = [...result.newCalendarEvents];
|
||||
|
||||
newEventModal.value = {
|
||||
isOpen: false,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isAdding.value = false;
|
||||
}
|
||||
|
||||
function onCloseNewEvent() {
|
||||
newEventModal.value = {
|
||||
isOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
function toggleImportExportOptionsDropdown() {
|
||||
isImportExportOptionsDropdownOpen.value = !isImportExportOptionsDropdownOpen.value;
|
||||
}
|
||||
|
||||
function toggleViewOptionsDropdown() {
|
||||
isViewOptionsDropdownOpen.value = !isViewOptionsDropdownOpen.value;
|
||||
}
|
||||
|
||||
function onClickOpenEvent(calendarEvent: CalendarEvent) {
|
||||
if (openEventModal.value.isOpen) {
|
||||
openEventModal.value = {
|
||||
isOpen: false,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const calendar = calendars.value.find((calendar) => calendar.uid === calendarEvent.calendarId)!;
|
||||
|
||||
openEventModal.value = {
|
||||
isOpen: true,
|
||||
calendar,
|
||||
calendarEvent,
|
||||
};
|
||||
}
|
||||
|
||||
async function onClickDeleteEvent(calendarEventId: string) {
|
||||
if (confirm('Are you sure you want to delete this event?')) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteRequestBody = {
|
||||
calendarIds: visibleCalendars.map((calendar) => calendar.uid!),
|
||||
calendarView: view,
|
||||
calendarStartDate: startDate,
|
||||
calendarEventId,
|
||||
calendarId: calendarEvents.value.find((calendarEvent) => calendarEvent.uid === calendarEventId)!.calendarId,
|
||||
};
|
||||
const response = await fetch(`/api/calendar/delete-event`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as DeleteResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete event!');
|
||||
}
|
||||
|
||||
calendarEvents.value = [...result.newCalendarEvents];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.value = false;
|
||||
|
||||
openEventModal.value = { isOpen: false };
|
||||
}
|
||||
}
|
||||
|
||||
function onCloseOpenEvent() {
|
||||
openEventModal.value = {
|
||||
isOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
function onClickChangeStartDate(changeTo: 'previous' | 'next' | 'today') {
|
||||
const previousDay = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() - 1)).toISOString()
|
||||
.substring(0, 10);
|
||||
const nextDay = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() + 1)).toISOString()
|
||||
.substring(0, 10);
|
||||
const previousWeek = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() - 7)).toISOString()
|
||||
.substring(0, 10);
|
||||
const nextWeek = new Date(new Date(startDate).setUTCDate(new Date(startDate).getUTCDate() + 7)).toISOString()
|
||||
.substring(0, 10);
|
||||
const previousMonth = new Date(new Date(startDate).setUTCMonth(new Date(startDate).getUTCMonth() - 1)).toISOString()
|
||||
.substring(0, 10);
|
||||
const nextMonth = new Date(new Date(startDate).setUTCMonth(new Date(startDate).getUTCMonth() + 1)).toISOString()
|
||||
.substring(0, 10);
|
||||
|
||||
if (changeTo === 'today') {
|
||||
if (today === startDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/calendar?view=${view}&startDate=${today}`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (changeTo === 'previous') {
|
||||
let newStartDate = previousMonth;
|
||||
|
||||
if (view === 'day') {
|
||||
newStartDate = previousDay;
|
||||
} else if (view === 'week') {
|
||||
newStartDate = previousWeek;
|
||||
}
|
||||
|
||||
if (newStartDate === startDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/calendar?view=${view}&startDate=${newStartDate}`;
|
||||
return;
|
||||
}
|
||||
|
||||
let newStartDate = nextMonth;
|
||||
|
||||
if (view === 'day') {
|
||||
newStartDate = nextDay;
|
||||
} else if (view === 'week') {
|
||||
newStartDate = nextWeek;
|
||||
}
|
||||
|
||||
if (newStartDate === startDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/calendar?view=${view}&startDate=${newStartDate}`;
|
||||
}
|
||||
|
||||
function onClickChangeView(newView: MainCalendarProps['view']) {
|
||||
if (view === newView) {
|
||||
isViewOptionsDropdownOpen.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/calendar?view=${newView}&startDate=${startDate}`;
|
||||
}
|
||||
|
||||
function onClickImportICS() {
|
||||
openImportModal.value = { isOpen: true };
|
||||
isImportExportOptionsDropdownOpen.value = false;
|
||||
}
|
||||
|
||||
function onClickChooseImportCalendar(calendarId: string) {
|
||||
isImportExportOptionsDropdownOpen.value = false;
|
||||
|
||||
if (isImporting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.click();
|
||||
|
||||
fileInput.onchange = (event) => {
|
||||
const files = (event.target as HTMLInputElement)?.files!;
|
||||
const file = files[0];
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (fileRead) => {
|
||||
const importFileContents = fileRead.target?.result;
|
||||
|
||||
if (!importFileContents || isImporting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isImporting.value = true;
|
||||
|
||||
openImportModal.value = { isOpen: false };
|
||||
|
||||
try {
|
||||
const icsToImport = importFileContents!.toString();
|
||||
|
||||
const requestBody: ImportRequestBody = {
|
||||
icsToImport,
|
||||
calendarIds: visibleCalendars.map((calendar) => calendar.uid!),
|
||||
calendarView: view,
|
||||
calendarStartDate: startDate,
|
||||
calendarId,
|
||||
};
|
||||
|
||||
const response = await fetch(`/api/calendar/import`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as ImportResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to import file!');
|
||||
}
|
||||
|
||||
calendarEvents.value = [...result.newCalendarEvents];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isImporting.value = false;
|
||||
};
|
||||
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
};
|
||||
}
|
||||
|
||||
async function onClickExportICS() {
|
||||
isImportExportOptionsDropdownOpen.value = false;
|
||||
|
||||
if (isExporting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isExporting.value = true;
|
||||
|
||||
const fileName = ['calendar-', new Date().toISOString().substring(0, 19).replace(/:/g, '-'), '.ics']
|
||||
.join('');
|
||||
|
||||
try {
|
||||
const requestBody: ExportRequestBody = { calendarIds: visibleCalendars.map((calendar) => calendar.uid!) };
|
||||
const response = await fetch(`/api/calendar/export-events`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as ExportResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to get contact!');
|
||||
}
|
||||
|
||||
const exportContents = generateVCalendar([...result.calendarEvents]);
|
||||
|
||||
// Add content-type
|
||||
const vCardContent = ['data:text/calendar; charset=utf-8,', encodeURIComponent(exportContents)].join('');
|
||||
|
||||
// Download the file
|
||||
const data = vCardContent;
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', data);
|
||||
link.setAttribute('download', fileName);
|
||||
link.click();
|
||||
link.remove();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isExporting.value = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section class='flex flex-row items-center justify-between mb-4'>
|
||||
<section class='relative inline-block text-left mr-2'>
|
||||
<section class='flex flex-row items-center justify-start'>
|
||||
<a href='/calendars' class='mr-4 whitespace-nowrap'>Manage calendars</a>
|
||||
<SearchEvents calendars={visibleCalendars} onClickOpenEvent={onClickOpenEvent} />
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class='flex items-center justify-end'>
|
||||
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'>
|
||||
<time datetime={startDate}>{dateFormat.format(new Date(startDate))}</time>
|
||||
</h3>
|
||||
<section class='ml-2 relative flex items-center rounded-md bg-slate-700 shadow-sm md:items-stretch'>
|
||||
<button
|
||||
type='button'
|
||||
class='flex h-9 w-12 items-center justify-center rounded-l-md text-white hover:bg-slate-600 focus:relative'
|
||||
onClick={() => onClickChangeStartDate('previous')}
|
||||
>
|
||||
<span class='sr-only'>Previous {view}</span>
|
||||
<svg class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||
<path
|
||||
fill-rule='evenodd'
|
||||
d='M12.79 5.23a.75.75 0 01-.02 1.06L8.832 10l3.938 3.71a.75.75 0 11-1.04 1.08l-4.5-4.25a.75.75 0 010-1.08l4.5-4.25a.75.75 0 011.06.02z'
|
||||
clip-rule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
class='px-3.5 text-sm font-semibold text-white hover:bg-slate-600 focus:relative'
|
||||
onClick={() => onClickChangeStartDate('today')}
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
class='flex h-9 w-12 items-center justify-center rounded-r-md text-white hover:bg-slate-600 pl-1 focus:relative'
|
||||
onClick={() => onClickChangeStartDate('next')}
|
||||
>
|
||||
<span class='sr-only'>Next {view}</span>
|
||||
<svg class='h-5 w-5' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||
<path
|
||||
fill-rule='evenodd'
|
||||
d='M7.21 14.77a.75.75 0 01.02-1.06L11.168 10 7.23 6.29a.75.75 0 111.04-1.08l4.5 4.25a.75.75 0 010 1.08l-4.5 4.25a.75.75 0 01-1.06-.02z'
|
||||
clip-rule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</section>
|
||||
<section class='relative inline-block text-left ml-2'>
|
||||
<div>
|
||||
<button
|
||||
type='button'
|
||||
class='inline-flex w-full justify-center gap-x-1.5 rounded-md bg-slate-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-600'
|
||||
id='view-button'
|
||||
aria-expanded='true'
|
||||
aria-haspopup='true'
|
||||
onClick={() => toggleViewOptionsDropdown()}
|
||||
>
|
||||
{capitalizeWord(view)}
|
||||
<svg class='-mr-1 h-5 w-5 text-white' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||
<path
|
||||
fill-rule='evenodd'
|
||||
d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z'
|
||||
clip-rule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
||||
!isViewOptionsDropdownOpen.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='view-button'
|
||||
tabindex={-1}
|
||||
>
|
||||
<div class='py-1'>
|
||||
<button
|
||||
type='button'
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600 ${
|
||||
view === 'day' ? 'font-semibold' : ''
|
||||
}`}
|
||||
onClick={() => onClickChangeView('day')}
|
||||
>
|
||||
Day
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600 ${
|
||||
view === 'week' ? 'font-semibold' : ''
|
||||
}`}
|
||||
onClick={() => onClickChangeView('week')}
|
||||
>
|
||||
Week
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600 ${
|
||||
view === 'month' ? 'font-semibold' : ''
|
||||
}`}
|
||||
onClick={() => onClickChangeView('month')}
|
||||
>
|
||||
Month
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class='relative inline-block text-left ml-2'>
|
||||
<div>
|
||||
<button
|
||||
type='button'
|
||||
class='inline-flex w-full justify-center gap-x-1.5 rounded-md bg-slate-700 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-slate-600'
|
||||
id='import-export-button'
|
||||
aria-expanded='true'
|
||||
aria-haspopup='true'
|
||||
onClick={() => toggleImportExportOptionsDropdown()}
|
||||
>
|
||||
ICS
|
||||
<svg class='-mr-1 h-5 w-5 text-slate-400' viewBox='0 0 20 20' fill='currentColor' aria-hidden='true'>
|
||||
<path
|
||||
fill-rule='evenodd'
|
||||
d='M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z'
|
||||
clip-rule='evenodd'
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
||||
!isImportExportOptionsDropdownOpen.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='import-export-button'
|
||||
tabindex={-1}
|
||||
>
|
||||
<div class='py-1'>
|
||||
<button
|
||||
type='button'
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickImportICS()}
|
||||
>
|
||||
Import ICS
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickExportICS()}
|
||||
>
|
||||
Export ICS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<button
|
||||
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2'
|
||||
type='button'
|
||||
title='Add new event'
|
||||
onClick={() => onClickAddEvent()}
|
||||
>
|
||||
<img
|
||||
src='/public/images/add.svg'
|
||||
alt='Add new event'
|
||||
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class='mx-auto max-w-7xl my-8'>
|
||||
{view === 'day'
|
||||
? (
|
||||
<CalendarViewDay
|
||||
startDate={new Date(startDate)}
|
||||
visibleCalendars={visibleCalendars}
|
||||
calendarEvents={calendarEvents.value}
|
||||
onClickAddEvent={onClickAddEvent}
|
||||
onClickOpenEvent={onClickOpenEvent}
|
||||
timezoneId={timezoneId}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
{view === 'week'
|
||||
? (
|
||||
<CalendarViewWeek
|
||||
startDate={new Date(startDate)}
|
||||
visibleCalendars={visibleCalendars}
|
||||
calendarEvents={calendarEvents.value}
|
||||
onClickAddEvent={onClickAddEvent}
|
||||
onClickOpenEvent={onClickOpenEvent}
|
||||
timezoneId={timezoneId}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
{view === 'month'
|
||||
? (
|
||||
<CalendarViewMonth
|
||||
startDate={new Date(startDate)}
|
||||
visibleCalendars={visibleCalendars}
|
||||
calendarEvents={calendarEvents.value}
|
||||
onClickAddEvent={onClickAddEvent}
|
||||
onClickOpenEvent={onClickOpenEvent}
|
||||
timezoneId={timezoneId}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
<span
|
||||
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
|
||||
>
|
||||
{isDeleting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isExporting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isImporting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{!isDeleting.value && !isExporting.value && !isImporting.value ? <> </> : null}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<section class='flex flex-row items-center justify-start my-12'>
|
||||
<span class='font-semibold'>CalDav URL:</span>{' '}
|
||||
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/caldav</code>
|
||||
</section>
|
||||
|
||||
<AddEventModal
|
||||
isOpen={newEventModal.value.isOpen}
|
||||
initialStartDate={newEventModal.value.initialStartDate}
|
||||
initiallyAllDay={newEventModal.value.initiallyAllDay}
|
||||
calendars={calendars.value}
|
||||
onClickSave={onClickSaveNewEvent}
|
||||
onClose={onCloseNewEvent}
|
||||
/>
|
||||
|
||||
<ViewEventModal
|
||||
isOpen={openEventModal.value.isOpen}
|
||||
calendar={openEventModal.value.calendar!}
|
||||
calendarEvent={openEventModal.value.calendarEvent!}
|
||||
onClickDelete={onClickDeleteEvent}
|
||||
onClose={onCloseOpenEvent}
|
||||
timezoneId={timezoneId}
|
||||
/>
|
||||
|
||||
<ImportEventsModal
|
||||
isOpen={openImportModal.value.isOpen}
|
||||
calendars={calendars.value}
|
||||
onClickImport={onClickChooseImportCalendar}
|
||||
onClose={() => {
|
||||
openImportModal.value = { isOpen: false };
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
153
components/calendar/SearchEvents.tsx
Normal file
153
components/calendar/SearchEvents.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||
import { RequestBody, ResponseBody } from '/pages/api/calendar/search-events.ts';
|
||||
import { getColorAsHex } from '/public/ts/utils/calendar.ts';
|
||||
|
||||
interface SearchEventsProps {
|
||||
calendars: Calendar[];
|
||||
onClickOpenEvent: (calendarEvent: CalendarEvent) => void;
|
||||
}
|
||||
|
||||
export default function SearchEvents({ calendars, onClickOpenEvent }: SearchEventsProps) {
|
||||
const isSearching = useSignal<boolean>(false);
|
||||
const areResultsVisible = useSignal<boolean>(false);
|
||||
const calendarEvents = useSignal<CalendarEvent[]>([]);
|
||||
const searchTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
||||
const closeTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZone: 'UTC', // Calendar dates are parsed without timezone info, so we need to force to UTC so it's consistent across db, server, and client
|
||||
});
|
||||
|
||||
const calendarIds = calendars.map((calendar) => calendar.uid!);
|
||||
|
||||
function searchEvents(searchTerm: string) {
|
||||
if (searchTimeout.value) {
|
||||
clearTimeout(searchTimeout.value);
|
||||
}
|
||||
|
||||
if (searchTerm.trim().length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
areResultsVisible.value = false;
|
||||
|
||||
searchTimeout.value = setTimeout(async () => {
|
||||
isSearching.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: RequestBody = { calendarIds, searchTerm };
|
||||
const response = await fetch(`/api/calendar/search-events`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as ResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to search events!');
|
||||
}
|
||||
|
||||
calendarEvents.value = result.matchingCalendarEvents;
|
||||
|
||||
if (calendarEvents.value.length > 0) {
|
||||
areResultsVisible.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isSearching.value = false;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
if (calendarEvents.value.length > 0) {
|
||||
areResultsVisible.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
if (closeTimeout.value) {
|
||||
clearTimeout(closeTimeout.value);
|
||||
}
|
||||
|
||||
closeTimeout.value = setTimeout(() => {
|
||||
areResultsVisible.value = false;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeout.value) {
|
||||
clearTimeout(searchTimeout.value);
|
||||
}
|
||||
|
||||
if (closeTimeout.value) {
|
||||
clearTimeout(closeTimeout.value);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
class='input-field w-72 mr-2'
|
||||
type='search'
|
||||
name='search'
|
||||
placeholder='Search events...'
|
||||
onInput={(event) => searchEvents(event.currentTarget.value)}
|
||||
onFocus={() => onFocus()}
|
||||
onBlur={() => onBlur()}
|
||||
/>
|
||||
{isSearching.value ? <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} /> : null}
|
||||
{areResultsVisible.value
|
||||
? (
|
||||
<section class='relative inline-block text-left ml-2 text-xs'>
|
||||
<section
|
||||
class={`absolute right-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none`}
|
||||
role='menu'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='view-button'
|
||||
tabindex={-1}
|
||||
>
|
||||
<section class='py-1'>
|
||||
<ol class='mt-2'>
|
||||
{calendarEvents.value.map((calendarEvent) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href='javascript:void(0);'
|
||||
class={`block px-2 py-2 hover:no-underline hover:opacity-60`}
|
||||
style={{
|
||||
backgroundColor: calendars.find((calendar) => calendar.uid === calendarEvent.calendarId)
|
||||
?.calendarColor || getColorAsHex('bg-gray-700'),
|
||||
}}
|
||||
onClick={() => onClickOpenEvent(calendarEvent)}
|
||||
>
|
||||
<time
|
||||
datetime={new Date(calendarEvent.startDate).toISOString()}
|
||||
class='mr-2 flex-none text-slate-100 block'
|
||||
>
|
||||
{dateFormat.format(new Date(calendarEvent.startDate))}
|
||||
</time>
|
||||
<p class='flex-auto truncate font-medium text-white'>
|
||||
{calendarEvent.title}
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
275
components/calendar/ViewCalendarEvent.tsx
Normal file
275
components/calendar/ViewCalendarEvent.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||
import { capitalizeWord, convertObjectToFormData } from '/public/ts/utils/misc.ts';
|
||||
import { FormField, generateFieldHtml } from '/public/ts/utils/form.ts';
|
||||
import {
|
||||
RequestBody as DeleteRequestBody,
|
||||
ResponseBody as DeleteResponseBody,
|
||||
} from '/pages/api/calendar/delete-event.ts';
|
||||
|
||||
interface ViewCalendarEventProps {
|
||||
initialCalendarEvent: CalendarEvent;
|
||||
calendars: Calendar[];
|
||||
formData: Record<string, any>;
|
||||
error?: string;
|
||||
notice?: string;
|
||||
}
|
||||
|
||||
export function formFields(calendarEvent: CalendarEvent, calendars: Calendar[], updateType: 'raw' | 'ui') {
|
||||
const fields: FormField[] = [
|
||||
{
|
||||
name: 'update-type',
|
||||
label: 'Update type',
|
||||
type: 'hidden',
|
||||
value: updateType,
|
||||
readOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (updateType === 'ui') {
|
||||
fields.push({
|
||||
name: 'title',
|
||||
label: 'Title',
|
||||
type: 'text',
|
||||
placeholder: 'Dentis',
|
||||
value: calendarEvent.title,
|
||||
required: true,
|
||||
}, {
|
||||
name: 'calendarId',
|
||||
label: 'Calendar',
|
||||
type: 'select',
|
||||
value: calendarEvent.calendarId,
|
||||
options: calendars.map((calendar) => ({ label: calendar.displayName!, value: calendar.uid! })),
|
||||
required: true,
|
||||
description: 'Cannot be changed after the event has been created.',
|
||||
}, {
|
||||
name: 'startDate',
|
||||
label: 'Start date',
|
||||
type: 'datetime-local',
|
||||
value: new Date(calendarEvent.startDate).toISOString().substring(0, 16),
|
||||
required: true,
|
||||
description: 'Dates are set in the default calendar timezone, controlled by Radicale.',
|
||||
}, {
|
||||
name: 'endDate',
|
||||
label: 'End date',
|
||||
type: 'datetime-local',
|
||||
value: new Date(calendarEvent.endDate).toISOString().substring(0, 16),
|
||||
required: true,
|
||||
description: 'Dates are set in the default calendar timezone, controlled by Radicale.',
|
||||
}, {
|
||||
name: 'isAllDay',
|
||||
label: 'All-day?',
|
||||
type: 'checkbox',
|
||||
placeholder: 'YYYYMMDD',
|
||||
value: 'true',
|
||||
required: false,
|
||||
checked: calendarEvent.isAllDay,
|
||||
}, {
|
||||
name: 'status',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
value: calendarEvent.status,
|
||||
options: (['scheduled', 'pending', 'canceled'] as CalendarEvent['status'][]).map((status) => ({
|
||||
label: capitalizeWord(status),
|
||||
value: status,
|
||||
})),
|
||||
required: true,
|
||||
}, {
|
||||
name: 'description',
|
||||
label: 'Description',
|
||||
type: 'textarea',
|
||||
placeholder: 'Just a regular check-up.',
|
||||
value: calendarEvent.description,
|
||||
required: false,
|
||||
}, {
|
||||
name: 'eventUrl',
|
||||
label: 'URL',
|
||||
type: 'url',
|
||||
placeholder: 'https://example.com',
|
||||
value: calendarEvent.eventUrl,
|
||||
required: false,
|
||||
}, {
|
||||
name: 'location',
|
||||
label: 'Location',
|
||||
type: 'text',
|
||||
placeholder: 'Birmingham, UK',
|
||||
value: calendarEvent.location,
|
||||
required: false,
|
||||
}, {
|
||||
name: 'transparency',
|
||||
label: 'Transparency',
|
||||
type: 'select',
|
||||
value: calendarEvent.transparency,
|
||||
options: (['opaque', 'transparent'] as CalendarEvent['transparency'][]).map((
|
||||
transparency,
|
||||
) => ({
|
||||
label: capitalizeWord(transparency),
|
||||
value: transparency,
|
||||
})),
|
||||
required: true,
|
||||
});
|
||||
} else if (updateType === 'raw') {
|
||||
fields.push({
|
||||
name: 'ics',
|
||||
label: 'Raw ICS',
|
||||
type: 'textarea',
|
||||
placeholder: 'Raw ICS...',
|
||||
value: calendarEvent.data,
|
||||
description:
|
||||
'This is the raw ICS for this event. Use this to manually update the event _if_ you know what you are doing.',
|
||||
rows: '10',
|
||||
});
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
export default function ViewCalendarEvent(
|
||||
{ initialCalendarEvent, calendars, formData: formDataObject, error, notice }: ViewCalendarEventProps,
|
||||
) {
|
||||
const isDeleting = useSignal<boolean>(false);
|
||||
const calendarEvent = useSignal<CalendarEvent>(initialCalendarEvent);
|
||||
|
||||
const formData = convertObjectToFormData(formDataObject);
|
||||
|
||||
async function onClickDeleteEvent() {
|
||||
const message = calendarEvent.peek().isRecurring
|
||||
? 'Are you sure you want to delete _all_ instances of this recurring event?'
|
||||
: 'Are you sure you want to delete this event?';
|
||||
if (confirm(message)) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteRequestBody = {
|
||||
calendarIds: calendars.map((calendar) => calendar.uid!),
|
||||
calendarView: 'day',
|
||||
calendarStartDate: new Date().toISOString().substring(0, 10),
|
||||
calendarEventId: calendarEvent.value.uid!,
|
||||
calendarId: calendarEvent.value.calendarId,
|
||||
};
|
||||
const response = await fetch(`/api/calendar/delete-event`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
const result = await response.json() as DeleteResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete event!');
|
||||
}
|
||||
|
||||
window.location.href = '/calendar';
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section class='flex flex-row items-center justify-between mb-4'>
|
||||
<a href='/calendar' class='mr-2'>View calendar</a>
|
||||
<section class='flex items-center'>
|
||||
<button
|
||||
class='inline-block justify-center gap-x-1.5 rounded-md bg-red-800 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-600 ml-2'
|
||||
type='button'
|
||||
title='Delete event'
|
||||
onClick={() => onClickDeleteEvent()}
|
||||
>
|
||||
<img
|
||||
src='/public/images/delete.svg'
|
||||
alt='Delete event'
|
||||
class={`white ${isDeleting.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class='mx-auto max-w-7xl my-8'>
|
||||
{error
|
||||
? (
|
||||
<section class='notification-error'>
|
||||
<h3>Failed to update!</h3>
|
||||
<p>{error}</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{notice
|
||||
? (
|
||||
<section class='notification-success'>
|
||||
<h3>Success!</h3>
|
||||
<p>{notice}</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
<form method='POST' class='mb-12'>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: formFields(calendarEvent.peek(), calendars, 'ui').map((field) =>
|
||||
generateFieldHtml(field, formData)
|
||||
).join(''),
|
||||
}}
|
||||
/>
|
||||
|
||||
<section class='flex justify-end items-center mt-8 mb-4'>
|
||||
{calendarEvent.peek().isRecurring
|
||||
? (
|
||||
<p class='text-sm text-slate-400 mr-4'>
|
||||
Note that you'll update all instances of this recurring event.
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
<button class='button' type='submit'>Update event</button>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<hr class='my-8 border-slate-700' />
|
||||
|
||||
<details class='mb-12 group'>
|
||||
<summary class='text-slate-100 flex items-center font-bold cursor-pointer text-center justify-center mx-auto hover:text-sky-400'>
|
||||
Edit Raw ICS{' '}
|
||||
<span class='ml-2 text-slate-400 group-open:rotate-90 transition-transform duration-200'>
|
||||
<img src='/public/images/right.svg' alt='Expand' width={16} height={16} class='white' />
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<form method='POST' class='mb-12'>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: formFields(calendarEvent.peek(), calendars, 'raw').map((field) =>
|
||||
generateFieldHtml(field, formData)
|
||||
).join(''),
|
||||
}}
|
||||
/>
|
||||
|
||||
<section class='flex justify-end mt-8 mb-4'>
|
||||
<button class='button' type='submit'>Update ICS</button>
|
||||
</section>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<span
|
||||
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
|
||||
>
|
||||
{isDeleting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{!isDeleting.value ? <> </> : null}
|
||||
</span>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
159
components/calendar/ViewEventModal.tsx
Normal file
159
components/calendar/ViewEventModal.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { Calendar, CalendarEvent } from '/lib/models/calendar.ts';
|
||||
import { convertRRuleToWords } from '/public/ts/utils/calendar.ts';
|
||||
|
||||
interface ViewEventModalProps {
|
||||
isOpen: boolean;
|
||||
calendarEvent: CalendarEvent;
|
||||
calendar: Calendar;
|
||||
onClickDelete: (eventId: string) => void;
|
||||
onClose: () => void;
|
||||
timezoneId: string;
|
||||
}
|
||||
|
||||
export default function ViewEventModal(
|
||||
{ isOpen, calendarEvent, calendar, onClickDelete, onClose, timezoneId }: ViewEventModalProps,
|
||||
) {
|
||||
if (!calendarEvent || !calendar) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const allDayEventDateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
timeZone: timezoneId, // Calendar dates are parsed without timezone info, so we need to force to a specific one so it's consistent across db, server, and client
|
||||
});
|
||||
const hourFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: timezoneId, // Calendar dates are parsed without timezone info, so we need to force to a specific one so it's consistent across db, server, and client
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
<section
|
||||
class={`fixed ${
|
||||
isOpen ? 'block' : 'hidden'
|
||||
} z-50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 min-w-96 max-w-lg bg-slate-600 text-white rounded-md px-8 py-6 drop-shadow-lg overflow-y-scroll max-h-[80%]`}
|
||||
>
|
||||
<h1 class='text-2xl font-semibold my-5'>{calendarEvent.title}</h1>
|
||||
<header class='py-5 border-t border-b border-slate-500 font-semibold flex justify-between items-center'>
|
||||
<span>
|
||||
{calendarEvent.startDate ? allDayEventDateFormat.format(new Date(calendarEvent.startDate)) : ''}
|
||||
</span>
|
||||
{calendarEvent.isAllDay ? <span>All-day</span> : (
|
||||
<span>
|
||||
{calendarEvent.startDate ? hourFormat.format(new Date(calendarEvent.startDate)) : ''} -{' '}
|
||||
{calendarEvent.endDate ? hourFormat.format(new Date(calendarEvent.endDate)) : ''}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
<section class='py-5 my-0 border-b border-slate-500 flex justify-between items-center'>
|
||||
<span>
|
||||
{calendar.displayName}
|
||||
</span>
|
||||
<span
|
||||
class={`w-5 h-5 ml-2 block rounded-full`}
|
||||
title={calendar.calendarColor}
|
||||
style={{ backgroundColor: calendar.calendarColor }}
|
||||
/>
|
||||
</section>
|
||||
{calendarEvent.description
|
||||
? (
|
||||
<section class='py-5 my-0 border-b border-slate-500'>
|
||||
<article class='overflow-auto max-w-full max-h-80 font-mono text-sm whitespace-pre-wrap'>
|
||||
{calendarEvent.description}
|
||||
</article>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{calendarEvent.eventUrl
|
||||
? (
|
||||
<section class='py-5 my-0 border-b border-slate-500'>
|
||||
<a href={calendarEvent.eventUrl} target='_blank' rel='noopener noreferrer'>
|
||||
{calendarEvent.eventUrl}
|
||||
</a>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{calendarEvent.location
|
||||
? (
|
||||
<section class='py-5 my-0 border-b border-slate-500'>
|
||||
<a
|
||||
href={`https://www.openstreetmap.org/search?query=${encodeURIComponent(calendarEvent.location)}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
{calendarEvent.location}
|
||||
</a>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{Array.isArray(calendarEvent.attendees) && calendarEvent.attendees.length > 0
|
||||
? (
|
||||
<section class='py-5 my-0 border-b border-slate-500'>
|
||||
{calendarEvent.attendees.map((attendee) => (
|
||||
<p class='my-1'>
|
||||
<a href={`mailto:${attendee.email}`} target='_blank' rel='noopener noreferrer'>
|
||||
{attendee.name || attendee.email}
|
||||
</a>{' '}
|
||||
- {attendee.status}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{calendarEvent.isRecurring && calendarEvent.recurringRrule
|
||||
? (
|
||||
<section class='py-5 my-0 border-b border-slate-500'>
|
||||
<p class='text-xs'>
|
||||
Repeats {convertRRuleToWords(calendarEvent.recurringRrule, { capitalizeSentence: false })}.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{Array.isArray(calendarEvent.reminders) && calendarEvent.reminders.length > 0
|
||||
? (
|
||||
<section class='py-5 my-0 border-b border-slate-500'>
|
||||
{calendarEvent.reminders.map((reminder) => (
|
||||
<p class='my-1 text-xs'>
|
||||
{reminder.description || 'Reminder'} at {hourFormat.format(new Date(reminder.startDate))} via{' '}
|
||||
{reminder.type}.
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
<footer class='flex justify-between mt-2'>
|
||||
<button
|
||||
type='button'
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-red-600 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickDelete(calendarEvent.uid!)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<a
|
||||
href={`/calendar/${calendarEvent.uid}?calendarId=${calendar.uid}`}
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
target='_blank'
|
||||
>
|
||||
Edit
|
||||
</a>
|
||||
<button
|
||||
type='button'
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { AddressBook, Contact } from '/lib/models/contacts.ts';
|
||||
import { RequestBody as GetRequestBody, ResponseBody as GetResponseBody } from '/routes/api/contacts/get.tsx';
|
||||
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/contacts/add.tsx';
|
||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/contacts/delete.tsx';
|
||||
import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/routes/api/contacts/import.tsx';
|
||||
import { RequestBody as GetRequestBody, ResponseBody as GetResponseBody } from '/pages/api/contacts/get.ts';
|
||||
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/pages/api/contacts/add.ts';
|
||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/pages/api/contacts/delete.ts';
|
||||
import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/pages/api/contacts/import.ts';
|
||||
import {
|
||||
RequestBody as AddAddressBookRequestBody,
|
||||
ResponseBody as AddAddressBookResponseBody,
|
||||
} from '/routes/api/contacts/add-addressbook.tsx';
|
||||
} from '/pages/api/contacts/add-addressbook.ts';
|
||||
import {
|
||||
RequestBody as DeleteAddressBookRequestBody,
|
||||
ResponseBody as DeleteAddressBookResponseBody,
|
||||
} from '/routes/api/contacts/delete-addressbook.tsx';
|
||||
} from '/pages/api/contacts/delete-addressbook.ts';
|
||||
|
||||
interface ContactsProps {
|
||||
initialAddressBookId: string;
|
||||
|
|
@ -359,7 +359,7 @@ export default function Contacts(
|
|||
</div>
|
||||
|
||||
<div
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right divide-y divide-slate-600 rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right divide-y divide-slate-600 rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
||||
!isAddressBooksDropdownOpen.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
|
|
@ -424,7 +424,7 @@ export default function Contacts(
|
|||
</div>
|
||||
|
||||
<div
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
||||
!isOptionsDropdownOpen.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
|
|
@ -457,7 +457,7 @@ export default function Contacts(
|
|||
onClick={() => onClickAddContact()}
|
||||
>
|
||||
<img
|
||||
src='/images/add.svg'
|
||||
src='/public/images/add.svg'
|
||||
alt='Add new contact'
|
||||
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
|
|
@ -493,7 +493,7 @@ export default function Contacts(
|
|||
onClick={() => onClickDeleteContact(contact.uid!)}
|
||||
>
|
||||
<img
|
||||
src='/images/delete.svg'
|
||||
src='/public/images/delete.svg'
|
||||
class='red drop-shadow-md'
|
||||
width={24}
|
||||
height={24}
|
||||
|
|
@ -524,21 +524,21 @@ export default function Contacts(
|
|||
{isDeleting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isExporting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isImporting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Contact } from '/lib/models/contacts.ts';
|
||||
import { convertObjectToFormData } from '/lib/utils/misc.ts';
|
||||
import { FormField, generateFieldHtml } from '/lib/form-utils.tsx';
|
||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/contacts/delete.tsx';
|
||||
import { convertObjectToFormData } from '/public/ts/utils/misc.ts';
|
||||
import { FormField, generateFieldHtml } from '/public/ts/utils/form.ts';
|
||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/pages/api/contacts/delete.ts';
|
||||
|
||||
interface ViewContactProps {
|
||||
addressBookId: string;
|
||||
|
|
@ -131,7 +131,7 @@ export default function ViewContact(
|
|||
onClick={() => onClickDeleteContact()}
|
||||
>
|
||||
<img
|
||||
src='/images/delete.svg'
|
||||
src='/public/images/delete.svg'
|
||||
alt='Delete contact'
|
||||
class={`white ${isDeleting.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
|
|
@ -160,7 +160,11 @@ export default function ViewContact(
|
|||
: null}
|
||||
|
||||
<form method='POST' class='mb-12'>
|
||||
{formFields(contact.peek(), 'ui').map((field) => generateFieldHtml(field, formData))}
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: formFields(contact.peek(), 'ui').map((field) => generateFieldHtml(field, formData)).join(''),
|
||||
}}
|
||||
/>
|
||||
|
||||
<section class='flex justify-end mt-8 mb-4'>
|
||||
<button class='button' type='submit'>Update contact</button>
|
||||
|
|
@ -173,12 +177,16 @@ export default function ViewContact(
|
|||
<summary class='text-slate-100 flex items-center font-bold cursor-pointer text-center justify-center mx-auto hover:text-sky-400'>
|
||||
Edit Raw vCard{' '}
|
||||
<span class='ml-2 text-slate-400 group-open:rotate-90 transition-transform duration-200'>
|
||||
<img src='/images/right.svg' alt='Expand' width={16} height={16} class='white' />
|
||||
<img src='/public/images/right.svg' alt='Expand' width={16} height={16} class='white' />
|
||||
</span>
|
||||
</summary>
|
||||
|
||||
<form method='POST' class='mb-12'>
|
||||
{formFields(contact.peek(), 'raw').map((field) => generateFieldHtml(field, formData))}
|
||||
<div
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: formFields(contact.peek(), 'raw').map((field) => generateFieldHtml(field, formData)).join(''),
|
||||
}}
|
||||
/>
|
||||
|
||||
<section class='flex justify-end mt-8 mb-4'>
|
||||
<button class='button' type='submit'>Update vCard</button>
|
||||
|
|
@ -192,7 +200,7 @@ export default function ViewContact(
|
|||
{isDeleting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
|
|
@ -2,8 +2,8 @@ import { useSignal } from '@preact/signals';
|
|||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { DashboardLink } from '/lib/types.ts';
|
||||
import { validateUrl } from '/lib/utils/misc.ts';
|
||||
import { RequestBody, ResponseBody } from '/routes/api/dashboard/save-links.tsx';
|
||||
import { validateUrl } from '/public/ts/utils/misc.ts';
|
||||
import { RequestBody, ResponseBody } from '/pages/api/dashboard/save-links.ts';
|
||||
|
||||
interface LinksProps {
|
||||
initialLinks: DashboardLink[];
|
||||
|
|
@ -118,13 +118,13 @@ export default function Links({ initialLinks }: LinksProps) {
|
|||
<section class='flex flex-row items-center justify-end mb-4'>
|
||||
<section class='flex items-center'>
|
||||
<button
|
||||
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2'
|
||||
class='inline-block justify-center gap-x-1.5 rounded-md bg-[#51A4FB] px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-sky-400 ml-2 cursor-pointer'
|
||||
type='button'
|
||||
title='Add new link'
|
||||
onClick={() => onClickAddLink()}
|
||||
>
|
||||
<img
|
||||
src='/images/add.svg'
|
||||
src='/public/images/add.svg'
|
||||
alt='Add new link'
|
||||
class={`white`}
|
||||
width={20}
|
||||
|
|
@ -148,7 +148,7 @@ export default function Links({ initialLinks }: LinksProps) {
|
|||
onClick={() => onClickDeleteLink(index)}
|
||||
>
|
||||
<img
|
||||
src='/images/delete.svg'
|
||||
src='/public/images/delete.svg'
|
||||
class='red drop-shadow-md'
|
||||
width={24}
|
||||
height={24}
|
||||
|
|
@ -163,7 +163,7 @@ export default function Links({ initialLinks }: LinksProps) {
|
|||
onClick={() => onClickMoveLeftLink(index)}
|
||||
>
|
||||
<img
|
||||
src='/images/left-circle.svg'
|
||||
src='/public/images/left-circle.svg'
|
||||
class='gray'
|
||||
width={24}
|
||||
height={24}
|
||||
|
|
@ -185,14 +185,14 @@ export default function Links({ initialLinks }: LinksProps) {
|
|||
{isSaving.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{hasSaved.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/check.svg' class='green mr-2' width={18} height={18} />Saved!
|
||||
<img src='/public/images/check.svg' class='green mr-2' width={18} height={18} />Saved!
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useSignal, useSignalEffect } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { RequestBody, ResponseBody } from '/routes/api/dashboard/save-notes.tsx';
|
||||
import { RequestBody, ResponseBody } from '/pages/api/dashboard/save-notes.ts';
|
||||
|
||||
interface NotesProps {
|
||||
initialNotes: string;
|
||||
|
|
@ -85,14 +85,14 @@ export default function Notes({ initialNotes }: NotesProps) {
|
|||
{isSaving.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{hasSaved.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/check.svg' class='green mr-2' width={18} height={18} />Saved!
|
||||
<img src='/public/images/check.svg' class='green mr-2' width={18} height={18} />Saved!
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
|
|
@ -2,7 +2,7 @@ import { useSignal } from '@preact/signals';
|
|||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { Budget } from '/lib/types.ts';
|
||||
import { formatInputToNumber } from '/lib/utils/misc.ts';
|
||||
import { formatInputToNumber } from '/public/ts/utils/misc.ts';
|
||||
|
||||
interface BudgetModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -41,7 +41,7 @@ export default function BudgetModal(
|
|||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@ import { useSignal } from '@preact/signals';
|
|||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { Budget, Expense } from '/lib/types.ts';
|
||||
import { formatInputToNumber } from '/lib/utils/misc.ts';
|
||||
import { formatInputToNumber } from '/public/ts/utils/misc.ts';
|
||||
|
||||
import {
|
||||
RequestBody as SuggestionsRequestBody,
|
||||
ResponseBody as SuggestionsResponse,
|
||||
} from '/routes/api/expenses/auto-complete.tsx';
|
||||
} from '/pages/api/expenses/auto-complete.ts';
|
||||
|
||||
interface ExpenseModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -98,7 +98,7 @@ export default function ExpenseModal(
|
|||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
|
|
@ -191,7 +191,6 @@ export default function ExpenseModal(
|
|||
onChange={(event) => {
|
||||
newExpenseBudget.value = event.currentTarget.value;
|
||||
}}
|
||||
placeholder='Misc'
|
||||
>
|
||||
{sortedBudgetNames.map((budget) => (
|
||||
<option value={budget} selected={newExpenseBudget.value === budget}>{budget}</option>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import { Chart } from 'chart.js';
|
||||
import { Chart } from 'chart.js/auto';
|
||||
|
||||
import { formatNumber } from '/lib/utils/misc.ts';
|
||||
import { formatNumber } from '/public/ts/utils/misc.ts';
|
||||
import { Budget, SupportedCurrencySymbol } from '/lib/types.ts';
|
||||
|
||||
interface ListBudgetsProps {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Expense, SupportedCurrencySymbol } from '/lib/types.ts';
|
||||
import { formatNumber } from '/lib/utils/misc.ts';
|
||||
import { formatNumber } from '/public/ts/utils/misc.ts';
|
||||
|
||||
interface ListExpensesProps {
|
||||
expenses: Expense[];
|
||||
currency: SupportedCurrencySymbol;
|
||||
|
|
@ -19,7 +20,7 @@ export default function ListExpenses(
|
|||
timeZone: 'UTC', // Expense dates are stored without timezone info, so we need to force to UTC so it's consistent across db, server, and client
|
||||
};
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', dateFormatOptions);
|
||||
const dateFormat = new Intl.DateTimeFormat('en-US', dateFormatOptions);
|
||||
|
||||
return (
|
||||
<section class='mx-auto max-w-7xl my-8 mt-12'>
|
||||
|
|
@ -47,14 +48,14 @@ export default function ListExpenses(
|
|||
class='text-white border-t border-slate-700 hover:bg-slate-600 transition-colors even:bg-slate-700 odd:bg-slate-800 cursor-pointer flex md:table-row flex-row flex-wrap my-4 mx-4 md:my-0 md:mx-0 rounded md:rounded-none shadow-md md:shadow-none relative py-4 md:py-0 px-6 md:px-0'
|
||||
onClick={() => onClickEditExpense(expense.id)}
|
||||
>
|
||||
<td class='md:px-6 md:py-3 flex-[50] mx-2 md:mx-0'>{expense.description}</td>
|
||||
<td class='md:px-6 md:py-3 flex-[20] mx-2 md:mx-0 text-gray-400 md:text-gray-300'>
|
||||
<td class='md:px-6 md:py-3 flex-50 mx-2 md:mx-0'>{expense.description}</td>
|
||||
<td class='md:px-6 md:py-3 flex-20 mx-2 md:mx-0 text-gray-400 md:text-gray-300'>
|
||||
{expense.budget}
|
||||
</td>
|
||||
<td class='md:px-6 md:py-3 flex-[15] mx-2 md:mx-0 text-gray-400 md:text-gray-300'>
|
||||
<td class='md:px-6 md:py-3 flex-15 mx-2 md:mx-0 text-gray-400 md:text-gray-300'>
|
||||
{dateFormat.format(new Date(expense.date))}
|
||||
</td>
|
||||
<td class='md:px-6 md:py-3 flex-[15] mx-2 md:mx-0 font-bold md:font-semibold'>
|
||||
<td class='md:px-6 md:py-3 flex-15 mx-2 md:mx-0 font-bold md:font-semibold'>
|
||||
{formatNumber(currency, expense.cost)}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -5,35 +5,32 @@ import { Budget, Expense, SupportedCurrencySymbol } from '/lib/types.ts';
|
|||
import {
|
||||
RequestBody as ImportRequestBody,
|
||||
ResponseBody as ImportResponseBody,
|
||||
} from '/routes/api/expenses/import-expenses.tsx';
|
||||
import {
|
||||
RequestBody as ExportRequestBody,
|
||||
ResponseBody as ExportResponseBody,
|
||||
} from '/routes/api/expenses/export-expenses.tsx';
|
||||
} from '/pages/api/expenses/import-expenses.ts';
|
||||
import { ResponseBody as ExportResponseBody } from '/pages/api/expenses/export-expenses.ts';
|
||||
import {
|
||||
RequestBody as AddExpenseRequestBody,
|
||||
ResponseBody as AddExpenseResponseBody,
|
||||
} from '/routes/api/expenses/add-expense.tsx';
|
||||
} from '/pages/api/expenses/add-expense.ts';
|
||||
import {
|
||||
RequestBody as AddBudgetRequestBody,
|
||||
ResponseBody as AddBudgetResponseBody,
|
||||
} from '/routes/api/expenses/add-budget.tsx';
|
||||
} from '/pages/api/expenses/add-budget.ts';
|
||||
import {
|
||||
RequestBody as UpdateExpenseRequestBody,
|
||||
ResponseBody as UpdateExpenseResponseBody,
|
||||
} from '/routes/api/expenses/update-expense.tsx';
|
||||
} from '/pages/api/expenses/update-expense.ts';
|
||||
import {
|
||||
RequestBody as UpdateBudgetRequestBody,
|
||||
ResponseBody as UpdateBudgetResponseBody,
|
||||
} from '/routes/api/expenses/update-budget.tsx';
|
||||
} from '/pages/api/expenses/update-budget.ts';
|
||||
import {
|
||||
RequestBody as DeleteExpenseRequestBody,
|
||||
ResponseBody as DeleteExpenseResponseBody,
|
||||
} from '/routes/api/expenses/delete-expense.tsx';
|
||||
} from '/pages/api/expenses/delete-expense.ts';
|
||||
import {
|
||||
RequestBody as DeleteBudgetRequestBody,
|
||||
ResponseBody as DeleteBudgetResponseBody,
|
||||
} from '/routes/api/expenses/delete-budget.tsx';
|
||||
} from '/pages/api/expenses/delete-budget.ts';
|
||||
import ListBudgets from '/components/expenses/ListBudgets.tsx';
|
||||
import ListExpenses from '/components/expenses/ListExpenses.tsx';
|
||||
import ExpenseModal from './ExpenseModal.tsx';
|
||||
|
|
@ -167,10 +164,9 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
|
|||
const fileName = `expenses-data-export-${new Date().toISOString().substring(0, 19).replace(/:/g, '-')}.json`;
|
||||
|
||||
try {
|
||||
const requestBody: ExportRequestBody = {};
|
||||
const response = await fetch(`/api/expenses/export-expenses`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -663,7 +659,9 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
|
|||
placeholder='Filter expenses...'
|
||||
onInput={(event) => searchExpenses(event.currentTarget.value)}
|
||||
/>
|
||||
{isSearching.value ? <img src='/images/loading.svg' class='white mr-2' width={18} height={18} /> : null}
|
||||
{isSearching.value
|
||||
? <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />
|
||||
: null}
|
||||
</section>
|
||||
</section>
|
||||
<section class='flex items-center justify-end w-full'>
|
||||
|
|
@ -719,7 +717,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
|
|||
onClick={() => toggleNewOptionsDropdown()}
|
||||
>
|
||||
<img
|
||||
src='/images/add.svg'
|
||||
src='/public/images/add.svg'
|
||||
alt='Add new expense or budget'
|
||||
class={`white ${isSaving.value || isImporting.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
|
|
@ -729,7 +727,7 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
|
|||
</div>
|
||||
|
||||
<div
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
||||
!areNewOptionsOption.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
|
|
@ -797,21 +795,21 @@ export default function MainExpenses({ initialBudgets, initialExpenses, initialM
|
|||
{isSaving.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isImporting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isExporting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export default function CreateDirectoryModal(
|
|||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export default function CreateShareModal(
|
|||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { join } from 'std/path/join.ts';
|
||||
import { join } from '@std/path';
|
||||
|
||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import { humanFileSize, TRASH_PATH } from '/lib/utils/files.ts';
|
||||
import { humanFileSize, TRASH_PATH } from '/public/ts/utils/files.ts';
|
||||
|
||||
interface ListFilesProps {
|
||||
directories: Directory[];
|
||||
|
|
@ -18,6 +18,7 @@ interface ListFilesProps {
|
|||
onClickDeleteFile?: (parentPath: string, name: string) => Promise<void>;
|
||||
onClickCreateShare?: (filePath: string) => void;
|
||||
onClickOpenManageShare?: (fileShareId: string) => void;
|
||||
onClickDownloadDirectory?: (parentPath: string, name: string) => void;
|
||||
isShowingNotes?: boolean;
|
||||
isShowingPhotos?: boolean;
|
||||
fileShareId?: string;
|
||||
|
|
@ -39,6 +40,7 @@ export default function ListFiles(
|
|||
onClickDeleteFile,
|
||||
onClickCreateShare,
|
||||
onClickOpenManageShare,
|
||||
onClickDownloadDirectory,
|
||||
isShowingNotes,
|
||||
isShowingPhotos,
|
||||
fileShareId,
|
||||
|
|
@ -141,7 +143,7 @@ export default function ListFiles(
|
|||
class='flex items-center font-normal text-white'
|
||||
>
|
||||
<img
|
||||
src={`/images/${fullPath === TRASH_PATH ? 'trash.svg' : 'directory.svg'}`}
|
||||
src={`/public/images/${fullPath === TRASH_PATH ? 'trash.svg' : 'directory.svg'}`}
|
||||
class='white drop-shadow-md mr-2'
|
||||
width={18}
|
||||
height={18}
|
||||
|
|
@ -156,7 +158,7 @@ export default function ListFiles(
|
|||
</td>
|
||||
{isShowingNotes || isShowingPhotos ? null : (
|
||||
<td class='px-6 py-4 text-slate-200'>
|
||||
-
|
||||
{humanFileSize(directory.size_in_bytes)}
|
||||
</td>
|
||||
)}
|
||||
{isShowingPhotos || fileShareId ? null : (
|
||||
|
|
@ -165,13 +167,29 @@ export default function ListFiles(
|
|||
typeof onClickOpenMoveDirectory === 'undefined')
|
||||
? null
|
||||
: (
|
||||
<section class='flex items-center justify-end w-24'>
|
||||
<section class='flex items-center justify-end w-32'>
|
||||
{typeof onClickDownloadDirectory === 'undefined' ? null : (
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
onClick={() => onClickDownloadDirectory(directory.parent_path, directory.directory_name)}
|
||||
>
|
||||
<img
|
||||
src='/public/images/download.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
alt='Download directory as zip'
|
||||
title='Download directory as zip'
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
onClick={() => onClickOpenRenameDirectory(directory.parent_path, directory.directory_name)}
|
||||
onClick={() =>
|
||||
onClickOpenRenameDirectory(directory.parent_path, directory.directory_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/rename.svg'
|
||||
src='/public/images/rename.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
|
|
@ -181,11 +199,10 @@ export default function ListFiles(
|
|||
</span>
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
onClick={() =>
|
||||
onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)}
|
||||
onClick={() => onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/move.svg'
|
||||
src='/public/images/move.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
|
|
@ -199,7 +216,7 @@ export default function ListFiles(
|
|||
onClick={() => onClickDeleteDirectory(directory.parent_path, directory.directory_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/delete.svg'
|
||||
src='/public/images/delete.svg'
|
||||
class='red drop-shadow-md'
|
||||
width={20}
|
||||
height={20}
|
||||
|
|
@ -214,7 +231,7 @@ export default function ListFiles(
|
|||
onClick={() => onClickCreateShare(join(directory.parent_path, directory.directory_name))}
|
||||
>
|
||||
<img
|
||||
src='/images/share.svg'
|
||||
src='/public/images/share.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
|
|
@ -229,7 +246,7 @@ export default function ListFiles(
|
|||
onClick={() => onClickOpenManageShare(directory.file_share_id!)}
|
||||
>
|
||||
<img
|
||||
src='/images/share.svg'
|
||||
src='/public/images/share.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
|
|
@ -271,7 +288,7 @@ export default function ListFiles(
|
|||
rel='noopener noreferrer'
|
||||
>
|
||||
<img
|
||||
src='/images/file.svg'
|
||||
src='/public/images/file.svg'
|
||||
class='white drop-shadow-md mr-2'
|
||||
width={18}
|
||||
height={18}
|
||||
|
|
@ -298,7 +315,7 @@ export default function ListFiles(
|
|||
onClick={() => onClickOpenRenameFile(file.parent_path, file.file_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/rename.svg'
|
||||
src='/public/images/rename.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
|
|
@ -313,7 +330,7 @@ export default function ListFiles(
|
|||
onClick={() => onClickOpenMoveFile(file.parent_path, file.file_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/move.svg'
|
||||
src='/public/images/move.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
|
|
@ -328,7 +345,7 @@ export default function ListFiles(
|
|||
onClick={() => onClickDeleteFile(file.parent_path, file.file_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/delete.svg'
|
||||
src='/public/images/delete.svg'
|
||||
class='red drop-shadow-md'
|
||||
width={20}
|
||||
height={20}
|
||||
|
|
@ -343,7 +360,7 @@ export default function ListFiles(
|
|||
onClick={() => onClickCreateShare(join(file.parent_path, file.file_name))}
|
||||
>
|
||||
<img
|
||||
src='/images/share.svg'
|
||||
src='/public/images/share.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
|
|
@ -358,7 +375,7 @@ export default function ListFiles(
|
|||
onClick={() => onClickOpenManageShare(file.file_share_id!)}
|
||||
>
|
||||
<img
|
||||
src='/images/share.svg'
|
||||
src='/public/images/share.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import { humanFileSize, TRASH_PATH } from '/lib/utils/files.ts';
|
||||
import { humanFileSize, TRASH_PATH } from '/public/ts/utils/files.ts';
|
||||
|
||||
interface ListFilesProps {
|
||||
interface ListPhotosProps {
|
||||
directories: Directory[];
|
||||
files: DirectoryFile[];
|
||||
onClickOpenRenameDirectory?: (parentPath: string, name: string) => void;
|
||||
|
|
@ -13,7 +13,7 @@ interface ListFilesProps {
|
|||
isShowingNotes?: boolean;
|
||||
}
|
||||
|
||||
export default function ListFiles(
|
||||
export default function ListPhotos(
|
||||
{
|
||||
directories,
|
||||
files,
|
||||
|
|
@ -24,7 +24,7 @@ export default function ListFiles(
|
|||
onClickDeleteDirectory,
|
||||
onClickDeleteFile,
|
||||
isShowingNotes,
|
||||
}: ListFilesProps,
|
||||
}: ListPhotosProps,
|
||||
) {
|
||||
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
|
|
@ -64,7 +64,7 @@ export default function ListFiles(
|
|||
class='flex items-center font-normal text-white'
|
||||
>
|
||||
<img
|
||||
src={`/images/${fullPath === TRASH_PATH ? 'trash.svg' : 'directory.svg'}`}
|
||||
src={`/public/images/${fullPath === TRASH_PATH ? 'trash.svg' : 'directory.svg'}`}
|
||||
class='white drop-shadow-md mr-2'
|
||||
width={18}
|
||||
height={18}
|
||||
|
|
@ -79,7 +79,7 @@ export default function ListFiles(
|
|||
</td>
|
||||
{isShowingNotes ? null : (
|
||||
<td class='px-6 py-4 text-slate-200'>
|
||||
-
|
||||
{humanFileSize(directory.size_in_bytes)}
|
||||
</td>
|
||||
)}
|
||||
<td class='px-6 py-4'>
|
||||
|
|
@ -93,7 +93,7 @@ export default function ListFiles(
|
|||
onClick={() => onClickOpenRenameDirectory(directory.parent_path, directory.directory_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/rename.svg'
|
||||
src='/public/images/rename.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
|
|
@ -106,7 +106,7 @@ export default function ListFiles(
|
|||
onClick={() => onClickOpenMoveDirectory(directory.parent_path, directory.directory_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/move.svg'
|
||||
src='/public/images/move.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
|
|
@ -119,7 +119,7 @@ export default function ListFiles(
|
|||
onClick={() => onClickDeleteDirectory(directory.parent_path, directory.directory_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/delete.svg'
|
||||
src='/public/images/delete.svg'
|
||||
class='red drop-shadow-md'
|
||||
width={20}
|
||||
height={20}
|
||||
|
|
@ -145,7 +145,7 @@ export default function ListFiles(
|
|||
rel='noopener noreferrer'
|
||||
>
|
||||
<img
|
||||
src='/images/file.svg'
|
||||
src='/public/images/file.svg'
|
||||
class='white drop-shadow-md mr-2'
|
||||
width={18}
|
||||
height={18}
|
||||
|
|
@ -171,7 +171,7 @@ export default function ListFiles(
|
|||
onClick={() => onClickOpenRenameFile(file.parent_path, file.file_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/rename.svg'
|
||||
src='/public/images/rename.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
|
|
@ -186,7 +186,7 @@ export default function ListFiles(
|
|||
onClick={() => onClickOpenMoveFile(file.parent_path, file.file_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/move.svg'
|
||||
src='/public/images/move.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
|
|
@ -200,7 +200,7 @@ export default function ListFiles(
|
|||
onClick={() => onClickDeleteFile(file.parent_path, file.file_name)}
|
||||
>
|
||||
<img
|
||||
src='/images/delete.svg'
|
||||
src='/public/images/delete.svg'
|
||||
class='red drop-shadow-md'
|
||||
width={20}
|
||||
height={20}
|
||||
|
|
|
|||
|
|
@ -1,38 +1,38 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import { ResponseBody as UploadResponseBody } from '/routes/api/files/upload.tsx';
|
||||
import { RequestBody as RenameRequestBody, ResponseBody as RenameResponseBody } from '/routes/api/files/rename.tsx';
|
||||
import { RequestBody as MoveRequestBody, ResponseBody as MoveResponseBody } from '/routes/api/files/move.tsx';
|
||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/files/delete.tsx';
|
||||
import { ResponseBody as UploadResponseBody } from '/pages/api/files/upload.ts';
|
||||
import { RequestBody as RenameRequestBody, ResponseBody as RenameResponseBody } from '/pages/api/files/rename.ts';
|
||||
import { RequestBody as MoveRequestBody, ResponseBody as MoveResponseBody } from '/pages/api/files/move.ts';
|
||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/pages/api/files/delete.ts';
|
||||
import {
|
||||
RequestBody as CreateDirectoryRequestBody,
|
||||
ResponseBody as CreateDirectoryResponseBody,
|
||||
} from '/routes/api/files/create-directory.tsx';
|
||||
} from '/pages/api/files/create-directory.ts';
|
||||
import {
|
||||
RequestBody as RenameDirectoryRequestBody,
|
||||
ResponseBody as RenameDirectoryResponseBody,
|
||||
} from '/routes/api/files/rename-directory.tsx';
|
||||
} from '/pages/api/files/rename-directory.ts';
|
||||
import {
|
||||
RequestBody as MoveDirectoryRequestBody,
|
||||
ResponseBody as MoveDirectoryResponseBody,
|
||||
} from '/routes/api/files/move-directory.tsx';
|
||||
} from '/pages/api/files/move-directory.ts';
|
||||
import {
|
||||
RequestBody as DeleteDirectoryRequestBody,
|
||||
ResponseBody as DeleteDirectoryResponseBody,
|
||||
} from '/routes/api/files/delete-directory.tsx';
|
||||
} from '/pages/api/files/delete-directory.ts';
|
||||
import {
|
||||
RequestBody as CreateShareRequestBody,
|
||||
ResponseBody as CreateShareResponseBody,
|
||||
} from '/routes/api/files/create-share.tsx';
|
||||
} from '/pages/api/files/create-share.ts';
|
||||
import {
|
||||
RequestBody as UpdateShareRequestBody,
|
||||
ResponseBody as UpdateShareResponseBody,
|
||||
} from '/routes/api/files/update-share.tsx';
|
||||
} from '/pages/api/files/update-share.ts';
|
||||
import {
|
||||
RequestBody as DeleteShareRequestBody,
|
||||
ResponseBody as DeleteShareResponseBody,
|
||||
} from '/routes/api/files/delete-share.tsx';
|
||||
} from '/pages/api/files/delete-share.ts';
|
||||
import SearchFiles from './SearchFiles.tsx';
|
||||
import ListFiles from './ListFiles.tsx';
|
||||
import FilesBreadcrumb from './FilesBreadcrumb.tsx';
|
||||
|
|
@ -48,7 +48,7 @@ interface MainFilesProps {
|
|||
initialPath: string;
|
||||
baseUrl: string;
|
||||
isFileSharingAllowed: boolean;
|
||||
isCalDavEnabled?: boolean;
|
||||
areDirectoryDownloadsAllowed: boolean;
|
||||
fileShareId?: string;
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +59,7 @@ export default function MainFiles(
|
|||
initialPath,
|
||||
baseUrl,
|
||||
isFileSharingAllowed,
|
||||
isCalDavEnabled,
|
||||
areDirectoryDownloadsAllowed,
|
||||
fileShareId,
|
||||
}: MainFilesProps,
|
||||
) {
|
||||
|
|
@ -413,6 +413,21 @@ export default function MainFiles(
|
|||
moveDirectoryOrFileModal.value = null;
|
||||
}
|
||||
|
||||
function onClickDownloadDirectory(parentPath: string, name: string) {
|
||||
// Create download URL with proper path encoding
|
||||
const downloadUrl = `/api/files/download-directory?parentPath=${encodeURIComponent(parentPath)}&name=${
|
||||
encodeURIComponent(name)
|
||||
}`;
|
||||
|
||||
// Create a temporary anchor element to trigger download
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.download = `${name}.zip`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
async function onClickDeleteDirectory(parentPath: string, name: string, isBulkDeleting = false) {
|
||||
if (isBulkDeleting || confirm('Are you sure you want to delete this directory?')) {
|
||||
if (!isBulkDeleting && isDeleting.value) {
|
||||
|
|
@ -726,7 +741,7 @@ export default function MainFiles(
|
|||
onClick={() => toggleBulkOptionsDropdown()}
|
||||
>
|
||||
<img
|
||||
src={`/images/${areBulkOptionsOpen.value ? 'hide-options' : 'show-options'}.svg`}
|
||||
src={`/public/images/${areBulkOptionsOpen.value ? 'hide-options' : 'show-options'}.svg`}
|
||||
alt='Bulk actions'
|
||||
class={`white w-5 max-w-5`}
|
||||
width={20}
|
||||
|
|
@ -736,7 +751,7 @@ export default function MainFiles(
|
|||
</div>
|
||||
|
||||
<div
|
||||
class={`absolute left-0 z-10 mt-2 w-44 origin-top-left rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||
class={`absolute left-0 z-10 mt-2 w-44 origin-top-left rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
||||
!areBulkOptionsOpen.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
|
|
@ -777,7 +792,7 @@ export default function MainFiles(
|
|||
onClick={() => toggleNewOptionsDropdown()}
|
||||
>
|
||||
<img
|
||||
src='/images/add.svg'
|
||||
src='/public/images/add.svg'
|
||||
alt='Add new file or directory'
|
||||
class={`white ${isAdding.value || isUploading.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
|
|
@ -787,7 +802,7 @@ export default function MainFiles(
|
|||
</div>
|
||||
|
||||
<div
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
||||
!areNewOptionsOpen.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
|
|
@ -841,6 +856,7 @@ export default function MainFiles(
|
|||
onClickDeleteFile={onClickDeleteFile}
|
||||
onClickCreateShare={isFileSharingAllowed ? onClickCreateShare : undefined}
|
||||
onClickOpenManageShare={isFileSharingAllowed ? onClickOpenManageShare : undefined}
|
||||
onClickDownloadDirectory={areDirectoryDownloadsAllowed ? onClickDownloadDirectory : undefined}
|
||||
fileShareId={fileShareId}
|
||||
/>
|
||||
|
||||
|
|
@ -850,28 +866,28 @@ export default function MainFiles(
|
|||
{isDeleting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isAdding.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Creating...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Creating...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isUploading.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Uploading...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Uploading...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isUpdating.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Updating...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Updating...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
|
|
@ -888,15 +904,6 @@ export default function MainFiles(
|
|||
)
|
||||
: null}
|
||||
|
||||
{!fileShareId && isCalDavEnabled
|
||||
? (
|
||||
<section class='flex flex-row items-center justify-start my-12'>
|
||||
<span class='font-semibold'>CalDav URL:</span>{' '}
|
||||
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/caldav</code>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
{!fileShareId
|
||||
? (
|
||||
<CreateDirectoryModal
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { RequestBody, ResponseBody } from '/routes/api/files/get-share.tsx';
|
||||
import { RequestBody, ResponseBody } from '/pages/api/files/get-share.ts';
|
||||
import { FileShare } from '/lib/types.ts';
|
||||
|
||||
interface ManageShareModalProps {
|
||||
|
|
@ -62,7 +62,7 @@ export default function ManageShareModal(
|
|||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { RequestBody, ResponseBody } from '/routes/api/files/get-directories.tsx';
|
||||
import { RequestBody, ResponseBody } from '/pages/api/files/get-directories.ts';
|
||||
import { Directory } from '/lib/types.ts';
|
||||
|
||||
interface MoveDirectoryOrFileModalProps {
|
||||
|
|
@ -78,7 +78,7 @@ export default function MoveDirectoryOrFileModal(
|
|||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
|
|
@ -123,7 +123,7 @@ export default function MoveDirectoryOrFileModal(
|
|||
{isLoading.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Loading...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Loading...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export default function RenameDirectoryOrFileModal(
|
|||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@ import { useSignal } from '@preact/signals';
|
|||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import { RequestBody, ResponseBody } from '/routes/api/files/search.tsx';
|
||||
interface SearchFilesProps {}
|
||||
import { RequestBody, ResponseBody } from '/pages/api/files/search.ts';
|
||||
|
||||
export default function SearchFiles({}: SearchFilesProps) {
|
||||
export default function SearchFiles() {
|
||||
const isSearching = useSignal<boolean>(false);
|
||||
const areResultsVisible = useSignal<boolean>(false);
|
||||
const matchingDirectories = useSignal<Directory[]>([]);
|
||||
|
|
@ -107,12 +106,12 @@ export default function SearchFiles({}: SearchFilesProps) {
|
|||
onFocus={() => onFocus()}
|
||||
onBlur={() => onBlur()}
|
||||
/>
|
||||
{isSearching.value ? <img src='/images/loading.svg' class='white mr-2' width={18} height={18} /> : null}
|
||||
{isSearching.value ? <img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} /> : null}
|
||||
{areResultsVisible.value
|
||||
? (
|
||||
<section class='relative inline-block text-left ml-2 text-sm'>
|
||||
<section
|
||||
class={`absolute right-0 z-10 mt-2 w-80 origin-top-right rounded-md bg-slate-600 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none overflow-y-scroll max-h-[80%] min-h-56`}
|
||||
class={`absolute right-0 z-10 mt-2 w-80 origin-top-right rounded-md bg-slate-600 shadow-lg ring-1 ring-black/15 focus:outline-none overflow-y-scroll max-h-[80%] min-h-56`}
|
||||
role='menu'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='view-button'
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export default function ShareVerifyForm(
|
|||
{ error }: ShareVerifyFormProps,
|
||||
) {
|
||||
return (
|
||||
<section class='max-w-md w-full mb-12'>
|
||||
<section class='max-w-md w-full mb-12 mx-auto'>
|
||||
<section class='mb-6'>
|
||||
<h2 class='mt-6 text-center text-3xl font-extrabold text-white'>
|
||||
File Share Authentication
|
||||
|
|
|
|||
|
|
@ -1,11 +1,8 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { NewsFeedArticle } from '/lib/types.ts';
|
||||
import {
|
||||
RequestBody as RefreshRequestBody,
|
||||
ResponseBody as RefreshResponseBody,
|
||||
} from '/routes/api/news/refresh-articles.tsx';
|
||||
import { RequestBody as ReadRequestBody, ResponseBody as ReadResponseBody } from '/routes/api/news/mark-read.tsx';
|
||||
import { ResponseBody as RefreshResponseBody } from '/pages/api/news/refresh-articles.ts';
|
||||
import { RequestBody as ReadRequestBody, ResponseBody as ReadResponseBody } from '/pages/api/news/mark-read.ts';
|
||||
|
||||
interface ArticlesProps {
|
||||
initialArticles: NewsFeedArticle[];
|
||||
|
|
@ -15,6 +12,8 @@ interface Filter {
|
|||
status: 'all' | 'unread';
|
||||
}
|
||||
|
||||
let hasFetchedAllArticlesOnce = false;
|
||||
|
||||
export default function Articles({ initialArticles }: ArticlesProps) {
|
||||
const isRefreshing = useSignal<boolean>(false);
|
||||
const articles = useSignal<NewsFeedArticle[]>(initialArticles);
|
||||
|
|
@ -34,10 +33,9 @@ export default function Articles({ initialArticles }: ArticlesProps) {
|
|||
isRefreshing.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: RefreshRequestBody = {};
|
||||
const response = await fetch(`/api/news/refresh-articles`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -148,6 +146,11 @@ export default function Articles({ initialArticles }: ArticlesProps) {
|
|||
function setNewFilter(newFilter: Partial<Filter>) {
|
||||
filter.value = { ...filter.value, ...newFilter };
|
||||
|
||||
if (newFilter.status === 'all' && !hasFetchedAllArticlesOnce) {
|
||||
refreshArticles();
|
||||
hasFetchedAllArticlesOnce = true;
|
||||
}
|
||||
|
||||
isFilterDropdownOpen.value = false;
|
||||
}
|
||||
|
||||
|
|
@ -178,7 +181,7 @@ export default function Articles({ initialArticles }: ArticlesProps) {
|
|||
</div>
|
||||
|
||||
<div
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
||||
!isFilterDropdownOpen.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
|
|
@ -216,7 +219,7 @@ export default function Articles({ initialArticles }: ArticlesProps) {
|
|||
onClick={() => onClickMarkAllRead()}
|
||||
>
|
||||
<img
|
||||
src='/images/check-all.svg'
|
||||
src='/public/images/check-all.svg'
|
||||
alt='Mark all read'
|
||||
class={`white`}
|
||||
width={20}
|
||||
|
|
@ -231,7 +234,7 @@ export default function Articles({ initialArticles }: ArticlesProps) {
|
|||
onClick={() => refreshArticles()}
|
||||
>
|
||||
<img
|
||||
src='/images/refresh.svg'
|
||||
src='/public/images/refresh.svg'
|
||||
alt='Fetch new articles'
|
||||
class={`white ${isRefreshing.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
|
|
@ -251,7 +254,7 @@ export default function Articles({ initialArticles }: ArticlesProps) {
|
|||
class={`group order-first mx-auto max-w-full relative bg-slate-700 duration-150 first:rounded-tl-md first:rounded-tr-md last:rounded-bl-md last:rounded-br-md`}
|
||||
>
|
||||
<summary
|
||||
class={`bg-slate-700 hover:bg-slate-600 px-4 py-4 cursor-pointer flex justify-between group-[:first-child]:rounded-tl-md group-[:first-child]:rounded-tr-md ${
|
||||
class={`bg-slate-700 hover:bg-slate-600 px-4 py-4 cursor-pointer flex justify-between group-first:rounded-tl-md group-first:rounded-tr-md ${
|
||||
article.is_read ? 'opacity-50' : 'font-semibold'
|
||||
}`}
|
||||
onClick={() => onClickView(article.id)}
|
||||
|
|
@ -266,7 +269,7 @@ export default function Articles({ initialArticles }: ArticlesProps) {
|
|||
</article>
|
||||
<a
|
||||
href={article.article_url}
|
||||
class='py-4 px-8 flex justify-between text-right hover:bg-slate-600 group-[:last-child]:rounded-bl-md group-[:last-child]:rounded-br-md'
|
||||
class='py-4 px-8 flex justify-between text-right hover:bg-slate-600 group-last:rounded-bl-md group-last:rounded-br-md'
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
onClick={() => onClickView(article.id)}
|
||||
|
|
@ -1,13 +1,10 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { NewsFeed } from '/lib/types.ts';
|
||||
import { escapeHtml, validateUrl } from '/lib/utils/misc.ts';
|
||||
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/routes/api/news/add-feed.tsx';
|
||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/news/delete-feed.tsx';
|
||||
import {
|
||||
RequestBody as ImportRequestBody,
|
||||
ResponseBody as ImportResponseBody,
|
||||
} from '/routes/api/news/import-feeds.tsx';
|
||||
import { escapeHtml, validateUrl } from '/public/ts/utils/misc.ts';
|
||||
import { RequestBody as AddRequestBody, ResponseBody as AddResponseBody } from '/pages/api/news/add-feed.ts';
|
||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/pages/api/news/delete-feed.ts';
|
||||
import { RequestBody as ImportRequestBody, ResponseBody as ImportResponseBody } from '/pages/api/news/import-feeds.ts';
|
||||
|
||||
interface FeedsProps {
|
||||
initialFeeds: NewsFeed[];
|
||||
|
|
@ -257,7 +254,7 @@ export default function Feeds({ initialFeeds }: FeedsProps) {
|
|||
</div>
|
||||
|
||||
<div
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
||||
!isOptionsDropdownOpen.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
|
|
@ -290,7 +287,7 @@ export default function Feeds({ initialFeeds }: FeedsProps) {
|
|||
onClick={() => onClickAddFeed()}
|
||||
>
|
||||
<img
|
||||
src='/images/add.svg'
|
||||
src='/public/images/add.svg'
|
||||
alt='Add new feed'
|
||||
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
|
|
@ -333,7 +330,7 @@ export default function Feeds({ initialFeeds }: FeedsProps) {
|
|||
onClick={() => onClickDeleteFeed(newsFeed.id)}
|
||||
>
|
||||
<img
|
||||
src='/images/delete.svg'
|
||||
src='/public/images/delete.svg'
|
||||
class='red drop-shadow-md'
|
||||
width={24}
|
||||
height={24}
|
||||
|
|
@ -364,21 +361,21 @@ export default function Feeds({ initialFeeds }: FeedsProps) {
|
|||
{isDeleting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isExporting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isImporting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
|
|
@ -14,7 +14,7 @@ export default function CreateNoteModal(
|
|||
return (
|
||||
<>
|
||||
<section
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900 bg-opacity-60`}
|
||||
class={`fixed ${isOpen ? 'block' : 'hidden'} z-40 w-screen h-screen inset-0 bg-gray-900/60`}
|
||||
>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import { ResponseBody as UploadResponseBody } from '/routes/api/files/upload.tsx';
|
||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/routes/api/files/delete.tsx';
|
||||
import { ResponseBody as UploadResponseBody } from '/pages/api/files/upload.ts';
|
||||
import { RequestBody as DeleteRequestBody, ResponseBody as DeleteResponseBody } from '/pages/api/files/delete.ts';
|
||||
import {
|
||||
RequestBody as CreateDirectoryRequestBody,
|
||||
ResponseBody as CreateDirectoryResponseBody,
|
||||
} from '/routes/api/files/create-directory.tsx';
|
||||
} from '/pages/api/files/create-directory.ts';
|
||||
import {
|
||||
RequestBody as DeleteDirectoryRequestBody,
|
||||
ResponseBody as DeleteDirectoryResponseBody,
|
||||
} from '/routes/api/files/delete-directory.tsx';
|
||||
} from '/pages/api/files/delete-directory.ts';
|
||||
import ListFiles from '/components/files/ListFiles.tsx';
|
||||
import FilesBreadcrumb from '/components/files/FilesBreadcrumb.tsx';
|
||||
import CreateDirectoryModal from '/components/files/CreateDirectoryModal.tsx';
|
||||
|
|
@ -240,7 +240,7 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
|
|||
onClick={() => toggleNewOptionsDropdown()}
|
||||
>
|
||||
<img
|
||||
src='/images/add.svg'
|
||||
src='/public/images/add.svg'
|
||||
alt='Add new note or directory'
|
||||
class={`white ${isAdding.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
|
|
@ -250,7 +250,7 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
|
|||
</div>
|
||||
|
||||
<div
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
||||
!areNewOptionsOption.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
|
|
@ -294,14 +294,14 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
|
|||
{isDeleting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Deleting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isAdding.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Creating...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Creating...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useSignal, useSignalEffect } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { RequestBody, ResponseBody } from '/routes/api/notes/save.tsx';
|
||||
import { RequestBody, ResponseBody } from '/pages/api/notes/save.ts';
|
||||
import FilesBreadcrumb from '/components/files/FilesBreadcrumb.tsx';
|
||||
|
||||
interface NoteProps {
|
||||
|
|
@ -96,14 +96,14 @@ export default function Note({ fileName, currentPath, contents }: NoteProps) {
|
|||
{isSaving.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{hasSaved.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/check.svg' class='green mr-2' width={18} height={18} />Saved!
|
||||
<img src='/public/images/check.svg' class='green mr-2' width={18} height={18} />Saved!
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { DirectoryFile } from '/lib/types.ts';
|
||||
import { PHOTO_IMAGE_EXTENSIONS, PHOTO_VIDEO_EXTENSIONS } from '/lib/utils/photos.ts';
|
||||
import { PHOTO_IMAGE_EXTENSIONS, PHOTO_VIDEO_EXTENSIONS } from '/public/ts/utils/photos.ts';
|
||||
|
||||
interface ListPhotosProps {
|
||||
files: DirectoryFile[];
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import { ResponseBody as UploadResponseBody } from '/routes/api/files/upload.tsx';
|
||||
import { ResponseBody as UploadResponseBody } from '/pages/api/files/upload.ts';
|
||||
import {
|
||||
RequestBody as CreateDirectoryRequestBody,
|
||||
ResponseBody as CreateDirectoryResponseBody,
|
||||
} from '/routes/api/files/create-directory.tsx';
|
||||
} from '/pages/api/files/create-directory.ts';
|
||||
import CreateDirectoryModal from '/components/files/CreateDirectoryModal.tsx';
|
||||
import ListFiles from '/components/files/ListFiles.tsx';
|
||||
import FilesBreadcrumb from '/components/files/FilesBreadcrumb.tsx';
|
||||
|
|
@ -156,7 +156,7 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa
|
|||
onClick={() => toggleNewOptionsDropdown()}
|
||||
>
|
||||
<img
|
||||
src='/images/add.svg'
|
||||
src='/public/images/add.svg'
|
||||
alt='Add new file or directory'
|
||||
class={`white ${isAdding.value || isUploading.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
|
|
@ -166,7 +166,7 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa
|
|||
</div>
|
||||
|
||||
<div
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black ring-opacity-15 focus:outline-none ${
|
||||
class={`absolute right-0 z-10 mt-2 w-44 origin-top-right rounded-md bg-slate-700 shadow-lg ring-1 ring-black/15 focus:outline-none ${
|
||||
!areNewOptionsOption.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
|
|
@ -212,14 +212,14 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa
|
|||
{isAdding.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Creating...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Creating...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isUploading.value
|
||||
? (
|
||||
<>
|
||||
<img src='/images/loading.svg' class='white mr-2' width={18} height={18} />Uploading...
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Uploading...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Cron } from 'https://deno.land/x/croner@8.1.2/dist/croner.js';
|
||||
import { Cron } from '@hexagon/croner';
|
||||
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
import { cleanupSessions } from './sessions.ts';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import { NewsFeed } from '/lib/types.ts';
|
||||
import { concurrentPromises } from '/lib/utils/misc.ts';
|
||||
import { concurrentPromises } from '/public/ts/utils/misc.ts';
|
||||
import { FeedModel } from '/lib/models/news.ts';
|
||||
|
||||
const db = new Database();
|
||||
|
|
|
|||
119
deno.json
119
deno.json
|
|
@ -1,14 +1,19 @@
|
|||
{
|
||||
"lock": false,
|
||||
"lock": true,
|
||||
"tasks": {
|
||||
"check": "deno fmt --check && deno lint && deno check **/*.ts && deno check **/*.tsx",
|
||||
"cli": "echo \"import '\\$fresh/src/dev/cli.ts'\" | deno run --unstable -A -",
|
||||
"manifest": "deno task cli manifest $(pwd)",
|
||||
"start": "deno run -A --watch=static/,routes/,lib/,components/,islands/ dev.ts",
|
||||
"build": "deno run -A dev.ts build",
|
||||
"preview": "deno run -A main.ts",
|
||||
"update": "deno run -A -r https://fresh.deno.dev/update .",
|
||||
"test": "deno test -A --check"
|
||||
"execute-with-permissions": "deno run --allow-env --allow-net --allow-sys=networkInterfaces,hostname,cpus,homedir --allow-read=.,/ --allow-write=data-files,/ --allow-run",
|
||||
"check": "deno fmt --check && deno lint && deno check .",
|
||||
"build": "make build",
|
||||
"preview": "deno task execute-with-permissions ./main.ts",
|
||||
"test": "IS_TESTING=true deno test --allow-all --check",
|
||||
"migrate-db": "deno task execute-with-permissions ./migrate-db.ts",
|
||||
"watch-app": "deno task execute-with-permissions --watch --watch-exclude=./public/css/tailwind.css,./public/components ./main.ts",
|
||||
"watch-babel": "make watch-babel",
|
||||
"watch-tailwind": "make watch-tailwind",
|
||||
"download-frontend-imports": "deno run --allow-read=./deno.json,./public/js --allow-write=./public/js --allow-net=esm.sh ./download-frontend-imports.ts",
|
||||
"start": {
|
||||
"dependencies": ["watch-tailwind", "watch-babel", "watch-app"]
|
||||
}
|
||||
},
|
||||
"fmt": {
|
||||
"useTabs": false,
|
||||
|
|
@ -16,42 +21,78 @@
|
|||
"indentWidth": 2,
|
||||
"singleQuote": true,
|
||||
"proseWrap": "preserve",
|
||||
"exclude": ["README.md"]
|
||||
"exclude": ["README.md", "lib/models/dav.js", "public/css/tailwind.css"]
|
||||
},
|
||||
"lint": {
|
||||
"rules": {
|
||||
"tags": ["fresh", "recommended"],
|
||||
"exclude": ["no-explicit-any", "no-empty-interface", "no-window", "no-unused-vars"]
|
||||
}
|
||||
"tags": ["recommended"],
|
||||
"exclude": ["no-explicit-any", "no-window"]
|
||||
},
|
||||
"exclude": ["lib/models/dav.js"]
|
||||
},
|
||||
"exclude": ["./node_modules/*", "./public/js/*", "./public/components/*"],
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"dom.asynciterable",
|
||||
"deno.ns"
|
||||
]
|
||||
},
|
||||
"exclude": ["./_fresh/*", "./node_modules/*", "**/_fresh/*"],
|
||||
"compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" },
|
||||
"nodeModulesDir": "auto",
|
||||
"imports": {
|
||||
"/": "./",
|
||||
"./": "./",
|
||||
"xml": "https://deno.land/x/xml@2.1.3/mod.ts",
|
||||
"mrmime": "https://deno.land/x/mrmime@v2.0.0/mod.ts",
|
||||
"fresh/": "https://deno.land/x/fresh@1.7.3/",
|
||||
"$fresh/": "https://deno.land/x/fresh@1.7.3/",
|
||||
"std/": "https://deno.land/std@0.224.0/",
|
||||
"$std/": "https://deno.land/std@0.224.0/",
|
||||
"postgres": "https://deno.land/x/postgres@v0.19.3/mod.ts",
|
||||
"preact": "https://esm.sh/preact@10.23.2",
|
||||
"preact/": "https://esm.sh/preact@10.23.2/",
|
||||
"@preact/signals": "https://esm.sh/*@preact/signals@1.3.0",
|
||||
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.8.0",
|
||||
"chart.js": "https://esm.sh/chart.js@4.4.9/auto",
|
||||
"otpauth": "https://esm.sh/otpauth@9.4.0",
|
||||
"qrcode": "https://esm.sh/qrcode@1.5.4",
|
||||
"openid-client": "https://esm.sh/openid-client@6.6.3",
|
||||
"@simplewebauthn/server": "jsr:@simplewebauthn/server@13.1.2",
|
||||
"@simplewebauthn/server/helpers": "jsr:@simplewebauthn/server@13.1.2/helpers",
|
||||
"@simplewebauthn/browser": "jsr:@simplewebauthn/browser@13.1.2",
|
||||
"tailwindcss": "npm:tailwindcss@3.4.17",
|
||||
"tailwindcss/": "npm:/tailwindcss@3.4.17/",
|
||||
"tailwindcss/plugin": "npm:/tailwindcss@3.4.17/plugin.js",
|
||||
"nodemailer": "npm:nodemailer@7.0.5",
|
||||
"tsdav": "https://raw.githubusercontent.com/sunsama/tsdav/cc1c5a09b64c87bbee7e5f171cfcb6748e99469e/dist/tsdav.js"
|
||||
}
|
||||
|
||||
"sass": "https://deno.land/x/denosass@1.0.6/mod.ts",
|
||||
"@libs/xml": "https://deno.land/x/xml@2.1.3/mod.ts",
|
||||
|
||||
"chart.js": "https://esm.sh/chart.js@4.5.1",
|
||||
"chart.js/auto": "https://esm.sh/chart.js@4.5.1/auto",
|
||||
"preact": "https://esm.sh/preact@10.28.4",
|
||||
"preact/jsx-runtime": "https://esm.sh/preact@10.28.4/jsx-runtime",
|
||||
"preact/hooks": "https://esm.sh/preact@10.28.4/hooks",
|
||||
"preact-render-to-string": "https://esm.sh/preact-render-to-string@6.6.5?deps=preact@10.28.4",
|
||||
"@preact/signals": "https://esm.sh/*@preact/signals@2.8.1?deps=preact@10.28.4",
|
||||
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.13.0?deps=preact@10.28.4",
|
||||
"@simplewebauthn/browser": "https://esm.sh/@simplewebauthn/browser@13.2.2",
|
||||
"@std/path": "https://esm.sh/jsr/@std/path@1.1.4",
|
||||
|
||||
"deno/emit": "jsr:@deno/emit@0.46.0",
|
||||
"postgres": "jsr:@db/postgres@0.19.5",
|
||||
"@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@0.1.56",
|
||||
"@hexagon/croner": "jsr:@hexagon/croner@10.0.1",
|
||||
"@mikaelporttila/rss": "jsr:@mikaelporttila/rss@1.1.3",
|
||||
"@libs/qrcode": "jsr:@libs/qrcode@3.0.1",
|
||||
"@simplewebauthn/server": "jsr:@simplewebauthn/server@13.2.2",
|
||||
"@std/assert": "jsr:@std/assert@1.0.18",
|
||||
"@std/dotenv": "jsr:@std/dotenv@0.225.6",
|
||||
"@std/encoding": "jsr:@std/encoding@1.0.10",
|
||||
"@std/http": "jsr:@std/http@1.0.24",
|
||||
|
||||
"jimp": "npm:jimp@1.6.0",
|
||||
"mrmime": "npm:mrmime@2.0.1",
|
||||
"nodemailer": "npm:nodemailer@8.0.1",
|
||||
"openid-client": "npm:openid-client@6.8.2",
|
||||
"otpauth": "npm:otpauth@9.5.0",
|
||||
"tailwindcss": "npm:tailwindcss@4.2.0",
|
||||
"@tailwindcss/cli": "npm:@tailwindcss/cli@4.2.0",
|
||||
"@babel/cli": "npm:@babel/cli@7.28.6",
|
||||
"@babel/core": "npm:@babel/core@7.29.0",
|
||||
"@babel/plugin-transform-react-jsx": "npm:@babel/plugin-transform-react-jsx@7.28.6",
|
||||
"@babel/preset-react": "npm:@babel/preset-react@7.28.5",
|
||||
"@babel/preset-typescript": "npm:@babel/preset-typescript@7.28.5"
|
||||
},
|
||||
"frontendImports": [
|
||||
"chart.js",
|
||||
"chart.js/auto",
|
||||
"preact",
|
||||
"preact/hooks",
|
||||
"@preact/signals",
|
||||
"@preact/signals-core",
|
||||
"@simplewebauthn/browser",
|
||||
"@std/path"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
8
dev.ts
8
dev.ts
|
|
@ -1,8 +0,0 @@
|
|||
#!/usr/bin/env -S deno run -A --watch=static/,routes/,lib/,components/,islands/
|
||||
|
||||
import dev from 'fresh/dev.ts';
|
||||
import config from './fresh.config.ts';
|
||||
|
||||
import 'std/dotenv/load.ts';
|
||||
|
||||
await dev(import.meta.url, './main.ts', config);
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
services:
|
||||
postgresql:
|
||||
image: postgres:17
|
||||
image: postgres:18.1
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=fake
|
||||
- POSTGRES_DB=bewcloud
|
||||
restart: on-failure
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
- pgdata:/var/lib/postgresql/18/docker
|
||||
ports:
|
||||
- 5432:5432
|
||||
ulimits:
|
||||
|
|
@ -18,9 +18,9 @@ services:
|
|||
|
||||
# NOTE: If you don't want to use the CardDav/CalDav servers, you can comment/remove this service.
|
||||
radicale:
|
||||
image: tomsquest/docker-radicale:3.5.4.0
|
||||
image: tomsquest/docker-radicale:3.6.0.0
|
||||
ports:
|
||||
- 127.0.0.1:5232:5232
|
||||
- 5232:5232
|
||||
init: true
|
||||
read_only: true
|
||||
security_opt:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
services:
|
||||
website:
|
||||
image: ghcr.io/bewcloud/bewcloud:v2.4.2
|
||||
image: ghcr.io/bewcloud/bewcloud:v4.1.2
|
||||
# NOTE: uncomment below (and comment above) only if you pulled the repo and want to build the image locally
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8000:8000
|
||||
|
|
@ -13,16 +17,17 @@ services:
|
|||
- ./bewcloud.config.ts:/app/bewcloud.config.ts
|
||||
|
||||
postgresql:
|
||||
image: postgres:17
|
||||
image: postgres:18.1
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=fake
|
||||
- POSTGRES_DB=bewcloud
|
||||
restart: always
|
||||
volumes:
|
||||
- bewcloud-db:/var/lib/postgresql/data
|
||||
ports:
|
||||
- 127.0.0.1:5432:5432
|
||||
- bewcloud-db:/var/lib/postgresql/18/docker
|
||||
# NOTE: uncomment below only if you need to connect to the database from outside the container
|
||||
# ports:
|
||||
# - 127.0.0.1:5432:5432
|
||||
ulimits:
|
||||
memlock:
|
||||
soft: -1
|
||||
|
|
@ -31,9 +36,10 @@ services:
|
|||
|
||||
# NOTE: If you don't want to use the CardDav/CalDav servers, you can comment/remove this service.
|
||||
radicale:
|
||||
image: tomsquest/docker-radicale:3.5.4.0
|
||||
ports:
|
||||
- 127.0.0.1:5232:5232
|
||||
image: tomsquest/docker-radicale:3.6.0.0
|
||||
# NOTE: uncomment below only if you need to connect to the CardDav/CalDav servers from outside the container
|
||||
# ports:
|
||||
# - 127.0.0.1:5232:5232
|
||||
init: true
|
||||
read_only: true
|
||||
security_opt:
|
||||
|
|
|
|||
183
download-frontend-imports.ts
Normal file
183
download-frontend-imports.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import denoConfig from '/deno.json' with { type: 'json' };
|
||||
import { dirname } from '@std/path';
|
||||
|
||||
const downloadDirectory = `${Deno.cwd()}/public/js`;
|
||||
|
||||
function extractFileNameFromUrl(url: string) {
|
||||
let fileName = url.replace('https://esm.sh/', '').split('?')[0].trim();
|
||||
if (!fileName.endsWith('.mjs') && !fileName.includes('color@^0.3.0')) {
|
||||
fileName = `${fileName}.mjs`;
|
||||
}
|
||||
return fileName;
|
||||
}
|
||||
|
||||
async function ensureDirectoryExistsForFileName(fileName: string) {
|
||||
const directory = dirname(fileName);
|
||||
try {
|
||||
const stat = await Deno.stat(directory);
|
||||
if (!stat.isDirectory) {
|
||||
throw new Error(`Directory ${directory} is not a directory`);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as Error).toString().includes('NotFound')) {
|
||||
await Deno.mkdir(directory, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getUrlsToDownload(): Map<string, { url: string; fileName: string }> {
|
||||
const urlMap = new Map<string, { url: string; fileName: string }>();
|
||||
const { imports, frontendImports } = denoConfig;
|
||||
|
||||
for (const importName of frontendImports) {
|
||||
const url = new URL(imports[importName as keyof typeof imports]);
|
||||
// url.searchParams.append('standalone', ''); // This should be easier, but it results in broken bundles (probably because of the missing peerDependencies)
|
||||
|
||||
const finalUrl = url.toString();
|
||||
|
||||
urlMap.set(importName, { url: finalUrl, fileName: extractFileNameFromUrl(finalUrl) });
|
||||
}
|
||||
|
||||
return urlMap;
|
||||
}
|
||||
|
||||
async function downloadUrls(urlMap: Map<string, { url: string; fileName: string }>) {
|
||||
for (const { url, fileName } of urlMap.values()) {
|
||||
console.log(`Fetching source file ${fileName} (${url})...`);
|
||||
const response = await fetch(url);
|
||||
const sourceContent = await response.text();
|
||||
|
||||
const sourceLines = sourceContent.split('\n');
|
||||
for (const sourceLine of sourceLines) {
|
||||
const dependencyUrlsMap = new Map<string, { url: string; fileName: string }>();
|
||||
const importUrlInSourceContent = sourceLine.split('import "')[1]?.split('"')[0];
|
||||
const exportUrlInSourceContent = sourceLine.split('export * from "')[1]?.split('"')[0];
|
||||
const defaultExportUrlInSourceContent = sourceLine.split('export { default } from "')[1]?.split('"')[0];
|
||||
|
||||
if (importUrlInSourceContent) {
|
||||
const importUrl = new URL(`https://esm.sh${importUrlInSourceContent}`).toString();
|
||||
dependencyUrlsMap.set(importUrlInSourceContent, {
|
||||
url: importUrl,
|
||||
fileName: extractFileNameFromUrl(importUrl),
|
||||
});
|
||||
}
|
||||
|
||||
if (exportUrlInSourceContent) {
|
||||
const exportUrl = new URL(`https://esm.sh${exportUrlInSourceContent}`).toString();
|
||||
dependencyUrlsMap.set(exportUrlInSourceContent, {
|
||||
url: exportUrl,
|
||||
fileName: extractFileNameFromUrl(exportUrl),
|
||||
});
|
||||
}
|
||||
|
||||
if (defaultExportUrlInSourceContent) {
|
||||
const defaultExportUrl = new URL(`https://esm.sh${defaultExportUrlInSourceContent}`).toString();
|
||||
dependencyUrlsMap.set(defaultExportUrlInSourceContent, {
|
||||
url: defaultExportUrl,
|
||||
fileName: extractFileNameFromUrl(defaultExportUrl),
|
||||
});
|
||||
}
|
||||
|
||||
await downloadUrls(dependencyUrlsMap);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Downloading bundle file ${fileName} (${url})...`);
|
||||
|
||||
const bundleResponse = await fetch(url);
|
||||
let bundleFileContent = await bundleResponse.text();
|
||||
|
||||
// Update absolute import paths
|
||||
bundleFileContent = bundleFileContent.replaceAll('import "/', 'import "/public/js/');
|
||||
bundleFileContent = bundleFileContent.replaceAll('import"/', 'import"/public/js/');
|
||||
// Update absolute export paths
|
||||
bundleFileContent = bundleFileContent.replaceAll('export * from "/', 'export * from "/public/js/');
|
||||
bundleFileContent = bundleFileContent.replaceAll('from"/', 'from"/public/js/'); // minified files
|
||||
// Update absolute default export paths
|
||||
bundleFileContent = bundleFileContent.replaceAll(
|
||||
'export { default } from "/',
|
||||
'export { default } from "/public/js/',
|
||||
);
|
||||
// Remove sourcemap URLs (they're not downloaded)
|
||||
bundleFileContent = bundleFileContent.replaceAll('//# sourceMappingURL=', '//');
|
||||
|
||||
const fullFilePath = `${downloadDirectory}/${fileName}`;
|
||||
|
||||
console.log(`Writing bundle file ${fileName} (${fullFilePath})...`);
|
||||
|
||||
await ensureDirectoryExistsForFileName(fullFilePath);
|
||||
|
||||
await Deno.writeTextFile(fullFilePath, bundleFileContent);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const urlMap = getUrlsToDownload();
|
||||
|
||||
// These are some extra imports that are harder to match from analyzing the imported files (just related to @std/path)
|
||||
urlMap.set('@std/path/internals', {
|
||||
url: 'https://esm.sh/@jsr/std__internal@^1.0.12/os',
|
||||
fileName: '@jsr/std__internal@^1.0.12/os',
|
||||
});
|
||||
|
||||
const osPaths = [
|
||||
'basename',
|
||||
'constants',
|
||||
'dirname',
|
||||
'extname',
|
||||
'format',
|
||||
'from-file-url',
|
||||
'glob-to-regexp',
|
||||
'is-absolute',
|
||||
'join-globs',
|
||||
'join',
|
||||
'normalize-glob',
|
||||
'normalize',
|
||||
'parse',
|
||||
'relative',
|
||||
'resolve',
|
||||
'to-file-url',
|
||||
'to-namespaced-path',
|
||||
'_util',
|
||||
];
|
||||
for (const path of osPaths) {
|
||||
urlMap.set(`@std/path/posix/${path}`, {
|
||||
url: `https://esm.sh/@jsr/std__path@1.1.4/denonext/posix/${path}.mjs`,
|
||||
fileName: `@jsr/std__path@1.1.4/denonext/posix/${path}.mjs`,
|
||||
});
|
||||
urlMap.set(`@std/path/windows/${path}`, {
|
||||
url: `https://esm.sh/@jsr/std__path@1.1.4/denonext/windows/${path}.mjs`,
|
||||
fileName: `@jsr/std__path@1.1.4/denonext/windows/${path}.mjs`,
|
||||
});
|
||||
}
|
||||
|
||||
const commonPaths = [
|
||||
'common',
|
||||
'basename',
|
||||
'assert_path',
|
||||
'strip_trailing_separators',
|
||||
'dirname',
|
||||
'constants',
|
||||
'format',
|
||||
'from_file_url',
|
||||
'glob_to_reg_exp',
|
||||
'normalize',
|
||||
'normalize_string',
|
||||
'relative',
|
||||
'to_file_url',
|
||||
];
|
||||
for (const path of commonPaths) {
|
||||
urlMap.set(`@std/path/common/${path}`, {
|
||||
url: `https://esm.sh/@jsr/std__path@1.1.4/denonext/_common/${path}.mjs`,
|
||||
fileName: `@jsr/std__path@1.1.4/denonext/_common/${path}.mjs`,
|
||||
});
|
||||
}
|
||||
|
||||
await downloadUrls(urlMap);
|
||||
|
||||
console.log('Done');
|
||||
Deno.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { defineConfig } from 'fresh/server.ts';
|
||||
import tailwind from 'fresh/plugins/tailwind.ts';
|
||||
|
||||
import { startCrons } from '/crons/index.ts';
|
||||
|
||||
const isBuildMode = Deno.args.includes('build');
|
||||
|
||||
if (!isBuildMode) {
|
||||
await startCrons();
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwind()],
|
||||
});
|
||||
197
fresh.gen.ts
197
fresh.gen.ts
|
|
@ -1,197 +0,0 @@
|
|||
// DO NOT EDIT. This file is generated by Fresh.
|
||||
// This file SHOULD be checked into source version control.
|
||||
// This file is automatically updated during development when running `dev.ts`.
|
||||
|
||||
import * as $_well_known_caldav from './routes/.well-known/caldav.tsx';
|
||||
import * as $_well_known_carddav from './routes/.well-known/carddav.tsx';
|
||||
import * as $_404 from './routes/_404.tsx';
|
||||
import * as $_app from './routes/_app.tsx';
|
||||
import * as $_middleware from './routes/_middleware.tsx';
|
||||
import * as $api_auth_multi_factor_disable from './routes/api/auth/multi-factor/disable.ts';
|
||||
import * as $api_auth_multi_factor_email_setup from './routes/api/auth/multi-factor/email/setup.ts';
|
||||
import * as $api_auth_multi_factor_enable from './routes/api/auth/multi-factor/enable.ts';
|
||||
import * as $api_auth_multi_factor_passkey_begin from './routes/api/auth/multi-factor/passkey/begin.ts';
|
||||
import * as $api_auth_multi_factor_passkey_setup_begin from './routes/api/auth/multi-factor/passkey/setup-begin.ts';
|
||||
import * as $api_auth_multi_factor_passkey_setup_complete from './routes/api/auth/multi-factor/passkey/setup-complete.ts';
|
||||
import * as $api_auth_multi_factor_passkey_verify from './routes/api/auth/multi-factor/passkey/verify.ts';
|
||||
import * as $api_auth_multi_factor_totp_setup from './routes/api/auth/multi-factor/totp/setup.ts';
|
||||
import * as $api_contacts_add_addressbook from './routes/api/contacts/add-addressbook.tsx';
|
||||
import * as $api_contacts_add from './routes/api/contacts/add.tsx';
|
||||
import * as $api_contacts_delete_addressbook from './routes/api/contacts/delete-addressbook.tsx';
|
||||
import * as $api_contacts_delete from './routes/api/contacts/delete.tsx';
|
||||
import * as $api_contacts_get_addressbooks from './routes/api/contacts/get-addressbooks.tsx';
|
||||
import * as $api_contacts_get from './routes/api/contacts/get.tsx';
|
||||
import * as $api_contacts_import from './routes/api/contacts/import.tsx';
|
||||
import * as $api_dashboard_save_links from './routes/api/dashboard/save-links.tsx';
|
||||
import * as $api_dashboard_save_notes from './routes/api/dashboard/save-notes.tsx';
|
||||
import * as $api_expenses_add_budget from './routes/api/expenses/add-budget.tsx';
|
||||
import * as $api_expenses_add_expense from './routes/api/expenses/add-expense.tsx';
|
||||
import * as $api_expenses_auto_complete from './routes/api/expenses/auto-complete.tsx';
|
||||
import * as $api_expenses_delete_budget from './routes/api/expenses/delete-budget.tsx';
|
||||
import * as $api_expenses_delete_expense from './routes/api/expenses/delete-expense.tsx';
|
||||
import * as $api_expenses_export_expenses from './routes/api/expenses/export-expenses.tsx';
|
||||
import * as $api_expenses_import_expenses from './routes/api/expenses/import-expenses.tsx';
|
||||
import * as $api_expenses_update_budget from './routes/api/expenses/update-budget.tsx';
|
||||
import * as $api_expenses_update_expense from './routes/api/expenses/update-expense.tsx';
|
||||
import * as $api_files_create_directory from './routes/api/files/create-directory.tsx';
|
||||
import * as $api_files_create_share from './routes/api/files/create-share.tsx';
|
||||
import * as $api_files_delete_directory from './routes/api/files/delete-directory.tsx';
|
||||
import * as $api_files_delete_share from './routes/api/files/delete-share.tsx';
|
||||
import * as $api_files_delete from './routes/api/files/delete.tsx';
|
||||
import * as $api_files_get_directories from './routes/api/files/get-directories.tsx';
|
||||
import * as $api_files_get_share from './routes/api/files/get-share.tsx';
|
||||
import * as $api_files_get from './routes/api/files/get.tsx';
|
||||
import * as $api_files_move_directory from './routes/api/files/move-directory.tsx';
|
||||
import * as $api_files_move from './routes/api/files/move.tsx';
|
||||
import * as $api_files_rename_directory from './routes/api/files/rename-directory.tsx';
|
||||
import * as $api_files_rename from './routes/api/files/rename.tsx';
|
||||
import * as $api_files_search from './routes/api/files/search.tsx';
|
||||
import * as $api_files_update_share from './routes/api/files/update-share.tsx';
|
||||
import * as $api_files_upload from './routes/api/files/upload.tsx';
|
||||
import * as $api_news_add_feed from './routes/api/news/add-feed.tsx';
|
||||
import * as $api_news_delete_feed from './routes/api/news/delete-feed.tsx';
|
||||
import * as $api_news_import_feeds from './routes/api/news/import-feeds.tsx';
|
||||
import * as $api_news_mark_read from './routes/api/news/mark-read.tsx';
|
||||
import * as $api_news_refresh_articles from './routes/api/news/refresh-articles.tsx';
|
||||
import * as $api_notes_save from './routes/api/notes/save.tsx';
|
||||
import * as $caldav from './routes/caldav.tsx';
|
||||
import * as $carddav from './routes/carddav.tsx';
|
||||
import * as $contacts from './routes/contacts.tsx';
|
||||
import * as $contacts_contactId_ from './routes/contacts/[contactId].tsx';
|
||||
import * as $dashboard from './routes/dashboard.tsx';
|
||||
import * as $dav from './routes/dav.tsx';
|
||||
import * as $expenses from './routes/expenses.tsx';
|
||||
import * as $file_share_fileShareId_ from './routes/file-share/[fileShareId].tsx';
|
||||
import * as $file_share_fileShareId_open_fileName_ from './routes/file-share/[fileShareId]/open/[fileName].tsx';
|
||||
import * as $file_share_fileShareId_verify from './routes/file-share/[fileShareId]/verify.tsx';
|
||||
import * as $files from './routes/files.tsx';
|
||||
import * as $files_open_fileName_ from './routes/files/open/[fileName].tsx';
|
||||
import * as $index from './routes/index.tsx';
|
||||
import * as $login from './routes/login.tsx';
|
||||
import * as $logout from './routes/logout.tsx';
|
||||
import * as $mfa_verify from './routes/mfa-verify.tsx';
|
||||
import * as $news from './routes/news.tsx';
|
||||
import * as $news_feeds from './routes/news/feeds.tsx';
|
||||
import * as $notes from './routes/notes.tsx';
|
||||
import * as $notes_open_fileName_ from './routes/notes/open/[fileName].tsx';
|
||||
import * as $oidc_callback from './routes/oidc/callback.tsx';
|
||||
import * as $photos from './routes/photos.tsx';
|
||||
import * as $photos_thumbnail_fileName_ from './routes/photos/thumbnail/[fileName].tsx';
|
||||
import * as $settings from './routes/settings.tsx';
|
||||
import * as $signup from './routes/signup.tsx';
|
||||
import * as $Settings from './islands/Settings.tsx';
|
||||
import * as $auth_MultiFactorAuthSettings from './islands/auth/MultiFactorAuthSettings.tsx';
|
||||
import * as $auth_PasswordlessPasskeyLogin from './islands/auth/PasswordlessPasskeyLogin.tsx';
|
||||
import * as $contacts_Contacts from './islands/contacts/Contacts.tsx';
|
||||
import * as $contacts_ViewContact from './islands/contacts/ViewContact.tsx';
|
||||
import * as $dashboard_Links from './islands/dashboard/Links.tsx';
|
||||
import * as $dashboard_Notes from './islands/dashboard/Notes.tsx';
|
||||
import * as $expenses_ExpensesWrapper from './islands/expenses/ExpensesWrapper.tsx';
|
||||
import * as $files_FilesWrapper from './islands/files/FilesWrapper.tsx';
|
||||
import * as $news_Articles from './islands/news/Articles.tsx';
|
||||
import * as $news_Feeds from './islands/news/Feeds.tsx';
|
||||
import * as $notes_Note from './islands/notes/Note.tsx';
|
||||
import * as $notes_NotesWrapper from './islands/notes/NotesWrapper.tsx';
|
||||
import * as $photos_PhotosWrapper from './islands/photos/PhotosWrapper.tsx';
|
||||
import type { Manifest } from '$fresh/server.ts';
|
||||
|
||||
const manifest = {
|
||||
routes: {
|
||||
'./routes/.well-known/caldav.tsx': $_well_known_caldav,
|
||||
'./routes/.well-known/carddav.tsx': $_well_known_carddav,
|
||||
'./routes/_404.tsx': $_404,
|
||||
'./routes/_app.tsx': $_app,
|
||||
'./routes/_middleware.tsx': $_middleware,
|
||||
'./routes/api/auth/multi-factor/disable.ts': $api_auth_multi_factor_disable,
|
||||
'./routes/api/auth/multi-factor/email/setup.ts': $api_auth_multi_factor_email_setup,
|
||||
'./routes/api/auth/multi-factor/enable.ts': $api_auth_multi_factor_enable,
|
||||
'./routes/api/auth/multi-factor/passkey/begin.ts': $api_auth_multi_factor_passkey_begin,
|
||||
'./routes/api/auth/multi-factor/passkey/setup-begin.ts': $api_auth_multi_factor_passkey_setup_begin,
|
||||
'./routes/api/auth/multi-factor/passkey/setup-complete.ts': $api_auth_multi_factor_passkey_setup_complete,
|
||||
'./routes/api/auth/multi-factor/passkey/verify.ts': $api_auth_multi_factor_passkey_verify,
|
||||
'./routes/api/auth/multi-factor/totp/setup.ts': $api_auth_multi_factor_totp_setup,
|
||||
'./routes/api/contacts/add-addressbook.tsx': $api_contacts_add_addressbook,
|
||||
'./routes/api/contacts/add.tsx': $api_contacts_add,
|
||||
'./routes/api/contacts/delete-addressbook.tsx': $api_contacts_delete_addressbook,
|
||||
'./routes/api/contacts/delete.tsx': $api_contacts_delete,
|
||||
'./routes/api/contacts/get-addressbooks.tsx': $api_contacts_get_addressbooks,
|
||||
'./routes/api/contacts/get.tsx': $api_contacts_get,
|
||||
'./routes/api/contacts/import.tsx': $api_contacts_import,
|
||||
'./routes/api/dashboard/save-links.tsx': $api_dashboard_save_links,
|
||||
'./routes/api/dashboard/save-notes.tsx': $api_dashboard_save_notes,
|
||||
'./routes/api/expenses/add-budget.tsx': $api_expenses_add_budget,
|
||||
'./routes/api/expenses/add-expense.tsx': $api_expenses_add_expense,
|
||||
'./routes/api/expenses/auto-complete.tsx': $api_expenses_auto_complete,
|
||||
'./routes/api/expenses/delete-budget.tsx': $api_expenses_delete_budget,
|
||||
'./routes/api/expenses/delete-expense.tsx': $api_expenses_delete_expense,
|
||||
'./routes/api/expenses/export-expenses.tsx': $api_expenses_export_expenses,
|
||||
'./routes/api/expenses/import-expenses.tsx': $api_expenses_import_expenses,
|
||||
'./routes/api/expenses/update-budget.tsx': $api_expenses_update_budget,
|
||||
'./routes/api/expenses/update-expense.tsx': $api_expenses_update_expense,
|
||||
'./routes/api/files/create-directory.tsx': $api_files_create_directory,
|
||||
'./routes/api/files/create-share.tsx': $api_files_create_share,
|
||||
'./routes/api/files/delete-directory.tsx': $api_files_delete_directory,
|
||||
'./routes/api/files/delete-share.tsx': $api_files_delete_share,
|
||||
'./routes/api/files/delete.tsx': $api_files_delete,
|
||||
'./routes/api/files/get-directories.tsx': $api_files_get_directories,
|
||||
'./routes/api/files/get-share.tsx': $api_files_get_share,
|
||||
'./routes/api/files/get.tsx': $api_files_get,
|
||||
'./routes/api/files/move-directory.tsx': $api_files_move_directory,
|
||||
'./routes/api/files/move.tsx': $api_files_move,
|
||||
'./routes/api/files/rename-directory.tsx': $api_files_rename_directory,
|
||||
'./routes/api/files/rename.tsx': $api_files_rename,
|
||||
'./routes/api/files/search.tsx': $api_files_search,
|
||||
'./routes/api/files/update-share.tsx': $api_files_update_share,
|
||||
'./routes/api/files/upload.tsx': $api_files_upload,
|
||||
'./routes/api/news/add-feed.tsx': $api_news_add_feed,
|
||||
'./routes/api/news/delete-feed.tsx': $api_news_delete_feed,
|
||||
'./routes/api/news/import-feeds.tsx': $api_news_import_feeds,
|
||||
'./routes/api/news/mark-read.tsx': $api_news_mark_read,
|
||||
'./routes/api/news/refresh-articles.tsx': $api_news_refresh_articles,
|
||||
'./routes/api/notes/save.tsx': $api_notes_save,
|
||||
'./routes/caldav.tsx': $caldav,
|
||||
'./routes/carddav.tsx': $carddav,
|
||||
'./routes/contacts.tsx': $contacts,
|
||||
'./routes/contacts/[contactId].tsx': $contacts_contactId_,
|
||||
'./routes/dashboard.tsx': $dashboard,
|
||||
'./routes/dav.tsx': $dav,
|
||||
'./routes/expenses.tsx': $expenses,
|
||||
'./routes/file-share/[fileShareId].tsx': $file_share_fileShareId_,
|
||||
'./routes/file-share/[fileShareId]/open/[fileName].tsx': $file_share_fileShareId_open_fileName_,
|
||||
'./routes/file-share/[fileShareId]/verify.tsx': $file_share_fileShareId_verify,
|
||||
'./routes/files.tsx': $files,
|
||||
'./routes/files/open/[fileName].tsx': $files_open_fileName_,
|
||||
'./routes/index.tsx': $index,
|
||||
'./routes/login.tsx': $login,
|
||||
'./routes/logout.tsx': $logout,
|
||||
'./routes/mfa-verify.tsx': $mfa_verify,
|
||||
'./routes/news.tsx': $news,
|
||||
'./routes/news/feeds.tsx': $news_feeds,
|
||||
'./routes/notes.tsx': $notes,
|
||||
'./routes/notes/open/[fileName].tsx': $notes_open_fileName_,
|
||||
'./routes/oidc/callback.tsx': $oidc_callback,
|
||||
'./routes/photos.tsx': $photos,
|
||||
'./routes/photos/thumbnail/[fileName].tsx': $photos_thumbnail_fileName_,
|
||||
'./routes/settings.tsx': $settings,
|
||||
'./routes/signup.tsx': $signup,
|
||||
},
|
||||
islands: {
|
||||
'./islands/Settings.tsx': $Settings,
|
||||
'./islands/auth/MultiFactorAuthSettings.tsx': $auth_MultiFactorAuthSettings,
|
||||
'./islands/auth/PasswordlessPasskeyLogin.tsx': $auth_PasswordlessPasskeyLogin,
|
||||
'./islands/contacts/Contacts.tsx': $contacts_Contacts,
|
||||
'./islands/contacts/ViewContact.tsx': $contacts_ViewContact,
|
||||
'./islands/dashboard/Links.tsx': $dashboard_Links,
|
||||
'./islands/dashboard/Notes.tsx': $dashboard_Notes,
|
||||
'./islands/expenses/ExpensesWrapper.tsx': $expenses_ExpensesWrapper,
|
||||
'./islands/files/FilesWrapper.tsx': $files_FilesWrapper,
|
||||
'./islands/news/Articles.tsx': $news_Articles,
|
||||
'./islands/news/Feeds.tsx': $news_Feeds,
|
||||
'./islands/notes/Note.tsx': $notes_Note,
|
||||
'./islands/notes/NotesWrapper.tsx': $notes_NotesWrapper,
|
||||
'./islands/photos/PhotosWrapper.tsx': $photos_PhotosWrapper,
|
||||
},
|
||||
baseUrl: import.meta.url,
|
||||
} satisfies Manifest;
|
||||
|
||||
export default manifest;
|
||||
|
|
@ -1,247 +0,0 @@
|
|||
import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx';
|
||||
import { convertObjectToFormData } from '/lib/utils/misc.ts';
|
||||
import { currencyMap, SupportedCurrencySymbol, User } from '/lib/types.ts';
|
||||
import MultiFactorAuthSettings from '/islands/auth/MultiFactorAuthSettings.tsx';
|
||||
import { getEnabledMultiFactorAuthMethodsFromUser } from '/lib/utils/multi-factor-auth.ts';
|
||||
|
||||
interface SettingsProps {
|
||||
formData: Record<string, any>;
|
||||
error?: {
|
||||
title: string;
|
||||
message: string;
|
||||
};
|
||||
notice?: {
|
||||
title: string;
|
||||
message: string;
|
||||
};
|
||||
currency?: SupportedCurrencySymbol;
|
||||
isExpensesAppEnabled: boolean;
|
||||
isMultiFactorAuthEnabled: boolean;
|
||||
helpEmail: string;
|
||||
user: {
|
||||
extra: Pick<User['extra'], 'multi_factor_auth_methods'>;
|
||||
};
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| 'change-email'
|
||||
| 'verify-change-email'
|
||||
| 'change-password'
|
||||
| 'change-dav-password'
|
||||
| 'delete-account'
|
||||
| 'change-currency';
|
||||
|
||||
export const actionWords = new Map<Action, string>([
|
||||
['change-email', 'change email'],
|
||||
['verify-change-email', 'change email'],
|
||||
['change-password', 'change password'],
|
||||
['change-dav-password', 'change WebDav password'],
|
||||
['delete-account', 'delete account'],
|
||||
['change-currency', 'change currency'],
|
||||
]);
|
||||
|
||||
function formFields(action: Action, formData: FormData, currency?: SupportedCurrencySymbol) {
|
||||
const fields: FormField[] = [
|
||||
{
|
||||
name: 'action',
|
||||
label: '',
|
||||
type: 'hidden',
|
||||
value: action,
|
||||
overrideValue: action,
|
||||
required: true,
|
||||
readOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (action === 'change-email') {
|
||||
fields.push({
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
type: 'email',
|
||||
placeholder: 'jane.doe@example.com',
|
||||
value: getFormDataField(formData, 'email'),
|
||||
required: true,
|
||||
});
|
||||
} else if (action === 'verify-change-email') {
|
||||
fields.push({
|
||||
name: 'email',
|
||||
label: 'Email',
|
||||
type: 'email',
|
||||
placeholder: 'jane.doe@example.com',
|
||||
value: getFormDataField(formData, 'email'),
|
||||
required: true,
|
||||
}, {
|
||||
name: 'verification-code',
|
||||
label: 'Verification Code',
|
||||
description: `The verification code to validate your new email.`,
|
||||
type: 'text',
|
||||
placeholder: '000000',
|
||||
required: true,
|
||||
});
|
||||
} else if (action === 'change-password') {
|
||||
fields.push({
|
||||
name: 'current-password',
|
||||
label: 'Current Password',
|
||||
type: 'password',
|
||||
placeholder: 'super-SECRET-passphrase',
|
||||
required: true,
|
||||
}, {
|
||||
name: 'new-password',
|
||||
label: 'New Password',
|
||||
type: 'password',
|
||||
placeholder: 'super-SECRET-passphrase',
|
||||
required: true,
|
||||
});
|
||||
} else if (action === 'change-dav-password') {
|
||||
fields.push({
|
||||
name: 'new-dav-password',
|
||||
label: 'New WebDav Password',
|
||||
type: 'password',
|
||||
placeholder: 'super-SECRET-passphrase',
|
||||
required: true,
|
||||
description: 'Alternative password used for WebDav access and/or HTTP Basic Auth.',
|
||||
});
|
||||
} else if (action === 'delete-account') {
|
||||
fields.push({
|
||||
name: 'current-password',
|
||||
label: 'Password',
|
||||
type: 'password',
|
||||
placeholder: 'super-SECRET-passphrase',
|
||||
description: 'You need to input your password in order to delete your account.',
|
||||
required: true,
|
||||
});
|
||||
} else if (action === 'change-currency') {
|
||||
fields.push({
|
||||
name: 'currency',
|
||||
label: 'Currency',
|
||||
type: 'select',
|
||||
options: Array.from(currencyMap.keys()).map((currencySymbol) => ({
|
||||
value: currencySymbol,
|
||||
label: `${currencySymbol} (${currencyMap.get(currencySymbol)})`,
|
||||
})),
|
||||
value: getFormDataField(formData, 'currency') || currency,
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
export default function Settings(
|
||||
{
|
||||
formData: formDataObject,
|
||||
error,
|
||||
notice,
|
||||
currency,
|
||||
isExpensesAppEnabled,
|
||||
isMultiFactorAuthEnabled,
|
||||
helpEmail,
|
||||
user,
|
||||
}: SettingsProps,
|
||||
) {
|
||||
const formData = convertObjectToFormData(formDataObject);
|
||||
|
||||
const multiFactorAuthMethods = getEnabledMultiFactorAuthMethodsFromUser(user);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section class='mx-auto max-w-7xl my-8'>
|
||||
{error
|
||||
? (
|
||||
<section class='notification-error'>
|
||||
<h3>{error.title}</h3>
|
||||
<p>{error.message}</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
{notice
|
||||
? (
|
||||
<section class='notification-success'>
|
||||
<h3>{notice.title}</h3>
|
||||
<p>{notice.message}</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your email</h2>
|
||||
|
||||
<form method='POST' class='mb-12'>
|
||||
{formFields(
|
||||
'change-email',
|
||||
formData,
|
||||
).map((field) => generateFieldHtml(field, formData))}
|
||||
<section class='flex justify-end mt-8 mb-4'>
|
||||
<button class='button-secondary' type='submit'>Change email</button>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your password</h2>
|
||||
|
||||
<form method='POST' class='mb-12'>
|
||||
{formFields('change-password', formData).map((field) => generateFieldHtml(field, formData))}
|
||||
<section class='flex justify-end mt-8 mb-4'>
|
||||
<button class='button-secondary' type='submit'>Change password</button>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your WebDav password</h2>
|
||||
|
||||
<form method='POST' class='mb-12'>
|
||||
{formFields('change-dav-password', formData).map((field) => generateFieldHtml(field, formData))}
|
||||
<section class='flex justify-end mt-8 mb-4'>
|
||||
<button class='button-secondary' type='submit'>Change WebDav password</button>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
{isExpensesAppEnabled
|
||||
? (
|
||||
<>
|
||||
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Change your currency</h2>
|
||||
<p class='text-left mt-2 mb-6 px-4 max-w-screen-md mx-auto lg:min-w-96'>
|
||||
This is only used in the expenses app, visually. It changes nothing about the stored data or values.
|
||||
</p>
|
||||
|
||||
<form method='POST' class='mb-12'>
|
||||
{formFields('change-currency', formData, currency).map((field) => generateFieldHtml(field, formData))}
|
||||
<section class='flex justify-end mt-8 mb-4'>
|
||||
<button class='button-secondary' type='submit'>Change currency</button>
|
||||
</section>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
|
||||
{isMultiFactorAuthEnabled
|
||||
? (
|
||||
<MultiFactorAuthSettings
|
||||
methods={multiFactorAuthMethods.map((method) => ({
|
||||
type: method.type,
|
||||
id: method.id,
|
||||
name: method.name,
|
||||
enabled: method.enabled,
|
||||
backupCodesCount: method.metadata.totp?.hashed_backup_codes?.length,
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
<h2 class='text-2xl mb-4 text-left px-4 max-w-screen-md mx-auto lg:min-w-96'>Delete your account</h2>
|
||||
<p class='text-left mt-2 mb-6 px-4 max-w-screen-md mx-auto lg:min-w-96'>
|
||||
Deleting your account is instant and deletes all your data. {helpEmail !== ''
|
||||
? (
|
||||
<>
|
||||
If you need help, please <a href={`mailto:${helpEmail}`}>reach out</a>.
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
</p>
|
||||
|
||||
<form method='POST' class='mb-12'>
|
||||
{formFields('delete-account', formData).map((field) => generateFieldHtml(field, formData))}
|
||||
<section class='flex justify-end mt-8 mb-4'>
|
||||
<button class='button-danger' type='submit'>Delete account</button>
|
||||
</section>
|
||||
</form>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
|
||||
import {
|
||||
RequestBody as PasskeyBeginRequestBody,
|
||||
ResponseBody as PasskeyBeginResponseBody,
|
||||
} from '/routes/api/auth/multi-factor/passkey/begin.ts';
|
||||
|
||||
import {
|
||||
RequestBody as PasskeyVerifyRequestBody,
|
||||
ResponseBody as PasskeyVerifyResponseBody,
|
||||
} from '/routes/api/auth/multi-factor/passkey/verify.ts';
|
||||
|
||||
interface PasswordlessPasskeyLoginProps {
|
||||
email?: string;
|
||||
redirectUrl?: string;
|
||||
}
|
||||
|
||||
export default function PasswordlessPasskeyLogin({ email: providedEmail, redirectUrl }: PasswordlessPasskeyLoginProps) {
|
||||
const isLoading = useSignal(false);
|
||||
const email = useSignal<string | null>(providedEmail || null);
|
||||
const error = useSignal<string | null>(null);
|
||||
|
||||
const handlePasswordlessLogin = async () => {
|
||||
if (isLoading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!email.value) {
|
||||
const promptEmail = prompt('Please enter your email');
|
||||
if (!promptEmail) {
|
||||
throw new Error('Email is required to login with Passkey');
|
||||
}
|
||||
email.value = promptEmail;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const beginRequestBody: PasskeyBeginRequestBody = {
|
||||
email: email.value,
|
||||
};
|
||||
|
||||
const beginResponse = await fetch('/api/auth/multi-factor/passkey/begin', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(beginRequestBody),
|
||||
});
|
||||
|
||||
if (!beginResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to begin passwordless login! ${beginResponse.statusText} ${await beginResponse.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const beginData = await beginResponse.json() as PasskeyBeginResponseBody;
|
||||
|
||||
if (!beginData.success) {
|
||||
throw new Error(beginData.error || 'Failed to begin passwordless login');
|
||||
}
|
||||
|
||||
const authenticationResponse = await startAuthentication({
|
||||
optionsJSON: beginData.options!,
|
||||
});
|
||||
|
||||
const verifyRequestBody: PasskeyVerifyRequestBody = {
|
||||
email: email.value,
|
||||
challenge: beginData.sessionData!.challenge,
|
||||
authenticationResponse,
|
||||
redirectUrl: redirectUrl || '/',
|
||||
};
|
||||
|
||||
const verifyResponse = await fetch('/api/auth/multi-factor/passkey/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(verifyRequestBody),
|
||||
});
|
||||
|
||||
if (verifyResponse.ok) {
|
||||
window.location.href = redirectUrl || '/';
|
||||
return;
|
||||
}
|
||||
|
||||
const verifyData = await verifyResponse.json() as PasskeyVerifyResponseBody;
|
||||
throw new Error(
|
||||
verifyData.error || `Authentication failed! ${verifyResponse.statusText} ${await verifyResponse.text()}`,
|
||||
);
|
||||
} catch (handleError) {
|
||||
console.error('Passwordless passkey login failed:', handleError);
|
||||
error.value = (handleError as Error).message;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section class='space-y-4'>
|
||||
<section class='flex justify-center mt-2 mb-4'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={handlePasswordlessLogin}
|
||||
class='button-secondary'
|
||||
>
|
||||
{isLoading.value ? 'Authenticating...' : 'Login with Passkey'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{error.value
|
||||
? (
|
||||
<section class='notification-error'>
|
||||
<p>{error.value}</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { Budget, Expense, SupportedCurrencySymbol } from '/lib/types.ts';
|
||||
import MainExpenses from '/components/expenses/MainExpenses.tsx';
|
||||
|
||||
interface ExpensesWrapperProps {
|
||||
initialBudgets: Budget[];
|
||||
initialExpenses: Expense[];
|
||||
initialMonth: string;
|
||||
currency: SupportedCurrencySymbol;
|
||||
}
|
||||
|
||||
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
|
||||
export default function ExpensesWrapper(
|
||||
{ initialBudgets, initialExpenses, initialMonth, currency }: ExpensesWrapperProps,
|
||||
) {
|
||||
return (
|
||||
<MainExpenses
|
||||
initialBudgets={initialBudgets}
|
||||
initialExpenses={initialExpenses}
|
||||
initialMonth={initialMonth}
|
||||
currency={currency}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import MainFiles from '/components/files/MainFiles.tsx';
|
||||
|
||||
interface FilesWrapperProps {
|
||||
initialDirectories: Directory[];
|
||||
initialFiles: DirectoryFile[];
|
||||
initialPath: string;
|
||||
baseUrl: string;
|
||||
isFileSharingAllowed: boolean;
|
||||
isCalDavEnabled?: boolean;
|
||||
fileShareId?: string;
|
||||
}
|
||||
|
||||
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
|
||||
export default function FilesWrapper(
|
||||
{
|
||||
initialDirectories,
|
||||
initialFiles,
|
||||
initialPath,
|
||||
baseUrl,
|
||||
isFileSharingAllowed,
|
||||
isCalDavEnabled,
|
||||
fileShareId,
|
||||
}: FilesWrapperProps,
|
||||
) {
|
||||
return (
|
||||
<MainFiles
|
||||
initialDirectories={initialDirectories}
|
||||
initialFiles={initialFiles}
|
||||
initialPath={initialPath}
|
||||
baseUrl={baseUrl}
|
||||
isFileSharingAllowed={isFileSharingAllowed}
|
||||
isCalDavEnabled={isCalDavEnabled}
|
||||
fileShareId={fileShareId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import MainNotes from '/components/notes/MainNotes.tsx';
|
||||
|
||||
interface NotesWrapperProps {
|
||||
initialDirectories: Directory[];
|
||||
initialFiles: DirectoryFile[];
|
||||
initialPath: string;
|
||||
}
|
||||
|
||||
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
|
||||
export default function NotesWrapper(
|
||||
{ initialDirectories, initialFiles, initialPath }: NotesWrapperProps,
|
||||
) {
|
||||
return (
|
||||
<MainNotes
|
||||
initialDirectories={initialDirectories}
|
||||
initialFiles={initialFiles}
|
||||
initialPath={initialPath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import MainPhotos from '/components/photos/MainPhotos.tsx';
|
||||
|
||||
interface PhotosWrapperProps {
|
||||
initialDirectories: Directory[];
|
||||
initialFiles: DirectoryFile[];
|
||||
initialPath: string;
|
||||
}
|
||||
|
||||
// This wrapper is necessary because islands need to be the first frontend component, but they don't support functions as props, so the more complex logic needs to live in the component itself
|
||||
export default function PhotosWrapper(
|
||||
{ initialDirectories, initialFiles, initialPath }: PhotosWrapperProps,
|
||||
) {
|
||||
return (
|
||||
<MainPhotos
|
||||
initialDirectories={initialDirectories}
|
||||
initialFiles={initialFiles}
|
||||
initialPath={initialPath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
15
lib/auth.ts
15
lib/auth.ts
|
|
@ -1,12 +1,11 @@
|
|||
import { decodeBase64Url, encodeBase64Url } from 'std/encoding/base64url.ts';
|
||||
import { decodeBase64 } from 'std/encoding/base64.ts';
|
||||
import { Cookie, getCookies, setCookie } from 'std/http/cookie.ts';
|
||||
import 'std/dotenv/load.ts';
|
||||
import { decodeBase64, decodeBase64Url, encodeBase64Url } from '@std/encoding';
|
||||
import { Cookie, getCookies, setCookie } from '@std/http';
|
||||
import '@std/dotenv/load';
|
||||
|
||||
import { generateHash, isRunningLocally } from './utils/misc.ts';
|
||||
import { User, UserSession } from './types.ts';
|
||||
import { UserModel, UserSessionModel, validateUserAndSession } from './models/user.ts';
|
||||
import { AppConfig } from './config.ts';
|
||||
import { generateHash, isRunningLocally } from '/public/ts/utils/misc.ts';
|
||||
import { User, UserSession } from '/lib/types.ts';
|
||||
import { UserModel, UserSessionModel, validateUserAndSession } from '/lib/models/user.ts';
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
|
||||
export const JWT_SECRET = Deno.env.get('JWT_SECRET') || '';
|
||||
export const PASSWORD_SALT = Deno.env.get('PASSWORD_SALT') || '';
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { isAbsolute, join } from '@std/path';
|
||||
import { UserModel } from './models/user.ts';
|
||||
import { Config, OptionalApp } from './types.ts';
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ export class AppConfig {
|
|||
allowedCookieDomains: [],
|
||||
skipCookieDomainSecurity: false,
|
||||
enableSingleSignOn: false,
|
||||
allowSignupsViaSingleSignOn: false,
|
||||
singleSignOnUrl: '',
|
||||
singleSignOnEmailAttribute: 'email',
|
||||
singleSignOnScopes: ['openid', 'email'],
|
||||
|
|
@ -22,9 +24,12 @@ export class AppConfig {
|
|||
files: {
|
||||
rootPath: 'data-files',
|
||||
allowPublicSharing: false,
|
||||
allowDirectoryDownloads: false,
|
||||
maxUploadSizeInMegabytes: 100,
|
||||
},
|
||||
core: {
|
||||
enabledApps: ['news', 'notes', 'photos', 'expenses', 'contacts'],
|
||||
enabledApps: ['dashboard', 'files', 'news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'],
|
||||
maxRequestSizeInMegabytes: 12,
|
||||
},
|
||||
visuals: {
|
||||
title: '',
|
||||
|
|
@ -35,14 +40,16 @@ export class AppConfig {
|
|||
from: 'help@bewcloud.com',
|
||||
host: 'localhost',
|
||||
port: 465,
|
||||
tlsMode: 'auto',
|
||||
tlsVerify: true,
|
||||
},
|
||||
contacts: {
|
||||
enableCardDavServer: true,
|
||||
cardDavUrl: 'http://127.0.0.1:5232',
|
||||
cardDavUrl: 'http://radicale:5232',
|
||||
},
|
||||
calendar: {
|
||||
enableCalDavServer: true,
|
||||
calDavUrl: 'http://127.0.0.1:5232',
|
||||
calDavUrl: 'http://radicale:5232',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -95,6 +102,10 @@ export class AppConfig {
|
|||
|
||||
console.info('\nConfig loaded from bewcloud.config.ts', JSON.stringify(this.config, null, 2), '\n');
|
||||
|
||||
if (this.config.core.enabledApps.length === 0) {
|
||||
throw new Error('At least one app must be enabled. Please check the config.core.enabledApps array.');
|
||||
}
|
||||
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('Error loading config from bewcloud.config.ts. Using default config instead.', error);
|
||||
|
|
@ -109,11 +120,12 @@ export class AppConfig {
|
|||
return this.config;
|
||||
}
|
||||
|
||||
static async isSignupAllowed(): Promise<boolean> {
|
||||
static async isSignupAllowed({ viaSingleSignOn = false }: { viaSingleSignOn?: boolean } = {}): Promise<boolean> {
|
||||
await this.loadConfig();
|
||||
|
||||
const areSignupsAllowed = this.config.auth.allowSignups;
|
||||
|
||||
const areSignupsAllowed = viaSingleSignOn && !this.config.auth.allowSignups
|
||||
? this.config.auth.allowSignupsViaSingleSignOn
|
||||
: this.config.auth.allowSignups;
|
||||
const areThereAdmins = await UserModel.isThereAnAdmin();
|
||||
|
||||
if (areSignupsAllowed || !areThereAdmins) {
|
||||
|
|
@ -179,12 +191,20 @@ export class AppConfig {
|
|||
return this.config.files.allowPublicSharing;
|
||||
}
|
||||
|
||||
static async areDirectoryDownloadsAllowed(): Promise<boolean> {
|
||||
await this.loadConfig();
|
||||
|
||||
return this.config.files.allowDirectoryDownloads;
|
||||
}
|
||||
|
||||
static async getFilesRootPath(): Promise<string> {
|
||||
await this.loadConfig();
|
||||
|
||||
const filesRootPath = `${Deno.cwd()}/${this.config.files.rootPath}`;
|
||||
|
||||
return filesRootPath;
|
||||
if (isAbsolute(this.config.files.rootPath)) {
|
||||
return this.config.files.rootPath;
|
||||
} else {
|
||||
return join(Deno.cwd(), this.config.files.rootPath);
|
||||
}
|
||||
}
|
||||
|
||||
static async getEmailConfig(): Promise<Config['email']> {
|
||||
|
|
|
|||
20
lib/feed.ts
20
lib/feed.ts
|
|
@ -1,7 +1,8 @@
|
|||
import { DOMParser, initParser } from 'https://deno.land/x/deno_dom@v0.1.45/deno-dom-wasm-noinit.ts';
|
||||
import { Feed, parseFeed } from 'https://deno.land/x/rss@1.0.0/mod.ts';
|
||||
import { fetchUrl, fetchUrlAsGooglebot, fetchUrlWithProxy, fetchUrlWithRetries } from './utils/misc.ts';
|
||||
import { NewsFeed, NewsFeedCrawlType, NewsFeedType } from './types.ts';
|
||||
import { DOMParser, initParser } from '@b-fuze/deno-dom/wasm-noinit';
|
||||
import { Feed, parseFeed } from '@mikaelporttila/rss';
|
||||
|
||||
import { fetchUrl, fetchUrlAsGooglebot, fetchUrlWithProxy, fetchUrlWithRetries } from '/public/ts/utils/misc.ts';
|
||||
import { NewsFeed, NewsFeedCrawlType, NewsFeedType } from '/lib/types.ts';
|
||||
|
||||
export interface JsonFeedItem {
|
||||
id: string;
|
||||
|
|
@ -221,13 +222,20 @@ export async function getUrlInfo(url: string): Promise<{ title: string; htmlBody
|
|||
}
|
||||
|
||||
export async function parseTextFromHtml(html: string): Promise<string> {
|
||||
let text = '';
|
||||
if (!html || !html.trim()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
await initParser();
|
||||
|
||||
const document = new DOMParser().parseFromString(html, 'text/html');
|
||||
|
||||
text = document!.textContent;
|
||||
// Extract text from body to avoid any artifacts from the document wrapper
|
||||
const text = (document?.querySelector('body')?.textContent || document?.textContent || '')
|
||||
// Collapse runs of 2+ whitespace/newline characters, preserving single line breaks
|
||||
.replace(/[^\S\n]{2,}/g, ' ')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
|
||||
return text;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,186 +0,0 @@
|
|||
export interface FormField {
|
||||
name: string;
|
||||
label: string;
|
||||
value?: string | null;
|
||||
overrideValue?: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
type:
|
||||
| 'text'
|
||||
| 'email'
|
||||
| 'tel'
|
||||
| 'url'
|
||||
| 'date'
|
||||
| 'datetime-local'
|
||||
| 'number'
|
||||
| 'range'
|
||||
| 'select'
|
||||
| 'textarea'
|
||||
| 'checkbox'
|
||||
| 'hidden'
|
||||
| 'password';
|
||||
step?: string;
|
||||
max?: string;
|
||||
min?: string;
|
||||
rows?: string;
|
||||
options?: {
|
||||
label: string;
|
||||
value: string;
|
||||
}[];
|
||||
checked?: boolean;
|
||||
multiple?: boolean;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
extraClasses?: string;
|
||||
}
|
||||
|
||||
export function getFormDataField(formData: FormData, field: string) {
|
||||
return ((formData.get(field) || '') as string).trim();
|
||||
}
|
||||
|
||||
export function getFormDataFieldArray(formData: FormData, field: string) {
|
||||
return ((formData.getAll(field) || []) as string[]).map((value) => value.trim());
|
||||
}
|
||||
|
||||
export function generateFieldHtml(
|
||||
field: FormField,
|
||||
formData: FormData,
|
||||
) {
|
||||
let value = field.overrideValue ||
|
||||
(field.multiple ? getFormDataFieldArray(formData, field.name) : getFormDataField(formData, field.name)) ||
|
||||
field.value;
|
||||
|
||||
if (typeof field.overrideValue !== 'undefined') {
|
||||
value = field.overrideValue;
|
||||
}
|
||||
|
||||
if (field.type === 'hidden') {
|
||||
return generateInputHtml(field, value);
|
||||
}
|
||||
|
||||
return (
|
||||
<fieldset class={`block mb-4 ${field.extraClasses || ''}`}>
|
||||
<label class='text-slate-300 block pb-1' for={`field_${field.name}`}>{field.label}</label>
|
||||
{generateInputHtml(field, value)}
|
||||
{field.description
|
||||
? (
|
||||
<aside class={`text-sm text-slate-400 p-2 ${field.type === 'checkbox' ? 'inline' : ''}`}>
|
||||
{field.description}
|
||||
</aside>
|
||||
)
|
||||
: null}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function generateInputHtml(
|
||||
{
|
||||
name,
|
||||
placeholder,
|
||||
type,
|
||||
options,
|
||||
step,
|
||||
max,
|
||||
min,
|
||||
rows,
|
||||
checked,
|
||||
multiple,
|
||||
disabled,
|
||||
required,
|
||||
readOnly,
|
||||
}: FormField,
|
||||
value?: string | string[] | null,
|
||||
) {
|
||||
const additionalAttributes: Record<string, string | number | boolean> = {};
|
||||
|
||||
if (typeof step !== 'undefined') {
|
||||
additionalAttributes.step = parseInt(step, 10);
|
||||
}
|
||||
if (typeof max !== 'undefined') {
|
||||
additionalAttributes.max = parseInt(max, 10);
|
||||
}
|
||||
if (typeof min !== 'undefined') {
|
||||
additionalAttributes.min = parseInt(min, 10);
|
||||
}
|
||||
if (typeof rows !== 'undefined') {
|
||||
additionalAttributes.rows = parseInt(rows, 10);
|
||||
}
|
||||
if (checked === true && type === 'checkbox' && value) {
|
||||
additionalAttributes.checked = true;
|
||||
}
|
||||
if (multiple === true) {
|
||||
additionalAttributes.multiple = true;
|
||||
}
|
||||
if (required === true) {
|
||||
additionalAttributes.required = true;
|
||||
}
|
||||
if (disabled === true) {
|
||||
additionalAttributes.disabled = true;
|
||||
}
|
||||
if (readOnly === true) {
|
||||
additionalAttributes.readonly = true;
|
||||
}
|
||||
|
||||
if (type === 'select') {
|
||||
return (
|
||||
<select class='mt-1 input-field' id={`field_${name}`} name={name} type={type} {...additionalAttributes}>
|
||||
{options?.map((option) => (
|
||||
<option
|
||||
value={option.value}
|
||||
selected={option.value === value || (multiple && (value || [])?.includes(option.value))}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'textarea') {
|
||||
return (
|
||||
<textarea
|
||||
class='mt-1 input-field'
|
||||
id={`field_${name}`}
|
||||
name={name}
|
||||
rows={6}
|
||||
placeholder={placeholder}
|
||||
{...additionalAttributes}
|
||||
>
|
||||
{(value as string) || ''}
|
||||
</textarea>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'checkbox') {
|
||||
return (
|
||||
<input id={`field_${name}`} name={name} type={type} value={value as string || ''} {...additionalAttributes} />
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'password') {
|
||||
return (
|
||||
<input
|
||||
class='mt-1 input-field'
|
||||
id={`field_${name}`}
|
||||
name={name}
|
||||
type={type}
|
||||
placeholder={placeholder || ''}
|
||||
value=''
|
||||
{...additionalAttributes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
class='mt-1 input-field'
|
||||
id={`field_${name}`}
|
||||
name={name}
|
||||
type={type}
|
||||
placeholder={placeholder || ''}
|
||||
value={value as string || ''}
|
||||
{...additionalAttributes}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { Client } from 'postgres';
|
||||
import 'std/dotenv/load.ts';
|
||||
import '@std/dotenv/load';
|
||||
|
||||
const POSTGRESQL_HOST = Deno.env.get('POSTGRESQL_HOST') || '';
|
||||
const POSTGRESQL_USER = Deno.env.get('POSTGRESQL_USER') || '';
|
||||
|
|
|
|||
338
lib/models/calendar.ts
Normal file
338
lib/models/calendar.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
import { createDAVClient } from '/lib/models/dav.js';
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
import { getColorAsHex, parseVCalendar } from '/public/ts/utils/calendar.ts';
|
||||
import { concurrentPromises } from '/public/ts/utils/misc.ts';
|
||||
import { UserModel } from '/lib/models/user.ts';
|
||||
|
||||
interface DAVObject extends Record<string, any> {
|
||||
data?: string;
|
||||
displayName?: string;
|
||||
ctag?: string;
|
||||
url: string;
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
export interface Calendar extends DAVObject {
|
||||
calendarColor?: string;
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarEvent extends DAVObject {
|
||||
calendarId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
title: string;
|
||||
isAllDay: boolean;
|
||||
organizerEmail: string;
|
||||
attendees?: CalendarEventAttendee[];
|
||||
reminders?: CalendarEventReminder[];
|
||||
transparency: 'opaque' | 'transparent';
|
||||
description?: string;
|
||||
location?: string;
|
||||
eventUrl?: string;
|
||||
sequence?: number;
|
||||
isRecurring?: boolean;
|
||||
recurringRrule?: string;
|
||||
recurrenceId?: string;
|
||||
recurrenceMasterUid?: string;
|
||||
}
|
||||
|
||||
export interface CalendarEventAttendee {
|
||||
email: string;
|
||||
status: 'accepted' | 'rejected' | 'invited';
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface CalendarEventReminder {
|
||||
uid?: string;
|
||||
startDate: string;
|
||||
type: 'email' | 'sound' | 'display';
|
||||
acknowledgedAt?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const calendarConfig = await AppConfig.getCalendarConfig();
|
||||
|
||||
async function getClient(userId: string) {
|
||||
const client = await createDAVClient({
|
||||
serverUrl: calendarConfig.calDavUrl,
|
||||
credentials: {},
|
||||
authMethod: 'Custom',
|
||||
// deno-lint-ignore require-await
|
||||
authFunction: async () => {
|
||||
return {
|
||||
'X-Remote-User': userId,
|
||||
};
|
||||
},
|
||||
fetchOptions: {
|
||||
timeout: 15_000,
|
||||
},
|
||||
defaultAccountType: 'caldav',
|
||||
rootUrl: `${calendarConfig.calDavUrl}/`,
|
||||
principalUrl: `${calendarConfig.calDavUrl}/${userId}/`,
|
||||
homeUrl: `${calendarConfig.calDavUrl}/${userId}/`,
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export class CalendarModel {
|
||||
static async list(
|
||||
userId: string,
|
||||
): Promise<Calendar[]> {
|
||||
const client = await getClient(userId);
|
||||
|
||||
const calendarUrl = `${calendarConfig.calDavUrl}/${userId}/`;
|
||||
|
||||
const davCalendars: DAVObject[] = await client.fetchCalendars({
|
||||
calendar: {
|
||||
url: calendarUrl,
|
||||
},
|
||||
});
|
||||
|
||||
const user = await UserModel.getById(userId);
|
||||
|
||||
const calendars: Calendar[] = davCalendars.map((davCalendar) => {
|
||||
const uid = davCalendar.url.split('/').filter(Boolean).pop()!;
|
||||
|
||||
return {
|
||||
...davCalendar,
|
||||
displayName: decodeURIComponent(davCalendar.displayName || '(empty)'),
|
||||
calendarColor: decodeURIComponent(
|
||||
typeof davCalendar.calendarColor === 'string' ? davCalendar.calendarColor : getColorAsHex('bg-gray-700'),
|
||||
),
|
||||
isVisible: !user.extra.hidden_calendar_ids?.includes(uid),
|
||||
uid,
|
||||
};
|
||||
});
|
||||
|
||||
return calendars;
|
||||
}
|
||||
|
||||
static async get(
|
||||
userId: string,
|
||||
calendarId: string,
|
||||
): Promise<Calendar | undefined> {
|
||||
const calendars = await this.list(userId);
|
||||
|
||||
return calendars.find((calendar) => calendar.uid === calendarId);
|
||||
}
|
||||
|
||||
static async create(
|
||||
userId: string,
|
||||
name: string,
|
||||
color: string,
|
||||
): Promise<void> {
|
||||
const calendarId = crypto.randomUUID();
|
||||
const calendarUrl = `${calendarConfig.calDavUrl}/${userId}/${calendarId}/`;
|
||||
|
||||
const client = await getClient(userId);
|
||||
|
||||
await client.makeCalendar({
|
||||
url: calendarUrl,
|
||||
props: {
|
||||
displayname: name,
|
||||
},
|
||||
});
|
||||
|
||||
// Cannot properly set color with makeCalendar, so we quickly update it instead
|
||||
await this.update(userId, calendarUrl, name, color);
|
||||
}
|
||||
|
||||
static async update(
|
||||
userId: string,
|
||||
calendarUrl: string,
|
||||
displayName: string,
|
||||
color?: string,
|
||||
): Promise<void> {
|
||||
// Make "manual" request (https://www.rfc-editor.org/rfc/rfc4791.html#page-20) because the dav client doesn't have PROPPATCH
|
||||
const xmlBody = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:proppatch xmlns:d="DAV:" xmlns:a="http://apple.com/ns/ical/">
|
||||
<d:set>
|
||||
<d:prop>
|
||||
<d:displayname>${encodeURIComponent(displayName)}</d:displayname>
|
||||
${color ? `<a:calendar-color>${encodeURIComponent(color)}</a:calendar-color>` : ''}
|
||||
</d:prop>
|
||||
</d:set>
|
||||
</d:proppatch>`;
|
||||
|
||||
await fetch(calendarUrl, {
|
||||
method: 'PROPPATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'X-Remote-User': userId,
|
||||
},
|
||||
body: xmlBody,
|
||||
});
|
||||
}
|
||||
|
||||
static async delete(
|
||||
userId: string,
|
||||
calendarId: string,
|
||||
): Promise<void> {
|
||||
const calendarUrl = `${calendarConfig.calDavUrl}/${userId}/${calendarId}/`;
|
||||
|
||||
const client = await getClient(userId);
|
||||
|
||||
await client.deleteObject({
|
||||
url: calendarUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class CalendarEventModel {
|
||||
private static async fetchByCalendarId(
|
||||
userId: string,
|
||||
calendarId: string,
|
||||
dateRange?: { start: Date; end: Date },
|
||||
): Promise<CalendarEvent[]> {
|
||||
const client = await getClient(userId);
|
||||
|
||||
const fetchOptions: { calendar: { url: string }; timeRange?: { start: string; end: string }; expand?: boolean } = {
|
||||
calendar: {
|
||||
url: `${calendarConfig.calDavUrl}/${userId}/${calendarId}/`,
|
||||
},
|
||||
};
|
||||
|
||||
const davCalendarEvents: DAVObject[] = await client.fetchCalendarObjects(fetchOptions);
|
||||
|
||||
if (dateRange) {
|
||||
fetchOptions.timeRange = {
|
||||
start: dateRange.start.toISOString(),
|
||||
end: dateRange.end.toISOString(),
|
||||
};
|
||||
fetchOptions.expand = true;
|
||||
|
||||
// Sometimes the expand option doesn't return anything, so we we fetch with and without it, when queried for a date range
|
||||
const davCalendarEventsWithExpansion = await client.fetchCalendarObjects(fetchOptions);
|
||||
|
||||
for (const davCalendarEvent of davCalendarEventsWithExpansion) {
|
||||
// Only add the events that are not already in the list
|
||||
if (!davCalendarEvents.some((davCalendarEvent) => davCalendarEvent.url === davCalendarEvent.url)) {
|
||||
davCalendarEvents.push(davCalendarEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const calendarEvents: CalendarEvent[] = [];
|
||||
|
||||
for (const davCalendarEvent of davCalendarEvents) {
|
||||
let uid = davCalendarEvent.url.split('/').filter(Boolean).pop()!;
|
||||
|
||||
const parsedEvents = parseVCalendar(davCalendarEvent.data || '');
|
||||
|
||||
for (const parsedEvent of parsedEvents) {
|
||||
if (parsedEvent.uid) {
|
||||
uid = parsedEvent.uid;
|
||||
}
|
||||
|
||||
calendarEvents.push({
|
||||
...davCalendarEvent,
|
||||
...parsedEvent,
|
||||
uid,
|
||||
calendarId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return calendarEvents;
|
||||
}
|
||||
|
||||
static async list(
|
||||
userId: string,
|
||||
calendarIds: string[],
|
||||
dateRange?: { start: Date; end: Date },
|
||||
): Promise<CalendarEvent[]> {
|
||||
const allCalendarEvents: CalendarEvent[] = [];
|
||||
|
||||
await concurrentPromises(
|
||||
calendarIds.map((calendarId) => async () => {
|
||||
const calendarEvents = await this.fetchByCalendarId(userId, calendarId, dateRange);
|
||||
|
||||
allCalendarEvents.push(...calendarEvents);
|
||||
|
||||
return calendarEvents;
|
||||
}),
|
||||
5,
|
||||
);
|
||||
|
||||
return allCalendarEvents;
|
||||
}
|
||||
|
||||
static async get(
|
||||
userId: string,
|
||||
calendarId: string,
|
||||
eventId: string,
|
||||
): Promise<CalendarEvent | undefined> {
|
||||
const client = await getClient(userId);
|
||||
|
||||
const davCalendarEvents: DAVObject[] = await client.fetchCalendarObjects({
|
||||
calendar: {
|
||||
url: `${calendarConfig.calDavUrl}/${userId}/${calendarId}/`,
|
||||
},
|
||||
objectUrls: [`${calendarConfig.calDavUrl}/${userId}/${calendarId}/${eventId}.ics`],
|
||||
});
|
||||
|
||||
if (davCalendarEvents.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const davCalendarEvent = davCalendarEvents[0];
|
||||
|
||||
const calendarEvent: CalendarEvent = {
|
||||
...davCalendarEvent,
|
||||
...parseVCalendar(davCalendarEvent.data || '')[0],
|
||||
uid: eventId,
|
||||
calendarId,
|
||||
};
|
||||
|
||||
return calendarEvent;
|
||||
}
|
||||
|
||||
static async create(
|
||||
userId: string,
|
||||
calendarId: string,
|
||||
eventId: string,
|
||||
vCalendar: string,
|
||||
): Promise<void> {
|
||||
const client = await getClient(userId);
|
||||
|
||||
const calendarUrl = `${calendarConfig.calDavUrl}/${userId}/${calendarId}/`;
|
||||
|
||||
await client.createCalendarObject({
|
||||
calendar: {
|
||||
url: calendarUrl,
|
||||
},
|
||||
iCalString: vCalendar,
|
||||
filename: `${eventId}.ics`,
|
||||
});
|
||||
}
|
||||
|
||||
static async update(
|
||||
userId: string,
|
||||
eventUrl: string,
|
||||
ics: string,
|
||||
): Promise<void> {
|
||||
const client = await getClient(userId);
|
||||
|
||||
await client.updateCalendarObject({
|
||||
calendarObject: {
|
||||
url: eventUrl,
|
||||
data: ics,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async delete(
|
||||
userId: string,
|
||||
eventUrl: string,
|
||||
): Promise<void> {
|
||||
const client = await getClient(userId);
|
||||
|
||||
await client.deleteCalendarObject({
|
||||
calendarObject: {
|
||||
url: eventUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import { createDAVClient } from 'tsdav';
|
||||
|
||||
import { createDAVClient } from '/lib/models/dav.js';
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
import { parseVCard } from '/lib/utils/contacts.ts';
|
||||
import { parseVCard } from '/public/ts/utils/contacts.ts';
|
||||
|
||||
interface DAVObject extends Record<string, any> {
|
||||
data?: string;
|
||||
|
|
|
|||
10891
lib/models/dav.js
Normal file
10891
lib/models/dav.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
import nodemailer from 'nodemailer';
|
||||
import 'std/dotenv/load.ts';
|
||||
import '@std/dotenv/load';
|
||||
|
||||
import { escapeHtml } from '/lib/utils/misc.ts';
|
||||
import { escapeHtml } from '/public/ts/utils/misc.ts';
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
|
||||
const SMTP_USERNAME = Deno.env.get('SMTP_USERNAME') || '';
|
||||
|
|
@ -15,14 +15,30 @@ export class EmailModel {
|
|||
throw new Error('config.email.from, config.email.host, or config.email.port is not set');
|
||||
}
|
||||
|
||||
let tlsMode = emailConfig.tlsMode;
|
||||
if (tlsMode === 'auto') {
|
||||
tlsMode = Number(emailConfig.port) === 465 ? 'immediate' : 'starttls';
|
||||
}
|
||||
|
||||
const transporterConfig = {
|
||||
host: emailConfig.host,
|
||||
port: emailConfig.port,
|
||||
secure: Number(emailConfig.port) === 465,
|
||||
auth: {
|
||||
user: SMTP_USERNAME,
|
||||
pass: SMTP_PASSWORD,
|
||||
},
|
||||
|
||||
secure: tlsMode === 'immediate',
|
||||
requireTLS: tlsMode === 'starttls',
|
||||
ignoreTLS: tlsMode === 'none',
|
||||
tls: emailConfig.tlsVerify === false
|
||||
? { rejectUnauthorized: false }
|
||||
: emailConfig.tlsVerify !== true
|
||||
? { servername: emailConfig.tlsVerify }
|
||||
: {},
|
||||
|
||||
auth: (SMTP_USERNAME || SMTP_PASSWORD)
|
||||
? {
|
||||
user: SMTP_USERNAME,
|
||||
pass: SMTP_PASSWORD,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
const transporter = nodemailer.createTransport(transporterConfig);
|
||||
|
|
@ -59,7 +75,6 @@ export class EmailModel {
|
|||
<style type="text/css" rel="stylesheet" media="all">
|
||||
/* Base ------------------------------ */
|
||||
|
||||
@import url("https://fonts.googleapis.com/css?family=Nunito+Sans:400,700&display=swap");
|
||||
body {
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
|
|
@ -95,7 +110,8 @@ export class EmailModel {
|
|||
body,
|
||||
td,
|
||||
th {
|
||||
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
|
||||
/* Source: https://fontsarena.com/blog/operating-systems-default-sans-serif-fonts/ */
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, "Helvetica Neue", Oxygen, Cantarell, sans-serif;
|
||||
}
|
||||
|
||||
h1 {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import { join } from 'std/path/join.ts';
|
||||
import { resolve } from 'std/path/resolve.ts';
|
||||
import { join, resolve } from '@std/path';
|
||||
import { lookup } from 'mrmime';
|
||||
import { Cookie, getCookies, setCookie } from 'std/http/cookie.ts';
|
||||
import { Cookie, getCookies, setCookie } from '@std/http';
|
||||
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
import { Directory, DirectoryFile, FileShare } from '/lib/types.ts';
|
||||
import { sortDirectoriesByName, sortEntriesByName, sortFilesByName, TRASH_PATH } from '/lib/utils/files.ts';
|
||||
import {
|
||||
bytesFromHumanFileSize,
|
||||
sortDirectoriesByName,
|
||||
sortEntriesByName,
|
||||
sortFilesByName,
|
||||
TRASH_PATH,
|
||||
} from '/public/ts/utils/files.ts';
|
||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import {
|
||||
COOKIE_NAME as AUTH_COOKIE_NAME,
|
||||
|
|
@ -15,7 +20,7 @@ import {
|
|||
resolveCookieDomain,
|
||||
verifyAuthJwt,
|
||||
} from '/lib/auth.ts';
|
||||
import { isRunningLocally } from '/lib/utils/misc.ts';
|
||||
import { isRunningLocally } from '/public/ts/utils/misc.ts';
|
||||
|
||||
const COOKIE_NAME = `${AUTH_COOKIE_NAME}-file-share`;
|
||||
|
||||
|
|
@ -40,12 +45,14 @@ export class DirectoryModel {
|
|||
for (const entry of directoryEntries) {
|
||||
const stat = await Deno.stat(join(rootPath, entry.name));
|
||||
|
||||
const directorySize = await getDirectorySize(join(rootPath, entry.name));
|
||||
|
||||
const directory: Directory = {
|
||||
user_id: userId,
|
||||
parent_path: path,
|
||||
directory_name: entry.name,
|
||||
has_write_access: true,
|
||||
size_in_bytes: stat.size,
|
||||
size_in_bytes: directorySize || stat.size,
|
||||
file_share_id: fileShares.find((fileShare) => fileShare.file_path === `${join(path, entry.name)}/`)?.id || null,
|
||||
updated_at: stat.mtime || new Date(),
|
||||
created_at: stat.birthtime || new Date(),
|
||||
|
|
@ -142,12 +149,14 @@ export class DirectoryModel {
|
|||
parentPath = '/';
|
||||
}
|
||||
|
||||
const directorySize = await getDirectorySize(join(rootPath, relativeDirectoryPath));
|
||||
|
||||
const directory: Directory = {
|
||||
user_id: userId,
|
||||
parent_path: parentPath,
|
||||
directory_name: directoryName,
|
||||
has_write_access: true,
|
||||
size_in_bytes: stat.size,
|
||||
size_in_bytes: directorySize || stat.size,
|
||||
file_share_id: fileShares.find((fileShare) =>
|
||||
fileShare.file_path === `${join(relativeDirectoryPath, directoryName)}/`
|
||||
)?.id || null,
|
||||
|
|
@ -599,7 +608,11 @@ export async function ensureUserPathIsValidAndSecurelyAccessible(userId: string,
|
|||
|
||||
const resolvedFullPath = `${resolve(fullPath)}/`;
|
||||
|
||||
if (!resolvedFullPath.startsWith(userRootPath)) {
|
||||
// Normalize path separators for consistent comparison on Windows
|
||||
const normalizedUserRootPath = userRootPath.replaceAll('\\', '/');
|
||||
const normalizedResolvedFullPath = resolvedFullPath.replaceAll('\\', '/');
|
||||
|
||||
if (!normalizedResolvedFullPath.startsWith(normalizedUserRootPath)) {
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
}
|
||||
|
|
@ -698,6 +711,14 @@ async function deleteDirectoryOrFile(userId: string, path: string, name: string)
|
|||
|
||||
const rootPath = join(await AppConfig.getFilesRootPath(), userId, path);
|
||||
|
||||
const fileShares = (await AppConfig.isPublicFileSharingAllowed())
|
||||
? await FileShareModel.getByParentFilePath(userId, path)
|
||||
: [];
|
||||
|
||||
const fileSharesForPath = fileShares.filter((fileShare) =>
|
||||
fileShare.file_path === `${join(path, name)}/` || fileShare.file_path === join(path, name)
|
||||
);
|
||||
|
||||
try {
|
||||
if (path.startsWith(TRASH_PATH)) {
|
||||
await Deno.remove(join(rootPath, name), { recursive: true });
|
||||
|
|
@ -705,6 +726,11 @@ async function deleteDirectoryOrFile(userId: string, path: string, name: string)
|
|||
const trashPath = join(await AppConfig.getFilesRootPath(), userId, TRASH_PATH);
|
||||
await Deno.rename(join(rootPath, name), join(trashPath, name));
|
||||
}
|
||||
|
||||
// Delete all file shares for this path
|
||||
for (const fileShare of fileSharesForPath) {
|
||||
await FileShareModel.delete(fileShare.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
|
|
@ -748,3 +774,45 @@ export async function getPathInfo(userId: string, path: string): Promise<{ isDir
|
|||
isFile: stat.isFile,
|
||||
};
|
||||
}
|
||||
|
||||
// NOTE: We're using `-h` (human readable) and parsing the output because that's more stable than `-B 1B` across different systems for a reliable byte size.
|
||||
async function getDirectorySize(path: string): Promise<number> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const commandTimeout = setTimeout(() => controller.abort(), 5_000);
|
||||
|
||||
const command = new Deno.Command(`du`, {
|
||||
args: [
|
||||
`-sh`,
|
||||
path,
|
||||
],
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const { code, stdout, stderr } = await command.output();
|
||||
|
||||
if (commandTimeout) {
|
||||
clearTimeout(commandTimeout);
|
||||
}
|
||||
|
||||
if (code !== 0) {
|
||||
if (stderr) {
|
||||
throw new Error(new TextDecoder().decode(stderr));
|
||||
}
|
||||
|
||||
throw new Error(`Unknown error running "du"`);
|
||||
}
|
||||
|
||||
const output = new TextDecoder().decode(stdout);
|
||||
|
||||
const value = output.split('\t')[0].trim();
|
||||
|
||||
const number = Number.parseFloat(value.match(/\d+(\.\d+)?/)?.[0] || '0');
|
||||
const unit = value.match(/[A-Z]+/)?.[0] || 'B'.toUpperCase();
|
||||
|
||||
return bytesFromHumanFileSize(`${number} ${unit}B`);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { Cookie, getCookies, setCookie } from 'std/http/cookie.ts';
|
||||
import { Cookie, getCookies, setCookie } from '@std/http';
|
||||
|
||||
import { MultiFactorAuthMethod, User } from '/lib/types.ts';
|
||||
import {
|
||||
getEnabledMultiFactorAuthMethodsFromUser,
|
||||
getMultiFactorAuthMethodByIdFromUser,
|
||||
} from '/lib/utils/multi-factor-auth.ts';
|
||||
} from '/public/ts/utils/multi-factor-auth.ts';
|
||||
import {
|
||||
COOKIE_NAME as AUTH_COOKIE_NAME,
|
||||
generateKey,
|
||||
|
|
@ -14,7 +14,7 @@ import {
|
|||
resolveCookieDomain,
|
||||
verifyAuthJwt,
|
||||
} from '/lib/auth.ts';
|
||||
import { isRunningLocally } from '/lib/utils/misc.ts';
|
||||
import { isRunningLocally } from '/public/ts/utils/misc.ts';
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
import { UserModel } from './user.ts';
|
||||
import { EmailModel } from './multi-factor-auth/email.ts';
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { Secret, TOTP } from 'otpauth';
|
||||
import QRCode from 'qrcode';
|
||||
import { encodeBase32 } from 'std/encoding/base32.ts';
|
||||
import { decodeBase64, encodeBase64 } from 'std/encoding/base64.ts';
|
||||
import { qrcode } from '@libs/qrcode';
|
||||
import { decodeBase64, encodeBase32, encodeBase64 } from '@std/encoding';
|
||||
|
||||
import { MultiFactorAuthMethod } from '/lib/types.ts';
|
||||
import { MFA_KEY, MFA_SALT } from '/lib/auth.ts';
|
||||
import { generateHash } from '/lib/utils/misc.ts';
|
||||
import { generateHash } from '/public/ts/utils/misc.ts';
|
||||
import { MultiFactorAuthSetup } from '/lib/models/multi-factor-auth.ts';
|
||||
|
||||
export class TOTPModel {
|
||||
|
|
@ -133,7 +132,8 @@ export class TOTPModel {
|
|||
private static async generateQRCodeDataURL(secret: string, issuer: string, accountName: string): Promise<string> {
|
||||
const totp = this.createTOTP(secret, issuer, accountName);
|
||||
const uri = totp.toString();
|
||||
return await QRCode.toDataURL(uri);
|
||||
const svgString = await qrcode(uri, { output: 'svg', border: 0 });
|
||||
return `data:image/svg+xml;base64,${encodeBase64(svgString)}`;
|
||||
}
|
||||
|
||||
private static verifyTOTPToken(secret: string, token: string, window = 1): boolean {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Feed } from 'https://deno.land/x/rss@1.0.0/mod.ts';
|
||||
import { Feed } from '@mikaelporttila/rss';
|
||||
|
||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import Locker from '/lib/interfaces/locker.ts';
|
||||
|
|
@ -132,9 +132,15 @@ export class FeedModel {
|
|||
continue;
|
||||
}
|
||||
|
||||
const url = (feedArticle as JsonFeedArticle).url || getArticleUrl((feedArticle as FeedArticle).links) ||
|
||||
let url = (feedArticle as JsonFeedArticle).url || getArticleUrl((feedArticle as FeedArticle).links) ||
|
||||
feedArticle.id;
|
||||
|
||||
// Fix relative URLs in the feeds
|
||||
if (url!.startsWith('/')) {
|
||||
const feedUrl = new URL(newsFeed.feed_url);
|
||||
url = `${feedUrl.origin}${url}`;
|
||||
}
|
||||
|
||||
const articleIsoDate = (feedArticle as JsonFeedArticle).date_published ||
|
||||
(feedArticle as FeedArticle).published?.toISOString() || (feedArticle as JsonFeedArticle).date_modified ||
|
||||
(feedArticle as FeedArticle).updated?.toISOString();
|
||||
|
|
@ -208,6 +214,17 @@ export class ArticleModel {
|
|||
return articles;
|
||||
}
|
||||
|
||||
static async listUnread(userId: string) {
|
||||
const articles = await db.query<NewsFeedArticle>(
|
||||
sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "user_id" = $1 AND "is_read" = FALSE ORDER BY "article_date" DESC`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return articles;
|
||||
}
|
||||
|
||||
static async listByFeedId(feedId: string) {
|
||||
const articles = await db.query<NewsFeedArticle>(
|
||||
sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "feed_id" = $1 ORDER BY "article_date" DESC`,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { decodeBase64Url } from 'std/encoding/base64url.ts';
|
||||
import { decodeBase64Url } from '@std/encoding';
|
||||
import * as openIdClient from 'openid-client';
|
||||
import 'std/dotenv/load.ts';
|
||||
import '@std/dotenv/load';
|
||||
|
||||
import { createSessionResponse, dataToText } from '/lib/auth.ts';
|
||||
import { UserModel } from '/lib/models/user.ts';
|
||||
import { generateRandomCode } from '/lib/utils/misc.ts';
|
||||
import { generateRandomCode } from '/public/ts/utils/misc.ts';
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
import SimpleCache from '/lib/interfaces/simple-cache.ts';
|
||||
|
||||
|
|
@ -169,7 +169,7 @@ export class OidcModel {
|
|||
throw new Error(`Missing user/${emailAttribute}`);
|
||||
}
|
||||
|
||||
const isSignupAllowed = await AppConfig.isSignupAllowed();
|
||||
const isSignupAllowed = await AppConfig.isSignupAllowed({ viaSingleSignOn: true });
|
||||
const isThereAnAdmin = await UserModel.isThereAnAdmin();
|
||||
|
||||
// Confirm the user exists (or signup if allowed)
|
||||
|
|
@ -181,10 +181,16 @@ export class OidcModel {
|
|||
}
|
||||
|
||||
if (!user) {
|
||||
if (!config.auth.allowSignupsViaSingleSignOn) {
|
||||
throw new Error('Sign up via SSO is not allowed!');
|
||||
}
|
||||
|
||||
throw new Error('There was a problem signing up or logging in!');
|
||||
}
|
||||
|
||||
let urlToRedirectTo = '/dashboard';
|
||||
const firstEnabledApp = config.core.enabledApps[0];
|
||||
|
||||
let urlToRedirectTo = `/${firstEnabledApp}`;
|
||||
|
||||
if (urlSearchParams.has('state')) {
|
||||
const state = this.parseState(urlSearchParams.get('state')!);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import { User, UserSession, VerificationCode } from '/lib/types.ts';
|
||||
import { generateRandomCode } from '/lib/utils/misc.ts';
|
||||
import { generateRandomCode } from '/public/ts/utils/misc.ts';
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
|
||||
const db = new Database();
|
||||
|
|
|
|||
69
lib/page.ts
Normal file
69
lib/page.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { User, UserSession } from './types.ts';
|
||||
import { JwtData } from './auth.ts';
|
||||
|
||||
export type RequestHandlerParams = {
|
||||
request: Request;
|
||||
match: URLPatternResult;
|
||||
user?: User | null;
|
||||
session?: { userSession?: UserSession; tokenData?: JwtData['data'] } | null;
|
||||
isRunningLocally: boolean;
|
||||
};
|
||||
export type RequestHandler<T = Response> = (params: RequestHandlerParams) => T | Promise<T>;
|
||||
|
||||
export interface Page {
|
||||
get?: RequestHandler;
|
||||
post?: RequestHandler;
|
||||
put?: RequestHandler;
|
||||
patch?: RequestHandler;
|
||||
delete?: RequestHandler;
|
||||
options?: RequestHandler;
|
||||
catchAll?: RequestHandler;
|
||||
}
|
||||
|
||||
type AccessMode = 'public' | 'user';
|
||||
interface PermissionsParams {
|
||||
accessMode: AccessMode;
|
||||
}
|
||||
|
||||
type Params = Page & PermissionsParams;
|
||||
|
||||
function permissioned(handler: RequestHandler, accessMode: AccessMode) {
|
||||
return ({ request, match, user, session, isRunningLocally }: RequestHandlerParams) => {
|
||||
if (accessMode !== 'public') {
|
||||
if (!user) {
|
||||
const url = new URL(request.url);
|
||||
const redirectTo = encodeURIComponent(`${url.pathname}${url.search}`);
|
||||
return new Response('Redirect', { status: 302, headers: { 'Location': `/login?redirectTo=${redirectTo}` } });
|
||||
}
|
||||
}
|
||||
|
||||
if (!handler) {
|
||||
return new Response('Not Implemented', { status: 501 });
|
||||
}
|
||||
|
||||
return handler({ request, match, user, session, isRunningLocally });
|
||||
};
|
||||
}
|
||||
|
||||
export default function page(
|
||||
{
|
||||
get,
|
||||
post,
|
||||
put,
|
||||
patch,
|
||||
delete: deleteAction,
|
||||
options,
|
||||
catchAll,
|
||||
accessMode,
|
||||
}: Params,
|
||||
): Page {
|
||||
return {
|
||||
get: get ? permissioned(get, accessMode) : undefined,
|
||||
post: post ? permissioned(post, accessMode) : undefined,
|
||||
put: put ? permissioned(put, accessMode) : undefined,
|
||||
patch: patch ? permissioned(patch, accessMode) : undefined,
|
||||
delete: deleteAction ? permissioned(deleteAction, accessMode) : undefined,
|
||||
options: options ? permissioned(options, accessMode) : undefined,
|
||||
catchAll: catchAll ? permissioned(catchAll, accessMode) : undefined,
|
||||
};
|
||||
}
|
||||
38
lib/types.ts
38
lib/types.ts
|
|
@ -14,6 +14,11 @@ export interface User {
|
|||
dav_hashed_password?: string;
|
||||
expenses_currency?: SupportedCurrencySymbol;
|
||||
multi_factor_auth_methods?: MultiFactorAuthMethod[];
|
||||
hidden_calendar_ids?: string[];
|
||||
timezone?: {
|
||||
id: string;
|
||||
utcOffset: number;
|
||||
};
|
||||
};
|
||||
created_at: Date;
|
||||
}
|
||||
|
|
@ -26,11 +31,6 @@ export interface UserSession {
|
|||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface FreshContextState {
|
||||
user?: User;
|
||||
session?: UserSession;
|
||||
}
|
||||
|
||||
export interface VerificationCode {
|
||||
id: string;
|
||||
user_id: string;
|
||||
|
|
@ -135,23 +135,15 @@ export interface Expense {
|
|||
}
|
||||
|
||||
export type SupportedCurrencySymbol = '$' | '€' | '£' | '¥' | '₹';
|
||||
type SupportedCurrency = 'USD' | 'EUR' | 'GBP' | 'JPY' | 'INR';
|
||||
|
||||
export const currencyMap = new Map<SupportedCurrencySymbol, SupportedCurrency>([
|
||||
['$', 'USD'],
|
||||
['€', 'EUR'],
|
||||
['£', 'GBP'],
|
||||
['¥', 'JPY'],
|
||||
['₹', 'INR'],
|
||||
]);
|
||||
export type SupportedCurrency = 'USD' | 'EUR' | 'GBP' | 'JPY' | 'INR';
|
||||
|
||||
export type PartialDeep<T> = (T extends (infer U)[] ? PartialDeep<U>[] : { [P in keyof T]?: PartialDeep<T[P]> }) | T;
|
||||
|
||||
export type OptionalApp = 'news' | 'notes' | 'photos' | 'expenses' | 'contacts';
|
||||
export type OptionalApp = 'dashboard' | 'files' | 'news' | 'notes' | 'photos' | 'expenses' | 'contacts' | 'calendar';
|
||||
|
||||
export interface Config {
|
||||
auth: {
|
||||
/** The base URL of the application you use to access the app, i.e. "http://localhost:8000" or "https://cloud.example.com" */
|
||||
/** The base URL of the application you use to access the app, i.e. "http://localhost:8000" or "https://cloud.example.com" (note authentication won't work without https:// except for localhost; SSO redirect, if enabled, will be this + /oidc/callback, so "https://cloud.example.com/oidc/callback") */
|
||||
baseUrl: string;
|
||||
/** If true, anyone can sign up for an account. Note that it's always possible to sign up for the first user, and they will be an admin */
|
||||
allowSignups: boolean;
|
||||
|
|
@ -167,6 +159,8 @@ export interface Config {
|
|||
skipCookieDomainSecurity: boolean;
|
||||
/** If true, single sign-on will be enabled */
|
||||
enableSingleSignOn: boolean;
|
||||
/** If true, signups via single sign-on will be allowed, overriding allowSignups */
|
||||
allowSignupsViaSingleSignOn: boolean;
|
||||
/** The Discovery URL (AKA Issuer) of the identity/single sign-on provider */
|
||||
singleSignOnUrl: string;
|
||||
/** The attribute to prefer as email of the identity/single sign-on provider */
|
||||
|
|
@ -179,10 +173,16 @@ export interface Config {
|
|||
rootPath: string;
|
||||
/** If true, public file sharing will be allowed (still requires a user to enable sharing for a given file or directory) */
|
||||
allowPublicSharing: boolean;
|
||||
/** If true, directories can be downloaded as zip files */
|
||||
allowDirectoryDownloads: boolean;
|
||||
/** The maximum upload size in megabytes. Overrides the core.maxRequestSizeInMegabytes setting on /dav and /api/files/upload endpoints. */
|
||||
maxUploadSizeInMegabytes: number;
|
||||
};
|
||||
core: {
|
||||
/** dashboard and files cannot be disabled */
|
||||
/** The apps to show, in order of appearance in the header. The first app will be the default one shown after logging in. At least one is required. */
|
||||
enabledApps: OptionalApp[];
|
||||
/** The maximum request size in megabytes. */
|
||||
maxRequestSizeInMegabytes: number;
|
||||
};
|
||||
visuals: {
|
||||
/** An override title of the application. Empty shows the default title. */
|
||||
|
|
@ -199,6 +199,10 @@ export interface Config {
|
|||
host: string;
|
||||
/** The SMTP port to send emails from */
|
||||
port: number;
|
||||
/** "auto" means "immediate" on port 465, "starttls" otherwise. */
|
||||
tlsMode: 'auto' | 'immediate' | 'starttls' | 'none';
|
||||
/** Whether to verify the TLS certificate. If a string is used the hostname will be verified using that name. */
|
||||
tlsVerify: boolean | string;
|
||||
};
|
||||
contacts: {
|
||||
/** If true, the CardDAV server will be enabled (proxied) */
|
||||
|
|
|
|||
|
|
@ -1,503 +0,0 @@
|
|||
import { assertEquals } from 'std/assert/assert_equals.ts';
|
||||
import { assertMatch } from 'std/assert/assert_match.ts';
|
||||
|
||||
import { generateVCard, getIdFromVCard, parseVCard, splitTextIntoVCards, updateVCard } from './contacts.ts';
|
||||
|
||||
Deno.test('that getIdFromVCard works', () => {
|
||||
const tests: { input: string; expected?: string; shouldBeUUID?: boolean }[] = [
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:12345-abcde-67890
|
||||
FN:John Doe
|
||||
END:VCARD`,
|
||||
expected: '12345-abcde-67890',
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:Jane Smith
|
||||
UID:jane-smith-uuid
|
||||
EMAIL:jane@example.com
|
||||
END:VCARD`,
|
||||
expected: 'jane-smith-uuid',
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:No UID Contact
|
||||
EMAIL:nouid@example.com
|
||||
END:VCARD`,
|
||||
shouldBeUUID: true,
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID: spaced-uid
|
||||
FN:Spaced UID
|
||||
END:VCARD`,
|
||||
expected: 'spaced-uid',
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = getIdFromVCard(test.input);
|
||||
if (test.expected) {
|
||||
assertEquals(output, test.expected);
|
||||
} else if (test.shouldBeUUID) {
|
||||
// Check that it's a valid UUID format
|
||||
assertMatch(output, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that splitTextIntoVCards works', () => {
|
||||
const tests: { input: string; expected: string[] }[] = [
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:1
|
||||
FN:John Doe
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:2
|
||||
FN:Jane Smith
|
||||
END:VCARD`,
|
||||
expected: [
|
||||
`BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:1
|
||||
FN:John Doe
|
||||
END:VCARD`,
|
||||
`BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:2
|
||||
FN:Jane Smith
|
||||
END:VCARD`,
|
||||
],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:Single Contact
|
||||
EMAIL:single@example.com
|
||||
END:VCARD`,
|
||||
expected: [
|
||||
`BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:Single Contact
|
||||
EMAIL:single@example.com
|
||||
END:VCARD`,
|
||||
],
|
||||
},
|
||||
{
|
||||
input: '',
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
FN:Incomplete Contact`,
|
||||
expected: [],
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = splitTextIntoVCards(test.input);
|
||||
assertEquals(output, test.expected);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that generateVCard works', () => {
|
||||
const tests: { input: { contactId: string; firstName: string; lastName?: string }; expected: string }[] = [
|
||||
{
|
||||
input: { contactId: 'test-123', firstName: 'John', lastName: 'Doe' },
|
||||
expected: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
UID:test-123
|
||||
END:VCARD`,
|
||||
},
|
||||
{
|
||||
input: { contactId: 'single-name', firstName: 'Madonna' },
|
||||
expected: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
N:;Madonna;
|
||||
FN:Madonna
|
||||
UID:single-name
|
||||
END:VCARD`,
|
||||
},
|
||||
{
|
||||
input: { contactId: 'special-chars', firstName: 'John,Test', lastName: 'Doe\nSmith' },
|
||||
expected: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
N:Doe\\nSmith;John\\,Test;
|
||||
FN:John\\,Test Doe\\nSmith
|
||||
UID:special-chars
|
||||
END:VCARD`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = generateVCard(test.input.contactId, test.input.firstName, test.input.lastName);
|
||||
assertEquals(output, test.expected);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that updateVCard works', () => {
|
||||
const tests: {
|
||||
input: {
|
||||
vCard: string;
|
||||
updates: {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
notes?: string;
|
||||
};
|
||||
};
|
||||
expected: string;
|
||||
}[] = [
|
||||
{
|
||||
input: {
|
||||
vCard: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-123
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
END:VCARD`,
|
||||
updates: { firstName: 'Jane', lastName: 'Smith' },
|
||||
},
|
||||
expected: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-123
|
||||
N:Smith;Jane;
|
||||
FN:Jane Smith
|
||||
END:VCARD`,
|
||||
},
|
||||
{
|
||||
input: {
|
||||
vCard: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-456
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
EMAIL:old@example.com
|
||||
TEL:+1234567890
|
||||
END:VCARD`,
|
||||
updates: { email: 'new@example.com', phone: '+9876543210' },
|
||||
},
|
||||
expected: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-456
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
EMAIL:new@example.com
|
||||
TEL:+9876543210
|
||||
END:VCARD`,
|
||||
},
|
||||
{
|
||||
input: {
|
||||
vCard: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-789
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
END:VCARD`,
|
||||
updates: { email: 'added@example.com', phone: '+1111111111', notes: 'Test notes' },
|
||||
},
|
||||
expected: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-789
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
EMAIL;TYPE=HOME:added@example.com
|
||||
TEL;TYPE=HOME:+1111111111
|
||||
NOTE:Test notes
|
||||
END:VCARD`,
|
||||
},
|
||||
{
|
||||
input: {
|
||||
vCard: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-special
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
NOTE:Old notes
|
||||
END:VCARD`,
|
||||
updates: { notes: 'New notes\nwith newlines, and commas' },
|
||||
},
|
||||
expected: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-special
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
NOTE:New notes\\nwith newlines\\, and commas
|
||||
END:VCARD`,
|
||||
},
|
||||
{
|
||||
input: {
|
||||
vCard: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-carriage
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
END:VCARD`,
|
||||
updates: { notes: 'Notes with\r\ncarriage returns' },
|
||||
},
|
||||
expected: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-carriage
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
NOTE:Notes with\\ncarriage returns
|
||||
END:VCARD`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = updateVCard(test.input.vCard, test.input.updates);
|
||||
assertEquals(output, test.expected);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that parseVCard works', () => {
|
||||
const tests: {
|
||||
input: string;
|
||||
expected: Array<{
|
||||
uid?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
middleNames?: string[];
|
||||
title?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
notes?: string;
|
||||
}>;
|
||||
}[] = [
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:test-123
|
||||
N:Doe;John;Middle;Jr
|
||||
FN:John Middle Doe Jr
|
||||
EMAIL;TYPE=HOME:john@example.com
|
||||
TEL;TYPE=HOME:+1234567890
|
||||
NOTE:Test contact notes
|
||||
END:VCARD`,
|
||||
expected: [{
|
||||
uid: 'test-123',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
middleNames: ['Middle'],
|
||||
title: 'Jr',
|
||||
email: 'john@example.com',
|
||||
phone: '+1234567890',
|
||||
notes: 'Test contact notes',
|
||||
}],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
UID:test-456
|
||||
N:Smith;Jane;;
|
||||
FN:Jane Smith
|
||||
EMAIL:jane@example.com
|
||||
TEL:+9876543210
|
||||
END:VCARD`,
|
||||
expected: [{
|
||||
uid: 'test-456',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
middleNames: [],
|
||||
title: '',
|
||||
email: 'jane@example.com',
|
||||
phone: '+9876543210',
|
||||
}],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:multi-1
|
||||
N:Doe;John;
|
||||
FN:John Doe
|
||||
END:VCARD
|
||||
BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:multi-2
|
||||
N:Smith;Jane;
|
||||
FN:Jane Smith
|
||||
EMAIL;PREF=1:jane@example.com
|
||||
END:VCARD`,
|
||||
expected: [
|
||||
{
|
||||
uid: 'multi-1',
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
middleNames: [],
|
||||
title: '',
|
||||
},
|
||||
{
|
||||
uid: 'multi-2',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
middleNames: [],
|
||||
title: '',
|
||||
email: 'jane@example.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:escaped-contact
|
||||
N:Test;Contact;
|
||||
FN:Contact Test
|
||||
NOTE:Notes with\\nescaped newlines\\, and commas
|
||||
END:VCARD`,
|
||||
expected: [{
|
||||
uid: 'escaped-contact',
|
||||
firstName: 'Contact',
|
||||
lastName: 'Test',
|
||||
middleNames: [],
|
||||
title: '',
|
||||
notes: 'Notes with\nescaped newlines, and commas',
|
||||
}],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:2.1
|
||||
UID:version-21
|
||||
N:Old;Format;
|
||||
FN:Format Old
|
||||
EMAIL:old@example.com
|
||||
TEL:+1111111111
|
||||
END:VCARD`,
|
||||
expected: [{
|
||||
uid: 'version-21',
|
||||
firstName: 'Format',
|
||||
lastName: 'Old',
|
||||
middleNames: [],
|
||||
title: '',
|
||||
email: 'old@example.com',
|
||||
phone: '+1111111111',
|
||||
}],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:email-variations
|
||||
N:Test;Email;
|
||||
FN:Email Test
|
||||
EMAIL:direct@example.com
|
||||
EMAIL;TYPE=WORK:work@example.com
|
||||
TEL:+1234567890
|
||||
TEL;TYPE=WORK:+9876543210
|
||||
END:VCARD`,
|
||||
expected: [{
|
||||
uid: 'email-variations',
|
||||
firstName: 'Email',
|
||||
lastName: 'Test',
|
||||
middleNames: [],
|
||||
title: '',
|
||||
email: 'direct@example.com', // Only first email is captured
|
||||
phone: '+1234567890', // Only first phone is captured
|
||||
}],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:5.0
|
||||
UID:invalid-version
|
||||
N:Invalid;Version;
|
||||
FN:Version Invalid
|
||||
END:VCARD`,
|
||||
expected: [{
|
||||
uid: 'invalid-version',
|
||||
firstName: 'Version',
|
||||
lastName: 'Invalid',
|
||||
middleNames: [],
|
||||
title: '',
|
||||
}],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:no-first-name
|
||||
N:LastOnly;;
|
||||
FN:LastOnly
|
||||
END:VCARD`,
|
||||
expected: [{ uid: 'no-first-name' }],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:empty-uid
|
||||
N:Test;Empty;
|
||||
FN:Empty Test
|
||||
END:VCARD`,
|
||||
expected: [{
|
||||
firstName: 'Empty',
|
||||
lastName: 'Test',
|
||||
middleNames: [],
|
||||
title: '',
|
||||
uid: 'empty-uid',
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = parseVCard(test.input);
|
||||
assertEquals(output, test.expected);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that parseVCard handles edge cases', () => {
|
||||
const edgeCases: { input: string; description: string; expected: any[] }[] = [
|
||||
{
|
||||
input: '',
|
||||
description: 'empty string',
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
input: 'Not a vCard at all',
|
||||
description: 'invalid format',
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:incomplete`,
|
||||
description: 'incomplete vCard without END',
|
||||
expected: [],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:missing-required
|
||||
FN:Missing Required Fields
|
||||
END:VCARD`,
|
||||
description: 'vCard without N field',
|
||||
expected: [{ uid: 'missing-required' }],
|
||||
},
|
||||
{
|
||||
input: `BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:empty-fields
|
||||
N:;;;
|
||||
FN:Empty Fields
|
||||
EMAIL:
|
||||
TEL:
|
||||
NOTE:
|
||||
END:VCARD`,
|
||||
description: 'vCard with empty field values',
|
||||
expected: [{ uid: 'empty-fields', notes: '' }],
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of edgeCases) {
|
||||
const output = parseVCard(test.input);
|
||||
assertEquals(output, test.expected, `Failed for: ${test.description}`);
|
||||
}
|
||||
});
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { assertEquals } from 'std/assert/assert_equals.ts';
|
||||
import { humanFileSize } from './files.ts';
|
||||
|
||||
Deno.test('that humanFileSize works', () => {
|
||||
const tests: { input: number; expected: string }[] = [
|
||||
{
|
||||
input: 1000,
|
||||
expected: '1000 B',
|
||||
},
|
||||
{
|
||||
input: 1024,
|
||||
expected: '1.00 KB',
|
||||
},
|
||||
{
|
||||
input: 10000,
|
||||
expected: '9.77 KB',
|
||||
},
|
||||
{
|
||||
input: 1,
|
||||
expected: '1 B',
|
||||
},
|
||||
{
|
||||
input: 1048576,
|
||||
expected: '1.00 MB',
|
||||
},
|
||||
{
|
||||
input: 1073741824,
|
||||
expected: '1.00 GB',
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = humanFileSize(test.input);
|
||||
assertEquals(output, test.expected);
|
||||
}
|
||||
});
|
||||
91
lib/utils/layout.tsx
Normal file
91
lib/utils/layout.tsx
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import { renderToString } from 'preact-render-to-string';
|
||||
|
||||
import denoConfig from '/deno.json' with { type: 'json' };
|
||||
import { RequestHandlerParams } from '/lib/page.ts';
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
import { escapeHtml, html } from '/public/ts/utils/misc.ts';
|
||||
|
||||
import Header from '/components/Header.tsx';
|
||||
|
||||
interface BasicLayoutOptions
|
||||
extends Pick<RequestHandlerParams, 'user' | 'session' | 'request' | 'match' | 'isRunningLocally'> {
|
||||
currentPath: string;
|
||||
titlePrefix: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
async function basicLayout(
|
||||
htmlContent: string,
|
||||
{ currentPath, titlePrefix, description, user }: BasicLayoutOptions,
|
||||
) {
|
||||
const config = await AppConfig.getConfig();
|
||||
|
||||
const defaultTitle = config.visuals.title || 'bewCloud is a modern and simpler alternative to Nextcloud and ownCloud';
|
||||
const defaultDescription = config.visuals.description || `Have your files under your own control.`;
|
||||
const enabledApps = config.core.enabledApps;
|
||||
|
||||
let title = defaultTitle;
|
||||
|
||||
if (titlePrefix) {
|
||||
title = `${titlePrefix} - bewCloud`;
|
||||
}
|
||||
|
||||
const headerReactNode = <Header route={currentPath} user={user} enabledApps={enabledApps} />;
|
||||
|
||||
const headerHtml = renderToString(headerReactNode);
|
||||
|
||||
return html`
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" dir="ltr" class="h-full bg-slate-800">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<meta name="description" content="${escapeHtml(description || defaultDescription)}">
|
||||
<meta name="author" content="Bruno Bernardino">
|
||||
<meta property="og:title" content="${escapeHtml(defaultTitle)}" />
|
||||
<link rel="icon" href="/public/images/favicon-dark.png" type="image/png" />
|
||||
<link rel="apple-touch-icon" href="/public/images/favicon-dark.png" />
|
||||
<link rel="manifest" href="/public/manifest.json" />
|
||||
<link rel="stylesheet" href="/public/scss/style.scss" />
|
||||
<link rel="stylesheet" href="/public/css/tailwind.css" />
|
||||
</head>
|
||||
|
||||
<script type="importmap">
|
||||
${JSON.stringify(importMap)}
|
||||
</script>
|
||||
|
||||
<body class="h-full">
|
||||
${headerHtml} ${htmlContent}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
const importMap = denoConfig.frontendImports.reduce(
|
||||
(importsObject: { imports: Record<string, string> }, importName: string) => {
|
||||
const url = new URL(denoConfig.imports[importName as keyof typeof denoConfig.imports]).toString();
|
||||
let fileName = url.replace('https://esm.sh/', '').split('?')[0].trim();
|
||||
if (!fileName.endsWith('.mjs')) {
|
||||
fileName = `${fileName}.mjs`;
|
||||
}
|
||||
importsObject.imports[importName] = `/public/js/${fileName}`;
|
||||
return importsObject;
|
||||
},
|
||||
{ imports: {} },
|
||||
);
|
||||
|
||||
export async function basicLayoutResponse(htmlContent: string, options: BasicLayoutOptions) {
|
||||
const headers: HeadersInit = {
|
||||
'content-type': 'text/html; charset=utf-8',
|
||||
'content-security-policy':
|
||||
`default-src 'self'; child-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'`,
|
||||
'x-frame-options': 'DENY',
|
||||
'x-content-type-options': 'nosniff',
|
||||
'strict-transport-security': 'max-age=31536000; includeSubDomains; preload',
|
||||
};
|
||||
|
||||
return new Response(await basicLayout(htmlContent, options), {
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
|
@ -1,270 +1,64 @@
|
|||
import { currencyMap } from '/lib/types.ts';
|
||||
import { SupportedCurrencySymbol } from '/lib/types.ts';
|
||||
import { serveFile } from '@std/http/file-server';
|
||||
import { transpile } from 'deno/emit';
|
||||
import sass from 'sass';
|
||||
|
||||
export function isRunningLocally(request: Request): boolean {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const hostname = url.hostname;
|
||||
|
||||
// Local hostnames check
|
||||
if (['localhost', '127.0.0.1', '0.0.0.0'].includes(hostname)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Private IP ranges check
|
||||
const ipParts = hostname.split('.').map(Number);
|
||||
|
||||
// Check if the IP address is valid
|
||||
if (ipParts.length !== 4 || ipParts.some((part) => isNaN(part) || part < 0 || part > 255)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 10.0.0.0 - 10.255.255.255
|
||||
if (ipParts[0] === 10) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 172.16.0.0 - 172.31.255.255
|
||||
if (ipParts[0] === 172 && ipParts[1] >= 16 && ipParts[1] <= 31) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 192.168.0.0 - 192.168.255.255
|
||||
if (ipParts[0] === 192 && ipParts[1] === 168) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.info('Failed to check if the request is running locally', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function escapeHtml(unsafe: string) {
|
||||
return unsafe.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
export function escapeXml(unsafe: string) {
|
||||
return escapeHtml(unsafe).replaceAll('\r', ' ');
|
||||
}
|
||||
|
||||
export function generateRandomCode(length = 6) {
|
||||
const getRandomDigit = () => Math.floor(Math.random() * (10)); // 0-9
|
||||
|
||||
const codeDigits = Array.from({ length }).map(getRandomDigit);
|
||||
|
||||
return codeDigits.join('');
|
||||
}
|
||||
|
||||
/** This generates a random string, including unicode characters. */
|
||||
export function generateRandomString(length = 10) {
|
||||
const array = new Uint8Array(length * 2);
|
||||
crypto.getRandomValues(array);
|
||||
|
||||
return Array.from(array)
|
||||
.map((byte) => String.fromCharCode(byte))
|
||||
.join('')
|
||||
.slice(0, length);
|
||||
}
|
||||
|
||||
export async function generateHash(value: string, algorithm: AlgorithmIdentifier) {
|
||||
const hashedValueData = await crypto.subtle.digest(
|
||||
algorithm,
|
||||
new TextEncoder().encode(value),
|
||||
);
|
||||
|
||||
const hashedValue = Array.from(new Uint8Array(hashedValueData)).map(
|
||||
(byte) => byte.toString(16).padStart(2, '0'),
|
||||
).join('');
|
||||
|
||||
return hashedValue;
|
||||
}
|
||||
|
||||
export function splitArrayInChunks<T = any>(array: T[], chunkLength: number) {
|
||||
const chunks = [];
|
||||
let chunkIndex = 0;
|
||||
const arrayLength = array.length;
|
||||
|
||||
while (chunkIndex < arrayLength) {
|
||||
chunks.push(array.slice(chunkIndex, chunkIndex += chunkLength));
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export function validateEmail(email: string) {
|
||||
const trimmedEmail = (email || '').trim().toLocaleLowerCase();
|
||||
if (!trimmedEmail) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const requiredCharsNotInEdges = ['@', '.'];
|
||||
return requiredCharsNotInEdges.every((char) =>
|
||||
trimmedEmail.includes(char) && !trimmedEmail.startsWith(char) && !trimmedEmail.endsWith(char)
|
||||
);
|
||||
}
|
||||
|
||||
export function validateUrl(url: string) {
|
||||
const trimmedUrl = (url || '').trim().toLocaleLowerCase();
|
||||
if (!trimmedUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!trimmedUrl.includes('://')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const protocolIndex = trimmedUrl.indexOf('://');
|
||||
const urlAfterProtocol = trimmedUrl.substring(protocolIndex + 3);
|
||||
|
||||
if (!urlAfterProtocol) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Adapted from https://gist.github.com/fasiha/7f20043a12ce93401d8473aee037d90a
|
||||
export async function concurrentPromises<T>(
|
||||
generators: (() => Promise<T>)[],
|
||||
maxConcurrency: number,
|
||||
): Promise<T[]> {
|
||||
const iterator = generators.entries();
|
||||
|
||||
const results: T[] = [];
|
||||
|
||||
let hasFailed = false;
|
||||
|
||||
await Promise.all(
|
||||
Array.from(Array(maxConcurrency), async () => {
|
||||
for (const [index, promiseToExecute] of iterator) {
|
||||
if (hasFailed) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
results[index] = await promiseToExecute();
|
||||
} catch (error) {
|
||||
hasFailed = true;
|
||||
throw error;
|
||||
}
|
||||
async function transpileTs(content: string, specifier: URL) {
|
||||
const urlStr = specifier.toString();
|
||||
const result = await transpile(specifier, {
|
||||
load(specifier: string) {
|
||||
if (specifier !== urlStr) {
|
||||
return Promise.resolve({ kind: 'module', specifier, content: '' });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const MAX_RESPONSE_TIME_IN_MS = 10_000;
|
||||
|
||||
export async function fetchUrl(url: string) {
|
||||
const abortController = new AbortController();
|
||||
const requestCancelTimeout = setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, MAX_RESPONSE_TIME_IN_MS);
|
||||
|
||||
const response = await fetch(url, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (requestCancelTimeout) {
|
||||
clearTimeout(requestCancelTimeout);
|
||||
}
|
||||
|
||||
const urlContents = await response.text();
|
||||
return urlContents;
|
||||
}
|
||||
|
||||
export async function fetchUrlAsGooglebot(url: string) {
|
||||
const abortController = new AbortController();
|
||||
const requestCancelTimeout = setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, MAX_RESPONSE_TIME_IN_MS);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)',
|
||||
return Promise.resolve({ kind: 'module', specifier, content });
|
||||
},
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (requestCancelTimeout) {
|
||||
clearTimeout(requestCancelTimeout);
|
||||
}
|
||||
|
||||
const urlContents = await response.text();
|
||||
return urlContents;
|
||||
return result.get(urlStr) || '';
|
||||
}
|
||||
|
||||
export async function fetchUrlWithProxy(url: string) {
|
||||
const abortController = new AbortController();
|
||||
const requestCancelTimeout = setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, MAX_RESPONSE_TIME_IN_MS);
|
||||
export async function serveFileWithTs(request: Request, filePath: string, extraHeaders?: ResponseInit['headers']) {
|
||||
const response = await serveFile(request, filePath);
|
||||
|
||||
const response = await fetch(`https://api.allorigins.win/raw?url=${url}`, {
|
||||
signal: abortController.signal,
|
||||
if (response.status !== 200) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const tsCode = await response.text();
|
||||
const jsCode = await transpileTs(tsCode, new URL('file:///src.ts'));
|
||||
const { headers } = response;
|
||||
headers.set('content-type', 'application/javascript; charset=utf-8');
|
||||
headers.delete('content-length');
|
||||
|
||||
return new Response(jsCode, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
...(extraHeaders || {}),
|
||||
});
|
||||
}
|
||||
|
||||
if (requestCancelTimeout) {
|
||||
clearTimeout(requestCancelTimeout);
|
||||
function transpileSass(content: string) {
|
||||
const compiler = sass(content);
|
||||
|
||||
return compiler.to_string('compressed') as string;
|
||||
}
|
||||
|
||||
export async function serveFileWithSass(request: Request, filePath: string, extraHeaders?: ResponseInit['headers']) {
|
||||
const response = await serveFile(request, filePath);
|
||||
|
||||
if (response.status !== 200) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const urlContents = await response.text();
|
||||
return urlContents;
|
||||
}
|
||||
|
||||
export async function fetchUrlWithRetries(url: string) {
|
||||
try {
|
||||
const text = await fetchUrl(url);
|
||||
return text;
|
||||
} catch (_error) {
|
||||
try {
|
||||
const text = await fetchUrlAsGooglebot(url);
|
||||
return text;
|
||||
} catch (_error) {
|
||||
const text = await fetchUrlWithProxy(url);
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function convertFormDataToObject(formData: FormData): Record<string, any> {
|
||||
return JSON.parse(JSON.stringify(Object.fromEntries(formData)));
|
||||
}
|
||||
|
||||
export function convertObjectToFormData(formDataObject: Record<string, any>): FormData {
|
||||
const formData = new FormData();
|
||||
|
||||
for (const key of Object.keys(formDataObject || {})) {
|
||||
if (Array.isArray(formDataObject[key])) {
|
||||
formData.append(key, formDataObject[key].join(','));
|
||||
} else {
|
||||
formData.append(key, formDataObject[key]);
|
||||
}
|
||||
}
|
||||
|
||||
return formData;
|
||||
}
|
||||
|
||||
export const capitalizeWord = (string: string) => {
|
||||
return `${string.charAt(0).toLocaleUpperCase()}${string.slice(1)}`;
|
||||
};
|
||||
|
||||
export function getRandomItem<T>(items: Readonly<Array<T>>): T {
|
||||
return items[Math.floor(Math.random() * items.length)];
|
||||
}
|
||||
|
||||
export function formatNumber(currency: SupportedCurrencySymbol, number: number) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currencyMap.get(currency) || 'USD',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(Number.parseFloat(`${number}`.replace(',', '.')));
|
||||
}
|
||||
|
||||
export function formatInputToNumber(numberInput: number | string): number {
|
||||
return Number.parseFloat(`${numberInput}`.replace(',', '.'));
|
||||
const sassCode = await response.text();
|
||||
const cssCode = transpileSass(sassCode);
|
||||
const { headers } = response;
|
||||
headers.set('content-type', 'text/css; charset=utf-8');
|
||||
headers.delete('content-length');
|
||||
|
||||
return new Response(cssCode, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
...(extraHeaders || {}),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { join } from 'std/path/join.ts';
|
||||
import { join } from '@std/path';
|
||||
import { lookup } from 'mrmime';
|
||||
|
||||
export function getProperDestinationPath(url: string) {
|
||||
|
|
|
|||
225
lib/utils/webdav_test.ts
Normal file
225
lib/utils/webdav_test.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { assertEquals } from '@std/assert';
|
||||
import { parse, stringify } from '@libs/xml';
|
||||
|
||||
import { addDavPrefixToKeys, getProperDestinationPath, getPropertyNames } from './webdav.ts';
|
||||
|
||||
Deno.test('that getProperDestinationPath works', () => {
|
||||
const tests: { input: string; expected?: string }[] = [
|
||||
{
|
||||
input: `http://127.0.0.1/dav/12345-abcde-67890`,
|
||||
expected: 'dav/12345-abcde-67890',
|
||||
},
|
||||
{
|
||||
input: `http://127.0.0.1/dav/spaced-%20uid`,
|
||||
expected: 'dav/spaced- uid',
|
||||
},
|
||||
{
|
||||
input: `http://127.0.0.1/dav/something-deeper/spaced-%C3%A7uid`,
|
||||
expected: 'dav/something-deeper/spaced-çuid',
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = getProperDestinationPath(test.input);
|
||||
if (test.expected) {
|
||||
assertEquals(output, test.expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that addDavPrefixToKeys works', () => {
|
||||
const tests: {
|
||||
input: { object: Record<string, any> | Record<string, any>[]; prefix?: string };
|
||||
expected: Record<string, any>;
|
||||
}[] = [
|
||||
{
|
||||
input: {
|
||||
object: {
|
||||
displayname: 'test',
|
||||
},
|
||||
},
|
||||
expected: {
|
||||
'D:displayname': 'test',
|
||||
},
|
||||
},
|
||||
{
|
||||
input: {
|
||||
object: [
|
||||
{ displayname: 'test' },
|
||||
{ color: 'black' },
|
||||
],
|
||||
},
|
||||
expected: [
|
||||
{ 'D:displayname': 'test' },
|
||||
{ 'D:color': 'black' },
|
||||
],
|
||||
},
|
||||
{
|
||||
input: {
|
||||
object: { '@version': '1.0', '@encoding': 'UTF-8', displayname: 'test', color: 'black' },
|
||||
},
|
||||
expected: {
|
||||
'@version': '1.0',
|
||||
'@encoding': 'UTF-8',
|
||||
'D:displayname': 'test',
|
||||
'D:color': 'black',
|
||||
},
|
||||
},
|
||||
{
|
||||
input: {
|
||||
object: { displayname: 'test', color: 'black' },
|
||||
prefix: 'S:',
|
||||
},
|
||||
expected: {
|
||||
'S:displayname': 'test',
|
||||
'S:color': 'black',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = addDavPrefixToKeys(test.input.object, test.input.prefix);
|
||||
if (test.expected) {
|
||||
assertEquals(output, test.expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that getPropertyNames works', () => {
|
||||
const tests: {
|
||||
input: Record<string, any>;
|
||||
expected: string[];
|
||||
}[] = [
|
||||
{
|
||||
input: {
|
||||
'D:propfind': {
|
||||
'D:prop': {
|
||||
'D:displayname': 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: ['displayname'],
|
||||
},
|
||||
{
|
||||
input: {
|
||||
'D:propfind': {
|
||||
'D:prop': {
|
||||
'D:displayname': 'test',
|
||||
'D:color': 'black',
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: ['displayname', 'color'],
|
||||
},
|
||||
{
|
||||
input: {},
|
||||
expected: ['allprop'],
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = getPropertyNames(test.input);
|
||||
if (test.expected) {
|
||||
assertEquals(output, test.expected);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that @libs/xml.parse works', () => {
|
||||
const tests: { input: string; expected: Record<string, any> }[] = [
|
||||
{
|
||||
input: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<propfind xmlns="DAV:">
|
||||
<prop>
|
||||
<displayname>test</displayname>
|
||||
</prop>
|
||||
</propfind>`,
|
||||
expected: {
|
||||
xml: {
|
||||
'@version': 1,
|
||||
'@encoding': 'UTF-8',
|
||||
},
|
||||
propfind: {
|
||||
'@xmlns': 'DAV:',
|
||||
prop: {
|
||||
displayname: 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
input: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:displayname>test</D:displayname>
|
||||
</D:prop>
|
||||
</D:propfind>`,
|
||||
expected: {
|
||||
xml: {
|
||||
'@version': 1,
|
||||
'@encoding': 'UTF-8',
|
||||
},
|
||||
'D:propfind': {
|
||||
'@xmlns:D': 'DAV:',
|
||||
'D:prop': {
|
||||
'D:displayname': 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = parse(test.input);
|
||||
assertEquals(output, test.expected);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test('that @libs/xml.stringify works', () => {
|
||||
const tests: { input: Record<string, any>; expected: string }[] = [
|
||||
{
|
||||
input: {
|
||||
xml: {
|
||||
'@version': '1.0',
|
||||
'@encoding': 'UTF-8',
|
||||
},
|
||||
'D:propfind': {
|
||||
'D:prop': {
|
||||
'D:displayname': 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<D:propfind>
|
||||
<D:prop>
|
||||
<D:displayname>test</D:displayname>
|
||||
</D:prop>
|
||||
</D:propfind>`,
|
||||
},
|
||||
{
|
||||
input: {
|
||||
xml: {
|
||||
'@version': '1.0',
|
||||
'@encoding': 'UTF-8',
|
||||
},
|
||||
'D:propfind': {
|
||||
'@xmlns:D': 'DAV:',
|
||||
'D:prop': {
|
||||
'D:displayname': 'test',
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<D:propfind xmlns:D="DAV:">
|
||||
<D:prop>
|
||||
<D:displayname>test</D:displayname>
|
||||
</D:prop>
|
||||
</D:propfind>`,
|
||||
},
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
const output = stringify(test.input);
|
||||
assertEquals(output, test.expected);
|
||||
}
|
||||
});
|
||||
151
main.ts
151
main.ts
|
|
@ -1,13 +1,144 @@
|
|||
/// <reference no-default-lib="true" />
|
||||
/// <reference lib="dom" />
|
||||
/// <reference lib="dom.iterable" />
|
||||
/// <reference lib="dom.asynciterable" />
|
||||
/// <reference lib="deno.ns" />
|
||||
import routes, { Route } from './routes.ts';
|
||||
import { startCrons } from './crons/index.ts';
|
||||
import { Page } from './lib/page.ts';
|
||||
import { AppConfig } from './lib/config.ts';
|
||||
|
||||
import 'std/dotenv/load.ts';
|
||||
const config = await AppConfig.getConfig();
|
||||
const MAX_REQUEST_SIZE_IN_MEGABYTES = config.core.maxRequestSizeInMegabytes;
|
||||
const MAX_UPLOAD_SIZE_IN_MEGABYTES = config.files.maxUploadSizeInMegabytes;
|
||||
const MAX_REQUEST_SIZE_IN_BYTES = MAX_REQUEST_SIZE_IN_MEGABYTES * 1024 * 1024;
|
||||
const MAX_UPLOAD_SIZE_IN_BYTES = MAX_UPLOAD_SIZE_IN_MEGABYTES * 1024 * 1024;
|
||||
|
||||
import { start } from 'fresh/server.ts';
|
||||
import manifest from './fresh.gen.ts';
|
||||
import config from './fresh.config.ts';
|
||||
function applyCorsHeadersToResponse(origin: string, response: Response) {
|
||||
const headers = response.headers;
|
||||
headers.set('Access-Control-Allow-Origin', origin);
|
||||
headers.set('Access-Control-Allow-Credentials', 'true');
|
||||
headers.set(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With',
|
||||
);
|
||||
headers.set(
|
||||
'Access-Control-Allow-Methods',
|
||||
'POST, OPTIONS, GET, PUT, DELETE',
|
||||
);
|
||||
if (!headers.get('content-security-policy')) {
|
||||
headers.set(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; child-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'",
|
||||
);
|
||||
}
|
||||
if (!headers.get('x-frame-options')) {
|
||||
headers.set('X-Frame-Options', 'DENY');
|
||||
}
|
||||
if (!headers.get('x-content-type-options')) {
|
||||
headers.set('X-Content-Type-Options', 'nosniff');
|
||||
}
|
||||
if (!headers.get('strict-transport-security')) {
|
||||
headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
|
||||
}
|
||||
}
|
||||
|
||||
await start(manifest, config);
|
||||
function handleLogging(request: Request, response: Response) {
|
||||
console.info(`${new Date().toISOString()} - [${response.status}] ${request.method} ${request.url}`);
|
||||
// NOTE: Uncomment when debugging WebDav/CardDav/CalDav stuff
|
||||
// if (request.url.includes('/dav') || request.url.includes('/carddav') || request.url.includes('/caldav')) {
|
||||
// console.info(`Request`, request.headers);
|
||||
// try {
|
||||
// console.info((await request.clone().text()) || '<No Body>');
|
||||
// } catch (_error) {
|
||||
// console.info('<No Body>');
|
||||
// }
|
||||
// console.info(`Response`, response.headers);
|
||||
// console.info(`Status`, response.status);
|
||||
// }
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function handler(request: Request) {
|
||||
const contentLength = request.headers.get('content-length');
|
||||
const path = new URL(request.url).pathname;
|
||||
|
||||
const isUploadRequest = path.startsWith('/api/files/upload') || path.startsWith('/dav');
|
||||
const maxSizeInBytes = isUploadRequest ? MAX_UPLOAD_SIZE_IN_BYTES : MAX_REQUEST_SIZE_IN_BYTES;
|
||||
|
||||
if (contentLength && parseInt(contentLength, 10) > maxSizeInBytes) {
|
||||
return new Response('Payload too large', { status: 413 });
|
||||
}
|
||||
|
||||
const origin = request.headers.get('Origin') || '*';
|
||||
|
||||
// CORS headers for non-DAV routes
|
||||
if (
|
||||
request.method == 'OPTIONS' && path !== '/dav' && !path.startsWith('/dav/') && path !== '/carddav' &&
|
||||
!path.startsWith('/carddav/') && path !== '/caldav' && !path.startsWith('/caldav/')
|
||||
) {
|
||||
const response = new Response(null, {
|
||||
status: 204,
|
||||
});
|
||||
const headers = response.headers;
|
||||
headers.set('Access-Control-Allow-Origin', origin);
|
||||
headers.set('Access-Control-Allow-Credentials', 'true');
|
||||
headers.set('Access-Control-Allow-Methods', 'POST, OPTIONS, GET, PUT, DELETE');
|
||||
headers.set(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With',
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
const routeKeys = Object.keys(routes);
|
||||
|
||||
for (const routeKey of routeKeys) {
|
||||
const route: Route = routes[routeKey];
|
||||
const match = route.pattern.exec(request.url);
|
||||
|
||||
if (match) {
|
||||
const response = await route.handler(request, match);
|
||||
|
||||
applyCorsHeadersToResponse(origin, response);
|
||||
handleLogging(request, response);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
const notFoundPage: Page = (await import(`/pages/404.ts`)).default;
|
||||
|
||||
const response = await notFoundPage.get!({
|
||||
request,
|
||||
match: new URLPattern({ pathname: '/' }).exec(request.url) as URLPatternResult,
|
||||
isRunningLocally: false,
|
||||
});
|
||||
|
||||
handleLogging(request, response);
|
||||
|
||||
return new Response(response.body, {
|
||||
status: 404,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
|
||||
async function notifyServiceManagerReady() {
|
||||
const socketAddress = Deno.env.get('NOTIFY_SOCKET');
|
||||
if (typeof socketAddress !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await (new Deno.Command('systemd-notify', {
|
||||
args: ['--ready', `MESSAGE=bewCloud is ready`],
|
||||
})).output();
|
||||
if (!result.success) {
|
||||
const output = new TextDecoder().decode(result.stderr);
|
||||
throw new Deno.errors.NotCapable(`Failed to execute “systemd-notify”: ${output} (code ${result.code})`);
|
||||
}
|
||||
}
|
||||
|
||||
export const abortController = new AbortController();
|
||||
|
||||
const PORT = Deno.env.get('PORT') || 8000;
|
||||
|
||||
Deno.serve({ port: PORT as number, signal: abortController.signal }, handler);
|
||||
|
||||
startCrons();
|
||||
notifyServiceManagerReady();
|
||||
|
|
|
|||
63
main_test.ts
63
main_test.ts
|
|
@ -1,47 +1,44 @@
|
|||
import { assert } from 'std/assert/assert.ts';
|
||||
import { assertEquals } from 'std/assert/assert_equals.ts';
|
||||
import { createHandler, ServeHandlerInfo } from 'fresh/server.ts';
|
||||
import { assertEquals } from '@std/assert';
|
||||
|
||||
import manifest from './fresh.gen.ts';
|
||||
import config from './fresh.config.ts';
|
||||
import { abortController } from './main.ts';
|
||||
|
||||
// @ts-ignore Deno's newer types seem to have messed this up
|
||||
const CONN_INFO: ServeHandlerInfo = {
|
||||
remoteAddr: { hostname: '127.0.0.1', port: 53496, transport: 'tcp' },
|
||||
};
|
||||
const baseUrl = 'http://localhost:8000';
|
||||
|
||||
Deno.test('Basic routes', async (testContext) => {
|
||||
const handler = await createHandler(manifest, config);
|
||||
|
||||
await testContext.step('#1 GET /', async () => {
|
||||
const response = await handler(new Request('http://127.0.0.1/'), CONN_INFO);
|
||||
assertEquals(response.status, 303);
|
||||
});
|
||||
|
||||
await testContext.step('#2 GET /login', async () => {
|
||||
const response = await handler(new Request('http://127.0.0.1/login'), CONN_INFO);
|
||||
const text = await response.text();
|
||||
assert(text.includes('bewCloud'));
|
||||
Deno.test({
|
||||
name: 'HTTP Server',
|
||||
fn: async () => {
|
||||
let response = await fetch(`${baseUrl}/login`);
|
||||
assertEquals(response.status, 200);
|
||||
});
|
||||
|
||||
await testContext.step('#3 GET /blah', async () => {
|
||||
const response = await handler(new Request('http://127.0.0.1/blah'), CONN_INFO);
|
||||
const text = await response.text();
|
||||
assert(text.includes('404 - Page not found'));
|
||||
let responseText = await response.text();
|
||||
assertEquals(responseText.includes('bewCloud'), true);
|
||||
|
||||
response = await fetch(`${baseUrl}/`, {
|
||||
redirect: 'manual',
|
||||
});
|
||||
assertEquals(response.status, 303);
|
||||
assertEquals(response.headers.get('Location'), `/login`);
|
||||
|
||||
response = await fetch(`${baseUrl}/blah`);
|
||||
assertEquals(response.status, 404);
|
||||
});
|
||||
|
||||
await testContext.step('#4 POST /login', async () => {
|
||||
responseText = await response.text();
|
||||
assertEquals(responseText.includes('404 - Page not found'), true);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('email', 'user@example.com');
|
||||
const request = new Request('http://127.0.0.1/login', {
|
||||
|
||||
response = await fetch(`${baseUrl}/login`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const response = await handler(request, CONN_INFO);
|
||||
const text = await response.text();
|
||||
assert(text.includes('Error: Password is too short'));
|
||||
assertEquals(response.status, 200);
|
||||
});
|
||||
|
||||
responseText = await response.text();
|
||||
assertEquals(responseText.includes('Error: Password is too short'), true);
|
||||
|
||||
abortController.abort('Test finished');
|
||||
},
|
||||
sanitizeResources: false,
|
||||
sanitizeOps: false,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import 'std/dotenv/load.ts';
|
||||
import '@std/dotenv/load';
|
||||
|
||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
|
||||
|
|
|
|||
10
pages/.well-known/caldav.ts
Normal file
10
pages/.well-known/caldav.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import page from '/lib/page.ts';
|
||||
|
||||
function get() {
|
||||
return new Response('Redirecting...', { status: 301, headers: { 'Location': '/caldav' } });
|
||||
}
|
||||
|
||||
export default page({
|
||||
get,
|
||||
accessMode: 'public',
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue