mirror of
https://github.com/bewcloud/bewcloud.git
synced 2026-03-11 08:54:49 +00:00
Compare commits
124 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 | ||
|
|
eabd888df2 | ||
|
|
4864c283b7 | ||
|
|
8ff0a434fd | ||
|
|
289f34fe8e | ||
|
|
820d1622f6 | ||
|
|
781df673dc | ||
|
|
5d324aac9e | ||
|
|
e0ad428a9f | ||
|
|
cb95085ea3 | ||
|
|
7fac7febcf | ||
|
|
c7d6b8077b | ||
|
|
111321e9c6 | ||
|
|
3038461fb7 | ||
|
|
717f55f0af | ||
|
|
ef75eb520c | ||
|
|
a675f76178 | ||
|
|
aa244c4ea9 | ||
|
|
aa18dcdb4e | ||
|
|
cabc18f15d | ||
|
|
455a7201e9 | ||
|
|
2a77915630 | ||
|
|
e337859a22 | ||
|
|
69142973d8 | ||
|
|
6cfb62d1a2 | ||
|
|
e1193a2770 | ||
|
|
b8866cdb39 | ||
|
|
1e1d3657a2 | ||
|
|
5467fb3533 | ||
|
|
9e05f591b8 | ||
|
|
0b5dd1ada7 | ||
|
|
df332802c0 | ||
|
|
05c20ec0a2 | ||
|
|
07bbfbb0a5 | ||
|
|
4faa7bd05d | ||
|
|
b3bd8cb3cc | ||
|
|
874ab006f9 | ||
|
|
869e712432 | ||
|
|
5c3af00c24 | ||
|
|
581ff2ccc9 | ||
|
|
2e995cc9f7 | ||
|
|
2a95ce06b0 | ||
|
|
8929b6e7d2 | ||
|
|
aaeaac0285 | ||
|
|
24c0c41902 | ||
|
|
6736bb898d | ||
|
|
e08188b58b | ||
|
|
63a39970fa | ||
|
|
5e5448a9e9 | ||
|
|
33ecce353e | ||
|
|
bbf8683004 | ||
|
|
7ef8220bb1 | ||
|
|
e0b8ea6e30 | ||
|
|
560ea569a0 | ||
|
|
0851dfbb44 | ||
|
|
13b76ba2e0 | ||
|
|
fbcaab53d0 | ||
|
|
8062df1bb5 | ||
|
|
c9444883e1 | ||
|
|
769ae5b9f1 | ||
|
|
4ef575671a | ||
|
|
b9c5d495f1 | ||
|
|
7f7901da35 | ||
|
|
d0c23a9746 | ||
|
|
4506febf03 |
471 changed files with 49249 additions and 5409 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: "15"
|
||||
11
.dockerignore
Normal file
11
.dockerignore
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
.github
|
||||
.git
|
||||
.gitignore
|
||||
docker-compose*
|
||||
Dockerfile
|
||||
LICENSE
|
||||
README.md
|
||||
.env.sample
|
||||
node_modules
|
||||
data-files
|
||||
bewcloud.config.sample.ts
|
||||
2
.dvmrc
2
.dvmrc
|
|
@ -1 +1 @@
|
|||
1.42.4
|
||||
2.6.10
|
||||
|
|
|
|||
14
.env.sample
14
.env.sample
|
|
@ -1,5 +1,4 @@
|
|||
PORT=8000
|
||||
BASE_URL="http://localhost:8000"
|
||||
|
||||
POSTGRESQL_HOST="postgresql" # docker container name or external hostname/IP
|
||||
POSTGRESQL_USER="postgres"
|
||||
|
|
@ -11,10 +10,11 @@ POSTGRESQL_CAFILE=""
|
|||
JWT_SECRET="fake"
|
||||
PASSWORD_SALT="fake"
|
||||
|
||||
BREVO_API_KEY="fake"
|
||||
MFA_KEY="fake" # optional, if you want to enable multi-factor authentication
|
||||
MFA_SALT="fake" # optional, if you want to enable multi-factor authentication
|
||||
|
||||
CONFIG_ALLOW_SIGNUPS="false"
|
||||
CONFIG_ENABLED_APPS="news,notes,photos" # dashboard and files cannot be disabled
|
||||
CONFIG_FILES_ROOT_PATH="data-files"
|
||||
CONFIG_ENABLE_EMAILS="false" # if true, email verification will be required for signups (using Brevo)
|
||||
CONFIG_ENABLE_FOREVER_SIGNUP="true" # if true, all signups become active for 100 years
|
||||
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="" # 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
|
||||
|
|
|
|||
3
.github/FUNDING.yml
vendored
3
.github/FUNDING.yml
vendored
|
|
@ -1,6 +1,7 @@
|
|||
github: [BrunoBernardino]
|
||||
github: [bewcloud, BrunoBernardino]
|
||||
custom:
|
||||
[
|
||||
'https://payment-links.mollie.com/payment/wUS9dvewvjEPvseZVHEi5',
|
||||
'https://paypal.me/brunobernardino',
|
||||
'https://gist.github.com/BrunoBernardino/ff5b54c13dd96ac7f9fee6fbfd825b09',
|
||||
]
|
||||
|
|
|
|||
15
.github/workflows/build-docker-image.yml
vendored
15
.github/workflows/build-docker-image.yml
vendored
|
|
@ -1,10 +1,10 @@
|
|||
name: "Build Docker Image"
|
||||
name: 'Build Docker Image'
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
- 'main'
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -37,9 +37,16 @@ jobs:
|
|||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
|
|
|
|||
4
.github/workflows/deploy.yml
vendored
4
.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/
|
||||
|
|
@ -30,4 +30,4 @@ jobs:
|
|||
SSH_KEY: ${{ secrets.SSH_KEY }}
|
||||
|
||||
- name: Deploy via SSH
|
||||
run: ssh server 'cd apps/bewcloud && git add . && git stash && git pull origin main && git stash clear && git remote prune origin && cp ../../scripts/config/bewcloud/.env . && cp ../../scripts/config/bewcloud/docker-compose.yml . && docker system prune -f && docker compose up -d --build && docker compose ps && docker compose logs'
|
||||
run: ssh server 'cd apps/bewcloud && git add . && git stash && git pull origin main && git stash clear && git remote prune origin && cp ../../scripts/config/bewcloud/.env . && cp ../../scripts/config/bewcloud/bewcloud.config.ts . && cp ../../scripts/config/bewcloud/docker-compose.yml . && docker system prune -f && docker compose up -d --build && docker compose ps && docker compose logs'
|
||||
|
|
|
|||
6
.github/workflows/test.yml
vendored
6
.github/workflows/test.yml
vendored
|
|
@ -6,10 +6,10 @@ jobs:
|
|||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: denoland/setup-deno@v1
|
||||
- uses: actions/checkout@v5
|
||||
- uses: denoland/setup-deno@v2
|
||||
with:
|
||||
deno-version: v1.42.4
|
||||
deno-version-file: .dvmrc
|
||||
- run: |
|
||||
make test
|
||||
make build
|
||||
|
|
|
|||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -1,6 +1,3 @@
|
|||
# Fresh build directory
|
||||
_fresh/
|
||||
|
||||
# npm dependencies
|
||||
node_modules/
|
||||
|
||||
|
|
@ -12,3 +9,9 @@ db/
|
|||
|
||||
# Files
|
||||
data-files/
|
||||
|
||||
# Config
|
||||
bewcloud.config.ts
|
||||
|
||||
# Radicale files
|
||||
data-radicale/
|
||||
|
|
|
|||
18
Dockerfile
18
Dockerfile
|
|
@ -1,25 +1,25 @@
|
|||
FROM denoland/deno:ubuntu-1.42.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
|
||||
|
|
|
|||
105
README.md
105
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,55 +10,85 @@ 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)
|
||||
|
||||
Or on your own machine:
|
||||
|
||||
Download/copy [`docker-compose.yml`](/docker-compose.yml) and [`.env.sample`](/.env.sample) as `.env`.
|
||||
Or, to run on your own machine, start with these commands:
|
||||
|
||||
```sh
|
||||
$ docker compose up # makes the app available at http://localhost:8000
|
||||
$ docker compose run website bash -c "cd /app && make migrate-db" # initializes/updates the database (only needs to be executed the first time and on any 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_ALLOW_SIGNUPS="false"`), the first signup will work and become an admin.
|
||||
> 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://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">
|
||||
<img src="https://nlnet.nl/logo/banner.svg" alt="NLnet Foundation" width="256" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Development
|
||||
|
||||
### Requirements
|
||||
|
||||
- Don't forget to set up your `.env` file based on `.env.sample`.
|
||||
> [!IMPORTANT]
|
||||
> Don't forget to set up your `.env` file based on `.env.sample`.
|
||||
> Don't forget to set up your `bewcloud.config.ts` file based on `bewcloud.config.sample.ts`.
|
||||
|
||||
- This was tested with [`Deno`](https://deno.land)'s version stated in the `.dvmrc` file, though other versions may work.
|
||||
- For the postgres dependency (used when running locally or in CI), you should have `Docker` and `docker compose` installed.
|
||||
|
||||
### 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/`.
|
||||
|
|
@ -67,21 +97,20 @@ $ make build # generates all static files for production deploy
|
|||
|
||||
Just push to the `main` branch.
|
||||
|
||||
## Where's Contacts/Calendar (CardDav/CalDav)?! Wasn't this supposed to be a core Nextcloud replacement?
|
||||
## FAQ (Frequently Asked Questions)
|
||||
|
||||
[Check this tag/release for more info and the code where/when that was being done](https://github.com/bewcloud/bewcloud/releases/tag/v0.0.1-self-made-carddav-caldav). Contacts/CardDav worked and Calendar/CalDav mostly worked as well at that point.
|
||||
|
||||
My focus was to get me to replace Nextcloud for me and my family ASAP, and it turns out it's not easy to do it all in a single, installable _thing_, so I focused on the Files UI, sync, and sharing, since [Radicale](https://radicale.org/v3.html) solved my other issues better than my own solution (and it's already _very_ efficient).
|
||||
|
||||
## How does file sharing work?
|
||||
|
||||
[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), 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,
|
||||
};
|
||||
51
bewcloud.config.sample.ts
Normal file
51
bewcloud.config.sample.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
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" (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
|
||||
enableMultiFactor: false, // If true, users can enable multi-factor authentication (TOTP, Passkeys, or Email if the SMTP settings below are set)
|
||||
// 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
|
||||
},
|
||||
// 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: ['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',
|
||||
// description: 'This is my own cloud!',
|
||||
// helpEmail: '',
|
||||
// },
|
||||
// email: {
|
||||
// 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://radicale:5232',
|
||||
// },
|
||||
// calendar: {
|
||||
// enableCalDavServer: true,
|
||||
// calDavUrl: 'http://radicale:5232',
|
||||
// },
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -1,11 +1,10 @@
|
|||
import { Head } from 'fresh/runtime.ts';
|
||||
|
||||
import { User } from '/lib/types.ts';
|
||||
import { isAppEnabled } from '/lib/config.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[];
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
|
|
@ -13,7 +12,7 @@ interface MenuItem {
|
|||
label: string;
|
||||
}
|
||||
|
||||
export default function Header({ route, user }: Data) {
|
||||
export default function Header({ route, user, enabledApps }: Data) {
|
||||
const activeClass = 'bg-slate-800 text-white rounded-md px-3 py-2 text-sm font-medium';
|
||||
const defaultClass = 'text-slate-300 hover:bg-slate-700 hover:text-white rounded-md px-3 py-2 text-sm font-medium';
|
||||
|
||||
|
|
@ -23,38 +22,14 @@ export default function Header({ route, user }: Data) {
|
|||
|
||||
const iconWidthAndHeightInPixels = 20;
|
||||
|
||||
const potentialMenuItems: (MenuItem | null)[] = [
|
||||
{
|
||||
url: '/dashboard',
|
||||
label: 'Dashboard',
|
||||
},
|
||||
isAppEnabled('news')
|
||||
? {
|
||||
url: '/news',
|
||||
label: 'News',
|
||||
}
|
||||
: null,
|
||||
{
|
||||
url: '/files',
|
||||
label: 'Files',
|
||||
},
|
||||
isAppEnabled('notes')
|
||||
? {
|
||||
url: '/notes',
|
||||
label: 'Notes',
|
||||
}
|
||||
: null,
|
||||
isAppEnabled('photos')
|
||||
? {
|
||||
url: '/photos',
|
||||
label: 'Photos',
|
||||
}
|
||||
: 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';
|
||||
|
|
@ -67,18 +42,31 @@ export default function Header({ route, user }: Data) {
|
|||
pageLabel = 'Settings';
|
||||
}
|
||||
|
||||
if (route.startsWith('/expenses')) {
|
||||
pageLabel = 'Budgets & Expenses';
|
||||
}
|
||||
|
||||
if (route.startsWith('/contacts')) {
|
||||
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'>
|
||||
|
|
@ -86,7 +74,7 @@ export default function Header({ route, user }: 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}
|
||||
|
|
@ -107,7 +95,7 @@ export default function Header({ route, user }: Data) {
|
|||
class={route.startsWith('/settings') ? activeClass : defaultClass}
|
||||
>
|
||||
<img
|
||||
src='/images/settings.svg'
|
||||
src='/public/images/settings.svg'
|
||||
alt='Settings'
|
||||
title='Settings'
|
||||
width={iconWidthAndHeightInPixels}
|
||||
|
|
@ -120,7 +108,7 @@ export default function Header({ route, user }: Data) {
|
|||
class={defaultClass}
|
||||
>
|
||||
<img
|
||||
src='/images/logout.svg'
|
||||
src='/public/images/logout.svg'
|
||||
alt='Logout'
|
||||
title='Logout'
|
||||
width={iconWidthAndHeightInPixels}
|
||||
|
|
@ -156,11 +144,11 @@ export default function Header({ route, user }: 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>
|
||||
`;
|
||||
}
|
||||
614
components/auth/MultiFactorAuthSettings.tsx
Normal file
614
components/auth/MultiFactorAuthSettings.tsx
Normal file
|
|
@ -0,0 +1,614 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
|
||||
import { MultiFactorAuthMethodType } from '/lib/types.ts';
|
||||
import { ResponseBody as PasskeySetupBeginResponseBody } from '/pages/api/auth/multi-factor/passkey/setup-begin.ts';
|
||||
import {
|
||||
RequestBody as PasskeySetupCompleteRequestBody,
|
||||
ResponseBody as PasskeySetupCompleteResponseBody,
|
||||
} 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 '/pages/api/auth/multi-factor/enable.ts';
|
||||
import {
|
||||
RequestBody as MultiFactorAuthDisableRequestBody,
|
||||
ResponseBody as MultiFactorAuthDisableResponseBody,
|
||||
} from '/pages/api/auth/multi-factor/disable.ts';
|
||||
|
||||
interface MultiFactorAuthMethod {
|
||||
type: MultiFactorAuthMethodType;
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
backupCodesCount?: number;
|
||||
}
|
||||
|
||||
interface MultiFactorAuthSettingsProps {
|
||||
methods: MultiFactorAuthMethod[];
|
||||
}
|
||||
|
||||
interface TOTPSetupData {
|
||||
type: 'totp';
|
||||
secret: string;
|
||||
qrCodeUrl: string;
|
||||
backupCodes: string[];
|
||||
methodId: string;
|
||||
}
|
||||
|
||||
interface PasskeySetupData {
|
||||
methodId: string;
|
||||
type: 'passkey';
|
||||
}
|
||||
|
||||
interface EmailSetupData {
|
||||
methodId: string;
|
||||
type: 'email';
|
||||
}
|
||||
|
||||
const methodTypeLabels: Record<MultiFactorAuthMethodType, string> = {
|
||||
totp: 'Authenticator App',
|
||||
passkey: 'Passkey',
|
||||
email: 'Email',
|
||||
};
|
||||
|
||||
const methodTypeDescriptions: Record<MultiFactorAuthMethodType, string> = {
|
||||
totp: 'Use an authenticator app like Aegis Authenticator or Google Authenticator to generate codes.',
|
||||
passkey: 'Use biometric authentication or security keys.',
|
||||
email: 'Receive codes in your email.',
|
||||
};
|
||||
|
||||
const availableMethodTypes = ['totp', 'passkey', 'email'] as MultiFactorAuthMethodType[];
|
||||
|
||||
export default function MultiFactorAuthSettings({ methods }: MultiFactorAuthSettingsProps) {
|
||||
const setupData = useSignal<TOTPSetupData | PasskeySetupData | EmailSetupData | null>(null);
|
||||
const isLoading = useSignal(false);
|
||||
const error = useSignal<string | null>(null);
|
||||
const success = useSignal<string | null>(null);
|
||||
const showDisableForm = useSignal<'all' | string | null>(null);
|
||||
const verificationToken = useSignal('');
|
||||
const disablePassword = useSignal('');
|
||||
|
||||
const enabledMethods = methods.filter((method) => method.enabled);
|
||||
const hasMultiFactorAuthEnabled = enabledMethods.length > 0;
|
||||
|
||||
const setupPasskey = async () => {
|
||||
const beginResponse = await fetch('/api/auth/multi-factor/passkey/setup-begin', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!beginResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to begin passkey registration! ${beginResponse.statusText} ${await beginResponse.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const beginData = await beginResponse.json() as PasskeySetupBeginResponseBody;
|
||||
|
||||
if (!beginData.success) {
|
||||
throw new Error(beginData.error || 'Failed to begin passkey registration.');
|
||||
}
|
||||
|
||||
const registrationResponse = await startRegistration({ optionsJSON: beginData.options! });
|
||||
|
||||
const completeRequestBody: PasskeySetupCompleteRequestBody = {
|
||||
methodId: beginData.sessionData!.methodId,
|
||||
challenge: beginData.sessionData!.challenge,
|
||||
registrationResponse,
|
||||
};
|
||||
|
||||
const completeResponse = await fetch('/api/auth/multi-factor/passkey/setup-complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(completeRequestBody),
|
||||
});
|
||||
|
||||
if (!completeResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to complete passkey registration! ${completeResponse.statusText} ${await completeResponse.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const completeData = await completeResponse.json() as PasskeySetupCompleteResponseBody;
|
||||
|
||||
if (!completeData.success) {
|
||||
throw new Error(completeData.error || 'Failed to complete passkey registration.');
|
||||
}
|
||||
|
||||
setupData.value = {
|
||||
methodId: beginData.sessionData!.methodId,
|
||||
type: 'passkey',
|
||||
};
|
||||
};
|
||||
|
||||
const setupTOTP = async () => {
|
||||
const response = await fetch('/api/auth/multi-factor/totp/setup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to setup TOTP multi-factor authentication. ${response.statusText} ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json() as TOTPSetupResponseBody;
|
||||
|
||||
if (!data.success || !data.data) {
|
||||
throw new Error(data.error || 'Failed to setup TOTP multi-factor authentication.');
|
||||
}
|
||||
|
||||
setupData.value = {
|
||||
type: 'totp',
|
||||
secret: data.data.secret!,
|
||||
qrCodeUrl: data.data.qrCodeUrl!,
|
||||
backupCodes: data.data.backupCodes!,
|
||||
methodId: data.data.methodId!,
|
||||
};
|
||||
};
|
||||
|
||||
const setupEmail = async () => {
|
||||
const response = await fetch('/api/auth/multi-factor/email/setup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to setup email multi-factor authentication. Please check your SMTP settings are valid and try again. ${response.statusText} ${await response
|
||||
.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json() as EmailSetupResponseBody;
|
||||
|
||||
if (!data.success || !data.data) {
|
||||
throw new Error(data.error || 'Failed to setup email multi-factor authentication.');
|
||||
}
|
||||
|
||||
setupData.value = {
|
||||
type: 'email',
|
||||
methodId: data.data.methodId!,
|
||||
};
|
||||
};
|
||||
|
||||
const setupMultiFactorAuth = async (type: MultiFactorAuthMethodType) => {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
if (type === 'totp') {
|
||||
await setupTOTP();
|
||||
} else if (type === 'passkey') {
|
||||
await setupPasskey();
|
||||
} else if (type === 'email') {
|
||||
await setupEmail();
|
||||
}
|
||||
} catch (setupError) {
|
||||
error.value = (setupError as Error).message;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const enableMultiFactorAuth = async () => {
|
||||
if (!setupData.value) {
|
||||
error.value = 'No setup data available';
|
||||
return;
|
||||
}
|
||||
|
||||
if (setupData.value.type !== 'passkey' && !verificationToken.value) {
|
||||
error.value = 'Please enter a verification code/token';
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const requestBody: MultiFactorAuthEnableRequestBody = {
|
||||
methodId: setupData.value.methodId,
|
||||
code: setupData.value.type === 'passkey' ? 'passkey-verified' : verificationToken.value,
|
||||
};
|
||||
|
||||
const response = await fetch('/api/auth/multi-factor/enable', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to enable multi-factor authentication method. ${response.statusText} ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json() as MultiFactorAuthEnableResponseBody;
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to enable multi-factor authentication method.');
|
||||
}
|
||||
|
||||
success.value = 'Multi-factor authentication method has been enabled successfully! Reloading...';
|
||||
setupData.value = null;
|
||||
verificationToken.value = '';
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} catch (enableError) {
|
||||
error.value = (enableError as Error).message;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const disableMultiFactorAuth = async (methodId?: string, disableAll = false) => {
|
||||
if (!disablePassword.value) {
|
||||
error.value = 'Please enter your password';
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const requestBody: MultiFactorAuthDisableRequestBody = {
|
||||
methodId,
|
||||
password: disablePassword.value,
|
||||
disableAll,
|
||||
};
|
||||
|
||||
const response = await fetch('/api/auth/multi-factor/disable', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to disable multi-factor authentication method. ${response.statusText} ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json() as MultiFactorAuthDisableResponseBody;
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to disable multi-factor authentication method.');
|
||||
}
|
||||
|
||||
success.value = 'Multi-factor authentication method has been disabled successfully! Reloading...';
|
||||
showDisableForm.value = null;
|
||||
disablePassword.value = '';
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} catch (disableError) {
|
||||
error.value = (disableError as Error).message;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelSetup = () => {
|
||||
setupData.value = null;
|
||||
verificationToken.value = '';
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
const cancelDisable = () => {
|
||||
showDisableForm.value = null;
|
||||
disablePassword.value = '';
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
return (
|
||||
<section class='mb-16'>
|
||||
<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-3xl mx-auto lg:min-w-96'>
|
||||
{error.value
|
||||
? (
|
||||
<section class='notification-error mb-4'>
|
||||
<p>{error.value}</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
{success.value
|
||||
? (
|
||||
<section class='notification-success mb-4'>
|
||||
<p>{success.value}</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
<p class='mb-6'>
|
||||
Multi-factor authentication adds an extra layer of security to your account by requiring additional
|
||||
verification beyond your password.
|
||||
</p>
|
||||
|
||||
{availableMethodTypes
|
||||
.filter((type) => !enabledMethods.some((method) => method.type === type)).length > 0
|
||||
? (
|
||||
<section class='mb-6 mt-4'>
|
||||
<h3 class='text-lg font-semibold mb-4'>
|
||||
Available Authentication Methods
|
||||
</h3>
|
||||
<section class='space-y-4'>
|
||||
{availableMethodTypes
|
||||
.filter((type) =>
|
||||
!enabledMethods.some((method) => method.type === type) && setupData.value?.type !== type
|
||||
)
|
||||
.map((type) => (
|
||||
<section key={type} class='border rounded-lg p-4'>
|
||||
<section class='flex items-center justify-between'>
|
||||
<section>
|
||||
<h4 class='font-medium'>{methodTypeLabels[type]}</h4>
|
||||
<p class='text-sm text-gray-400'>{methodTypeDescriptions[type]}</p>
|
||||
</section>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setupMultiFactorAuth(type)}
|
||||
disabled={isLoading.value}
|
||||
class='button-secondary'
|
||||
>
|
||||
{isLoading.value ? '...' : 'Add'}
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
))}
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
{setupData.value && setupData.value.type === 'totp'
|
||||
? (
|
||||
<section class='mb-6'>
|
||||
<h3 class='text-lg font-semibold mb-4'>Setup Authenticator App</h3>
|
||||
|
||||
<section class='mb-6'>
|
||||
<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 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:{' '}
|
||||
<code class='bg-gray-200 px-2 py-1 rounded text-gray-900'>{setupData.value.secret}</code>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class='mb-6'>
|
||||
<p class='mb-4'>
|
||||
2. Save these backup codes <strong class='font-bold text-sky-500'>NOW</strong> in a safe place:
|
||||
</p>
|
||||
<section class='bg-gray-200 border rounded p-4 font-mono text-sm text-gray-900'>
|
||||
{setupData.value.backupCodes.map((code, index) => <section key={index} class='mb-1'>{code}</section>)}
|
||||
</section>
|
||||
<p class='text-sm text-gray-400 mt-2'>
|
||||
These codes can be used to access your account if you lose your authenticator device.{' '}
|
||||
<strong class='font-bold text-sky-500'>They won't be visible again</strong>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<fieldset class='block mb-6'>
|
||||
<label class='text-slate-300 block pb-1'>
|
||||
3. Enter the 6-digit code from your authenticator app:
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={verificationToken.value}
|
||||
onInput={(event) => verificationToken.value = (event.target as HTMLInputElement).value}
|
||||
placeholder='123456'
|
||||
class='mt-1 input-field'
|
||||
maxLength={6}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<section class='flex justify-end gap-2 mt-8 mb-4'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={cancelSetup}
|
||||
disabled={isLoading.value}
|
||||
class='button-secondary'
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={enableMultiFactorAuth}
|
||||
disabled={isLoading.value || !verificationToken.value}
|
||||
class='button'
|
||||
>
|
||||
{isLoading.value ? 'Enabling...' : 'Enable TOTP MFA'}
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
{setupData.value && setupData.value.type === 'passkey'
|
||||
? (
|
||||
<section class='mb-6'>
|
||||
<h3 class='text-lg font-semibold mb-4'>Passkey Setup Complete</h3>
|
||||
<p class='mb-4'>
|
||||
Your passkey has been successfully registered! You can now enable it for multi-factor authentication.
|
||||
</p>
|
||||
|
||||
<section class='flex justify-end gap-2 mt-8 mb-4'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={cancelSetup}
|
||||
disabled={isLoading.value}
|
||||
class='button-secondary'
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={enableMultiFactorAuth}
|
||||
disabled={isLoading.value}
|
||||
class='button'
|
||||
>
|
||||
{isLoading.value ? 'Enabling...' : 'Enable Passkey MFA'}
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
{setupData.value && setupData.value.type === 'email'
|
||||
? (
|
||||
<section class='mb-6'>
|
||||
<h3 class='text-lg font-semibold mb-4'>Setup Email</h3>
|
||||
|
||||
<fieldset class='block mb-6'>
|
||||
<label class='text-slate-300 block pb-1'>
|
||||
Enter the 6-digit code you received in your email:
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={verificationToken.value}
|
||||
onInput={(event) => verificationToken.value = (event.target as HTMLInputElement).value}
|
||||
placeholder='123456'
|
||||
class='mt-1 input-field'
|
||||
maxLength={6}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<section class='flex justify-end gap-2 mt-8 mb-4'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={cancelSetup}
|
||||
disabled={isLoading.value}
|
||||
class='button-secondary'
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={enableMultiFactorAuth}
|
||||
disabled={isLoading.value || !verificationToken.value}
|
||||
class='button'
|
||||
>
|
||||
{isLoading.value ? 'Enabling...' : 'Enable Email MFA'}
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
{hasMultiFactorAuthEnabled && !showDisableForm.value
|
||||
? (
|
||||
<section>
|
||||
<section class='mb-6'>
|
||||
<h3 class='text-lg font-semibold mb-4'>Active Authentication Methods</h3>
|
||||
|
||||
{enabledMethods.map((method) => (
|
||||
<section key={method.id} class='border rounded-lg p-4 mb-4'>
|
||||
<section class='flex items-center justify-between'>
|
||||
<section>
|
||||
<section
|
||||
class={`flex items-center ${
|
||||
method.type === 'totp' && typeof method.backupCodesCount !== 'undefined' ? 'mb-2' : ''
|
||||
}`}
|
||||
>
|
||||
<span class='inline-block w-3 h-3 bg-green-500 rounded-full mr-2'></span>
|
||||
<span class='font-medium'>{method.name}</span>
|
||||
</section>
|
||||
{method.type === 'totp' && typeof method.backupCodesCount !== 'undefined'
|
||||
? (
|
||||
<p class='text-sm text-gray-600'>
|
||||
{method.backupCodesCount > 0
|
||||
? `${method.backupCodesCount} backup codes remaining`
|
||||
: 'No backup codes remaining'}
|
||||
</p>
|
||||
)
|
||||
: null}
|
||||
</section>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => showDisableForm.value = method.id}
|
||||
class='button-secondary'
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section class='flex justify-end mt-8 mb-4'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => showDisableForm.value = 'all'}
|
||||
class='button-danger'
|
||||
>
|
||||
Disable All MFA
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
{showDisableForm.value
|
||||
? (
|
||||
<section class='mb-6'>
|
||||
<h3 class='text-lg font-semibold mb-4'>
|
||||
{showDisableForm.value === 'all'
|
||||
? 'Disable All Multi-Factor Authentication'
|
||||
: 'Disable Authentication Method'}
|
||||
</h3>
|
||||
<p class='mb-4'>
|
||||
{showDisableForm.value === 'all'
|
||||
? 'This will disable all multi-factor authentication methods and make your account less secure.'
|
||||
: 'This will disable this authentication method.'} Please enter your password to confirm.
|
||||
</p>
|
||||
|
||||
<fieldset class='block mb-4'>
|
||||
<label class='text-slate-300 block pb-1'>Password</label>
|
||||
<input
|
||||
type='password'
|
||||
value={disablePassword.value}
|
||||
onInput={(event) => disablePassword.value = (event.target as HTMLInputElement).value}
|
||||
placeholder='Enter your password'
|
||||
class='mt-1 input-field'
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<section class='flex justify-end gap-2 mt-8 mb-4'>
|
||||
<button
|
||||
type='button'
|
||||
onClick={cancelDisable}
|
||||
disabled={isLoading.value}
|
||||
class='button-secondary'
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
disableMultiFactorAuth(
|
||||
showDisableForm.value === 'all' ? undefined : showDisableForm.value || undefined,
|
||||
showDisableForm.value === 'all',
|
||||
)}
|
||||
disabled={isLoading.value || !disablePassword.value}
|
||||
class='button-danger'
|
||||
>
|
||||
{isLoading.value ? 'Disabling...' : 'Disable'}
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
137
components/auth/MultiFactorAuthVerifyForm.tsx
Normal file
137
components/auth/MultiFactorAuthVerifyForm.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { MultiFactorAuthMethodType } from '/lib/types.ts';
|
||||
import PasswordlessPasskeyLogin from '/components/auth/PasswordlessPasskeyLogin.tsx';
|
||||
|
||||
interface MultiFactorAuthVerifyFormProps {
|
||||
email: string;
|
||||
redirectUrl: string;
|
||||
availableMethods: MultiFactorAuthMethodType[];
|
||||
error?: { title: string; message: string };
|
||||
}
|
||||
|
||||
export default function MultiFactorAuthVerifyForm(
|
||||
{ email, redirectUrl, availableMethods, error }: MultiFactorAuthVerifyFormProps,
|
||||
) {
|
||||
const hasPasskey = availableMethods.includes('passkey');
|
||||
const hasTotp = availableMethods.includes('totp');
|
||||
const hasEmail = availableMethods.includes('email');
|
||||
|
||||
return (
|
||||
<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
|
||||
</h2>
|
||||
<p class='mt-2 text-center text-sm text-gray-300'>
|
||||
You are required to authenticate with an additional method
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{error
|
||||
? (
|
||||
<section class='notification-error'>
|
||||
<h3>{error.title}</h3>
|
||||
<p>{error.message}</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
{hasEmail
|
||||
? (
|
||||
<form
|
||||
class='mb-6'
|
||||
method='POST'
|
||||
action={`/mfa-verify?redirect=${encodeURIComponent(redirectUrl)}`}
|
||||
>
|
||||
<fieldset class='block mb-4'>
|
||||
<label class='text-slate-300 block pb-1' for='token'>
|
||||
Email Verification Code
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='code'
|
||||
name='code'
|
||||
placeholder='123456'
|
||||
class='mt-1 input-field'
|
||||
autocomplete='off'
|
||||
required
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<section class='flex justify-center mt-8 mb-4'>
|
||||
<button
|
||||
type='submit'
|
||||
class='button'
|
||||
>
|
||||
Verify Code
|
||||
</button>
|
||||
</section>
|
||||
</form>
|
||||
)
|
||||
: null}
|
||||
|
||||
{hasEmail && hasTotp
|
||||
? (
|
||||
<section class='text-center -mt-10 mb-6 block'>
|
||||
<p class='text-gray-400 text-sm'>or</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
{hasTotp
|
||||
? (
|
||||
<form
|
||||
class='mb-6'
|
||||
method='POST'
|
||||
action={`/mfa-verify?redirect=${encodeURIComponent(redirectUrl)}`}
|
||||
>
|
||||
<fieldset class='block mb-4'>
|
||||
<label class='text-slate-300 block pb-1' for='token'>
|
||||
Authentication Token or Backup Code
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
id='token'
|
||||
name='token'
|
||||
placeholder='123456 or backup code'
|
||||
class='mt-1 input-field'
|
||||
autocomplete='one-time-code'
|
||||
required
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<section class='flex justify-center mt-8 mb-4'>
|
||||
<button
|
||||
type='submit'
|
||||
class='button'
|
||||
>
|
||||
Verify Code
|
||||
</button>
|
||||
</section>
|
||||
</form>
|
||||
)
|
||||
: null}
|
||||
|
||||
{(hasEmail || hasTotp) && hasPasskey
|
||||
? (
|
||||
<section class='text-center -mt-10 mb-6 block'>
|
||||
<p class='text-gray-400 text-sm'>or</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
{hasPasskey && email
|
||||
? (
|
||||
<section class='mb-8'>
|
||||
<PasswordlessPasskeyLogin email={email} redirectUrl={redirectUrl} />
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
<section class='text-center mt-6'>
|
||||
<a href='/login' class='text-blue-400 hover:text-blue-300 text-sm'>
|
||||
Back to Login
|
||||
</a>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
609
components/contacts/Contacts.tsx
Normal file
609
components/contacts/Contacts.tsx
Normal file
|
|
@ -0,0 +1,609 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { AddressBook, Contact } from '/lib/models/contacts.ts';
|
||||
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 '/pages/api/contacts/add-addressbook.ts';
|
||||
import {
|
||||
RequestBody as DeleteAddressBookRequestBody,
|
||||
ResponseBody as DeleteAddressBookResponseBody,
|
||||
} from '/pages/api/contacts/delete-addressbook.ts';
|
||||
|
||||
interface ContactsProps {
|
||||
initialAddressBookId: string;
|
||||
initialContacts: Contact[];
|
||||
initialAddressBooks: AddressBook[];
|
||||
page: number;
|
||||
contactsCount: number;
|
||||
baseUrl: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
const CONTACTS_PER_PAGE_COUNT = 10; // This helps make the UI a bit faster (less stuff to render)
|
||||
|
||||
export default function Contacts(
|
||||
{ initialContacts, initialAddressBooks, page, contactsCount, search, initialAddressBookId, baseUrl }: ContactsProps,
|
||||
) {
|
||||
const isAdding = useSignal<boolean>(false);
|
||||
const isDeleting = useSignal<boolean>(false);
|
||||
const isExporting = useSignal<boolean>(false);
|
||||
const isImporting = useSignal<boolean>(false);
|
||||
const contacts = useSignal<Contact[]>(initialContacts);
|
||||
const addressBooks = useSignal<AddressBook[]>(initialAddressBooks);
|
||||
const selectedAddressBookId = useSignal<string>(initialAddressBookId);
|
||||
const selectedAddressBookName = useSignal<string>(
|
||||
initialAddressBooks.find((addressBook) => addressBook.uid === initialAddressBookId)?.displayName || 'Address Book',
|
||||
);
|
||||
const isAddressBooksDropdownOpen = useSignal<boolean>(false);
|
||||
const isOptionsDropdownOpen = useSignal<boolean>(false);
|
||||
|
||||
async function onClickAddContact() {
|
||||
if (isAdding.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const firstName = (prompt(`What's the **first name** for the new contact?`) || '').trim();
|
||||
|
||||
if (!firstName) {
|
||||
alert('A first name is required for a new contact!');
|
||||
return;
|
||||
}
|
||||
|
||||
const lastName = (prompt(`What's the **last name** for the new contact?`) || '').trim();
|
||||
|
||||
isAdding.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: AddRequestBody = { firstName, lastName, addressBookId: selectedAddressBookId.value };
|
||||
const response = await fetch(`/api/contacts/add`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to add contact. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as AddResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to add contact!');
|
||||
}
|
||||
|
||||
contacts.value = [...result.contacts];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isAdding.value = false;
|
||||
}
|
||||
|
||||
function toggleOptionsDropdown() {
|
||||
isOptionsDropdownOpen.value = !isOptionsDropdownOpen.value;
|
||||
}
|
||||
|
||||
async function onClickAddAddressBook() {
|
||||
if (isAdding.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = (prompt(`What's the **name** for the new address book?`) || '').trim();
|
||||
|
||||
if (!name) {
|
||||
alert('A name is required for a new address book!');
|
||||
return;
|
||||
}
|
||||
|
||||
isAdding.value = true;
|
||||
isAddressBooksDropdownOpen.value = false;
|
||||
|
||||
try {
|
||||
const requestBody: AddAddressBookRequestBody = { name };
|
||||
const response = await fetch(`/api/contacts/add-addressbook`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to add address book. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as AddAddressBookResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to add address book!');
|
||||
}
|
||||
|
||||
addressBooks.value = [...result.addressBooks];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isAdding.value = false;
|
||||
}
|
||||
|
||||
function toggleAddressBooksDropdown() {
|
||||
isAddressBooksDropdownOpen.value = !isAddressBooksDropdownOpen.value;
|
||||
}
|
||||
|
||||
function onClickSelectAddressBook(addressBookId: string) {
|
||||
selectedAddressBookId.value = addressBookId;
|
||||
selectedAddressBookName.value =
|
||||
addressBooks.value.find((addressBook) => addressBook.uid === addressBookId)?.displayName ||
|
||||
'Address Book';
|
||||
isAddressBooksDropdownOpen.value = false;
|
||||
window.location.href = `/contacts?addressBookId=${addressBookId}`;
|
||||
}
|
||||
|
||||
async function onClickDeleteAddressBook(addressBookId: string) {
|
||||
if (confirm('Are you sure you want to delete this address book?')) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteAddressBookRequestBody = { addressBookId };
|
||||
const response = await fetch(`/api/contacts/delete-addressbook`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete address book. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as DeleteAddressBookResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete address book!');
|
||||
}
|
||||
|
||||
addressBooks.value = [...result.addressBooks];
|
||||
selectedAddressBookId.value = '';
|
||||
selectedAddressBookName.value = '';
|
||||
window.location.href = `/contacts`;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onClickDeleteContact(contactId: string) {
|
||||
if (confirm('Are you sure you want to delete this contact?')) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteRequestBody = { contactId, addressBookId: selectedAddressBookId.value };
|
||||
const response = await fetch(`/api/contacts/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete contact. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as DeleteResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete contact!');
|
||||
}
|
||||
|
||||
contacts.value = [...result.contacts];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onClickImportVCard() {
|
||||
isOptionsDropdownOpen.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;
|
||||
|
||||
try {
|
||||
const vCards = importFileContents!.toString();
|
||||
|
||||
const requestBody: ImportRequestBody = { addressBookId: selectedAddressBookId.value, vCards };
|
||||
const response = await fetch(`/api/contacts/import`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to import contact. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as ImportResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to import contact!');
|
||||
}
|
||||
|
||||
contacts.value = [...result.contacts];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isImporting.value = false;
|
||||
};
|
||||
|
||||
reader.readAsText(file, 'UTF-8');
|
||||
};
|
||||
}
|
||||
|
||||
async function onClickExportVCard() {
|
||||
isOptionsDropdownOpen.value = false;
|
||||
|
||||
if (isExporting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isExporting.value = true;
|
||||
|
||||
const fileName = ['contacts-', new Date().toISOString().substring(0, 19).replace(/:/g, '-'), '.vcf']
|
||||
.join('');
|
||||
|
||||
try {
|
||||
const requestBody: GetRequestBody = { addressBookId: selectedAddressBookId.value };
|
||||
const response = await fetch(`/api/contacts/get`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to export contact. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as GetResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to get contact!');
|
||||
}
|
||||
|
||||
const exportContents = result.contacts.map((contact) => contact.data).join('\n\n');
|
||||
|
||||
// Add content-type
|
||||
const vCardContent = ['data:text/vcard; 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;
|
||||
}
|
||||
|
||||
const pagesCount = Math.ceil(contactsCount / CONTACTS_PER_PAGE_COUNT);
|
||||
const pages = Array.from({ length: pagesCount }).map((_value, index) => index + 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section class='flex flex-row items-center justify-between mb-4'>
|
||||
<section class='relative inline-block text-left mr-2'>
|
||||
<form method='GET' action={`/contacts?addressBookId=${selectedAddressBookId.value}`} class='m-0 p-0'>
|
||||
<input
|
||||
class='input-field w-60'
|
||||
type='search'
|
||||
name='search'
|
||||
value={search}
|
||||
placeholder='Search contacts...'
|
||||
/>
|
||||
</form>
|
||||
</section>
|
||||
<section class='flex items-center'>
|
||||
<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 truncate'
|
||||
id='select-address-book-button'
|
||||
aria-expanded='true'
|
||||
aria-haspopup='true'
|
||||
onClick={() => toggleAddressBooksDropdown()}
|
||||
>
|
||||
{selectedAddressBookName.value}
|
||||
<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 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'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='select-address-book-button'
|
||||
tabindex={-1}
|
||||
>
|
||||
{addressBooks.value.length > 1
|
||||
? (
|
||||
<div class='py-1'>
|
||||
{addressBooks.value.filter((addressBook) => addressBook.uid !== selectedAddressBookId.value).map((
|
||||
addressBook,
|
||||
) => (
|
||||
<button
|
||||
type='button'
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600 truncate`}
|
||||
onClick={() => onClickSelectAddressBook(addressBook.uid!)}
|
||||
>
|
||||
{addressBook.displayName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
<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={() => onClickAddAddressBook()}
|
||||
>
|
||||
New Address Book
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-red-600`}
|
||||
onClick={() => onClickDeleteAddressBook(selectedAddressBookId.value)}
|
||||
>
|
||||
Delete "{selectedAddressBookName.value}"
|
||||
</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={() => toggleOptionsDropdown()}
|
||||
>
|
||||
VCF
|
||||
<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 ${
|
||||
!isOptionsDropdownOpen.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={() => onClickImportVCard()}
|
||||
>
|
||||
Import vCard
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickExportVCard()}
|
||||
>
|
||||
Export vCard
|
||||
</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 contact'
|
||||
onClick={() => onClickAddContact()}
|
||||
>
|
||||
<img
|
||||
src='/public/images/add.svg'
|
||||
alt='Add new contact'
|
||||
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-slate-500 shadow-sm rounded-md'>
|
||||
<thead>
|
||||
<tr class='border-b border-slate-600'>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white'>First Name</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white'>Last Name</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white w-20'></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class='divide-y divide-slate-600 border-t border-slate-600'>
|
||||
{contacts.value.map((contact) => (
|
||||
<tr class='bg-slate-700 hover:bg-slate-600 group'>
|
||||
<td class='flex gap-3 px-6 py-4 font-normal text-white'>
|
||||
<a href={`/contacts/${contact.uid}?addressBookId=${selectedAddressBookId.value}`}>
|
||||
{contact.firstName}
|
||||
</a>
|
||||
</td>
|
||||
<td class='px-6 py-4 text-slate-200'>
|
||||
{contact.lastName}
|
||||
</td>
|
||||
<td class='px-6 py-4'>
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100'
|
||||
onClick={() => onClickDeleteContact(contact.uid!)}
|
||||
>
|
||||
<img
|
||||
src='/public/images/delete.svg'
|
||||
class='red drop-shadow-md'
|
||||
width={24}
|
||||
height={24}
|
||||
alt='Delete contact'
|
||||
title='Delete contact'
|
||||
/>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{contacts.value.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td class='flex gap-3 px-6 py-4 font-normal' colspan={3}>
|
||||
<div class='text-md'>
|
||||
<div class='font-medium text-slate-400'>No contacts 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}
|
||||
{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>
|
||||
|
||||
{pagesCount > 0
|
||||
? (
|
||||
<section class='flex justify-end'>
|
||||
<nav class='isolate inline-flex -space-x-px rounded-md shadow-sm' aria-label='Pagination'>
|
||||
<a
|
||||
href={page > 1
|
||||
? `/contacts?search=${search}&page=${page - 1}&addressBookId=${selectedAddressBookId.value}`
|
||||
: 'javascript:void(0)'}
|
||||
class='relative inline-flex items-center rounded-l-md px-2 py-2 text-white hover:bg-slate-600 bg-slate-700'
|
||||
title='Previous'
|
||||
>
|
||||
<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>
|
||||
</a>
|
||||
{pages.map((pageNumber) => {
|
||||
const isCurrent = pageNumber === page;
|
||||
|
||||
return (
|
||||
<a
|
||||
href={`/contacts?search=${search}&page=${pageNumber}&addressBookId=${selectedAddressBookId.value}`}
|
||||
aria-current='page'
|
||||
class={`relative inline-flex items-center ${
|
||||
isCurrent ? 'bg-[#51A4FB] hover:bg-sky-400' : 'bg-slate-700 hover:bg-slate-600'
|
||||
} px-4 py-2 text-sm font-semibold text-white`}
|
||||
>
|
||||
{pageNumber}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
<a
|
||||
href={page < pagesCount
|
||||
? `/contacts?search=${search}&page=${page + 1}&addressBookId=${selectedAddressBookId.value}`
|
||||
: 'javascript:void(0)'}
|
||||
class='relative inline-flex items-center rounded-r-md px-2 py-2 text-white hover:bg-slate-600 bg-slate-700'
|
||||
title='Next'
|
||||
>
|
||||
<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>
|
||||
</a>
|
||||
</nav>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
<section class='flex flex-row items-center justify-start my-12'>
|
||||
<span class='font-semibold'>CardDAV URL:</span>{' '}
|
||||
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/carddav</code>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
212
components/contacts/ViewContact.tsx
Normal file
212
components/contacts/ViewContact.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Contact } from '/lib/models/contacts.ts';
|
||||
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;
|
||||
initialContact: Contact;
|
||||
formData: Record<string, any>;
|
||||
error?: string;
|
||||
notice?: string;
|
||||
}
|
||||
|
||||
export function formFields(contact: Contact, updateType: 'raw' | 'ui') {
|
||||
const fields: FormField[] = [
|
||||
{
|
||||
name: 'update-type',
|
||||
label: 'Update type',
|
||||
type: 'hidden',
|
||||
value: updateType,
|
||||
readOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (updateType === 'ui') {
|
||||
fields.push({
|
||||
name: 'first_name',
|
||||
label: 'First name',
|
||||
type: 'text',
|
||||
placeholder: 'John',
|
||||
value: contact.firstName,
|
||||
required: true,
|
||||
}, {
|
||||
name: 'last_name',
|
||||
label: 'Last name',
|
||||
type: 'text',
|
||||
placeholder: 'Doe',
|
||||
value: contact.lastName,
|
||||
required: false,
|
||||
}, {
|
||||
name: 'main_phone',
|
||||
label: 'Main phone',
|
||||
type: 'tel',
|
||||
placeholder: '+44 0000 111 2222',
|
||||
value: contact.phone,
|
||||
required: false,
|
||||
}, {
|
||||
name: 'main_email',
|
||||
label: 'Main email',
|
||||
type: 'email',
|
||||
placeholder: 'john.doe@example.com',
|
||||
value: contact.email,
|
||||
required: false,
|
||||
}, {
|
||||
name: 'notes',
|
||||
label: 'Notes',
|
||||
type: 'textarea',
|
||||
placeholder: 'Some notes...',
|
||||
value: contact.notes,
|
||||
required: false,
|
||||
});
|
||||
} else if (updateType === 'raw') {
|
||||
fields.push({
|
||||
name: 'vcard',
|
||||
label: 'Raw vCard',
|
||||
type: 'textarea',
|
||||
placeholder: 'Raw vCard...',
|
||||
value: contact.data,
|
||||
description:
|
||||
'This is the raw vCard for this contact. Use this to manually update the contact _if_ you know what you are doing.',
|
||||
rows: '10',
|
||||
});
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
export default function ViewContact(
|
||||
{ initialContact, formData: formDataObject, error, notice, addressBookId }: ViewContactProps,
|
||||
) {
|
||||
const isDeleting = useSignal<boolean>(false);
|
||||
const contact = useSignal<Contact>(initialContact);
|
||||
|
||||
const formData = convertObjectToFormData(formDataObject);
|
||||
|
||||
async function onClickDeleteContact() {
|
||||
if (confirm('Are you sure you want to delete this contact?')) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteRequestBody = { contactId: contact.value.uid!, addressBookId };
|
||||
const response = await fetch(`/api/contacts/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete contact. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as DeleteResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete contact!');
|
||||
}
|
||||
|
||||
window.location.href = '/contacts';
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section class='flex flex-row items-center justify-between mb-4'>
|
||||
<a href='/contacts' class='mr-2'>View contacts</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 contact'
|
||||
onClick={() => onClickDeleteContact()}
|
||||
>
|
||||
<img
|
||||
src='/public/images/delete.svg'
|
||||
alt='Delete contact'
|
||||
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(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>
|
||||
</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 vCard{' '}
|
||||
<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(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>
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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[];
|
||||
|
|
@ -33,10 +33,15 @@ export default function Links({ initialLinks }: LinksProps) {
|
|||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to save link. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as ResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to save notes!');
|
||||
throw new Error('Failed to save link!');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
|
@ -113,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}
|
||||
|
|
@ -143,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}
|
||||
|
|
@ -158,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}
|
||||
|
|
@ -180,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;
|
||||
|
|
@ -28,6 +28,11 @@ export default function Notes({ initialNotes }: NotesProps) {
|
|||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to save notes. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as ResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -80,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}
|
||||
136
components/expenses/BudgetModal.tsx
Normal file
136
components/expenses/BudgetModal.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { Budget } from '/lib/types.ts';
|
||||
import { formatInputToNumber } from '/public/ts/utils/misc.ts';
|
||||
|
||||
interface BudgetModalProps {
|
||||
isOpen: boolean;
|
||||
budget: Budget | null;
|
||||
onClickSave: (newBudgetName: string, newBudgetMonth: string, newBudgetValue: number) => Promise<void>;
|
||||
onClickDelete: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
shouldResetForm: boolean;
|
||||
}
|
||||
|
||||
export default function BudgetModal(
|
||||
{ isOpen, budget, onClickSave, onClickDelete, onClose, shouldResetForm }: BudgetModalProps,
|
||||
) {
|
||||
const newBudgetName = useSignal<string>(budget?.name ?? '');
|
||||
const newBudgetMonth = useSignal<string>(budget?.month ?? new Date().toISOString().substring(0, 10));
|
||||
const newBudgetValue = useSignal<number | string>(budget?.value ?? 100);
|
||||
|
||||
const resetForm = () => {
|
||||
newBudgetName.value = '';
|
||||
newBudgetMonth.value = new Date().toISOString().substring(0, 10);
|
||||
newBudgetValue.value = 100;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (budget) {
|
||||
newBudgetName.value = budget.name;
|
||||
newBudgetMonth.value = `${budget.month}-15`;
|
||||
newBudgetValue.value = budget.value;
|
||||
}
|
||||
|
||||
if (shouldResetForm) {
|
||||
resetForm();
|
||||
}
|
||||
}, [budget, shouldResetForm]);
|
||||
|
||||
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 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'>{budget ? 'Edit Budget' : 'Create New Budget'}</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='budget_name'>Name</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='text'
|
||||
name='budget_name'
|
||||
id='budget_name'
|
||||
value={newBudgetName.value}
|
||||
onInput={(event) => {
|
||||
newBudgetName.value = event.currentTarget.value;
|
||||
}}
|
||||
placeholder='Amazing'
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='budget_month'>Month</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='date'
|
||||
name='budget_month'
|
||||
id='budget_month'
|
||||
value={newBudgetMonth.value}
|
||||
onInput={(event) => {
|
||||
newBudgetMonth.value = event.currentTarget.value;
|
||||
}}
|
||||
placeholder='2025-01-01'
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='budget_value'>Value</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='text'
|
||||
name='budget_value'
|
||||
id='budget_value'
|
||||
value={newBudgetValue.value}
|
||||
onInput={(event) => {
|
||||
newBudgetValue.value = event.currentTarget.value;
|
||||
}}
|
||||
inputmode='decimal'
|
||||
placeholder='100'
|
||||
/>
|
||||
</fieldset>
|
||||
</section>
|
||||
<footer class='flex justify-between'>
|
||||
{budget
|
||||
? (
|
||||
<button
|
||||
class='px-5 py-2 bg-red-600 text-white cursor-pointer rounded-md mr-2 opacity-30 hover:opacity-100'
|
||||
onClick={() => onClickDelete()}
|
||||
type='button'
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md mr-2'
|
||||
onClick={() => onClose()}
|
||||
type='button'
|
||||
>
|
||||
{budget ? 'Cancel' : 'Close'}
|
||||
</button>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-700 hover:bg-slate-500 text-white cursor-pointer rounded-md ml-2'
|
||||
onClick={() =>
|
||||
onClickSave(
|
||||
newBudgetName.value,
|
||||
newBudgetMonth.value.substring(0, 7),
|
||||
formatInputToNumber(newBudgetValue.value),
|
||||
)}
|
||||
type='button'
|
||||
>
|
||||
{budget ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
273
components/expenses/ExpenseModal.tsx
Normal file
273
components/expenses/ExpenseModal.tsx
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { Budget, Expense } from '/lib/types.ts';
|
||||
import { formatInputToNumber } from '/public/ts/utils/misc.ts';
|
||||
|
||||
import {
|
||||
RequestBody as SuggestionsRequestBody,
|
||||
ResponseBody as SuggestionsResponse,
|
||||
} from '/pages/api/expenses/auto-complete.ts';
|
||||
|
||||
interface ExpenseModalProps {
|
||||
isOpen: boolean;
|
||||
expense: Expense | null;
|
||||
budgets: Budget[];
|
||||
onClickSave: (
|
||||
newExpenseCost: number,
|
||||
newExpenseDescription: string,
|
||||
newExpenseBudget: string,
|
||||
newExpenseDate: string,
|
||||
newExpenseIsRecurring: boolean,
|
||||
) => Promise<void>;
|
||||
onClickDelete: () => Promise<void>;
|
||||
onClose: () => void;
|
||||
shouldResetForm: boolean;
|
||||
}
|
||||
|
||||
export default function ExpenseModal(
|
||||
{ isOpen, expense, budgets, onClickSave, onClickDelete, onClose, shouldResetForm }: ExpenseModalProps,
|
||||
) {
|
||||
const newExpenseCost = useSignal<number | string>(expense?.cost ?? '');
|
||||
const newExpenseDescription = useSignal<string>(expense?.description ?? '');
|
||||
const newExpenseBudget = useSignal<string>(expense?.budget ?? 'Misc');
|
||||
const newExpenseDate = useSignal<string>(expense?.date ?? '');
|
||||
const newExpenseIsRecurring = useSignal<boolean>(expense?.is_recurring ?? false);
|
||||
const suggestions = useSignal<string[]>([]);
|
||||
const showSuggestions = useSignal<boolean>(false);
|
||||
|
||||
const resetForm = () => {
|
||||
newExpenseCost.value = '';
|
||||
newExpenseDescription.value = '';
|
||||
newExpenseBudget.value = 'Misc';
|
||||
newExpenseDate.value = '';
|
||||
newExpenseIsRecurring.value = false;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (expense) {
|
||||
newExpenseCost.value = expense.cost;
|
||||
newExpenseDescription.value = expense.description;
|
||||
newExpenseBudget.value = expense.budget;
|
||||
newExpenseDate.value = expense.date;
|
||||
newExpenseIsRecurring.value = expense.is_recurring;
|
||||
showSuggestions.value = false;
|
||||
}
|
||||
|
||||
if (shouldResetForm) {
|
||||
resetForm();
|
||||
}
|
||||
}, [expense, shouldResetForm]);
|
||||
|
||||
const sortedBudgetNames = budgets.map((budget) => budget.name).sort();
|
||||
|
||||
if (!sortedBudgetNames.includes('Misc')) {
|
||||
sortedBudgetNames.push('Misc');
|
||||
sortedBudgetNames.sort();
|
||||
}
|
||||
|
||||
const fetchSuggestions = async (name: string) => {
|
||||
if (name.length < 2) {
|
||||
suggestions.value = [];
|
||||
showSuggestions.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const requestBody: SuggestionsRequestBody = {
|
||||
name,
|
||||
};
|
||||
|
||||
const response = await fetch(`/api/expenses/auto-complete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json() as SuggestionsResponse;
|
||||
suggestions.value = result.suggestions;
|
||||
showSuggestions.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch suggestions:', error);
|
||||
suggestions.value = [];
|
||||
showSuggestions.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
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 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'>{expense ? 'Edit Expense' : 'Create New Expense'}</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='expense_cost'>Cost</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='text'
|
||||
name='expense_cost'
|
||||
id='expense_cost'
|
||||
value={newExpenseCost.value}
|
||||
onInput={(event) => {
|
||||
newExpenseCost.value = event.currentTarget.value;
|
||||
}}
|
||||
inputmode='decimal'
|
||||
placeholder='10.99'
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class='block mb-2 relative'>
|
||||
<label class='text-slate-300 block pb-1' for='expense_description'>Description</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='text'
|
||||
name='expense_description'
|
||||
id='expense_description'
|
||||
value={newExpenseDescription.value}
|
||||
onInput={(event) => {
|
||||
newExpenseDescription.value = event.currentTarget.value;
|
||||
fetchSuggestions(event.currentTarget.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
onClickSave(
|
||||
formatInputToNumber(newExpenseCost.value),
|
||||
newExpenseDescription.value,
|
||||
newExpenseBudget.value,
|
||||
newExpenseDate.value,
|
||||
newExpenseIsRecurring.value,
|
||||
);
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (suggestions.value.length > 0) {
|
||||
showSuggestions.value = true;
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
setTimeout(() => {
|
||||
showSuggestions.value = false;
|
||||
}, 200);
|
||||
}}
|
||||
placeholder='Lunch'
|
||||
/>
|
||||
{showSuggestions.value && suggestions.value.length > 0
|
||||
? (
|
||||
<ul class='absolute z-50 w-full bg-slate-700 rounded-md mt-1 max-h-40 overflow-y-auto ring-1 ring-slate-800 shadow-lg'>
|
||||
{suggestions.value.map((suggestion) => (
|
||||
<li
|
||||
key={suggestion}
|
||||
class='px-4 py-2 hover:bg-slate-600 cursor-pointer'
|
||||
onClick={() => {
|
||||
newExpenseDescription.value = suggestion;
|
||||
showSuggestions.value = false;
|
||||
suggestions.value = [];
|
||||
}}
|
||||
>
|
||||
{suggestion}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
: null}
|
||||
</fieldset>
|
||||
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='expense_budget'>Budget</label>
|
||||
<select
|
||||
class='input-field'
|
||||
name='expense_budget'
|
||||
id='expense_budget'
|
||||
value={newExpenseBudget.value}
|
||||
onChange={(event) => {
|
||||
newExpenseBudget.value = event.currentTarget.value;
|
||||
}}
|
||||
>
|
||||
{sortedBudgetNames.map((budget) => (
|
||||
<option value={budget} selected={newExpenseBudget.value === budget}>{budget}</option>
|
||||
))}
|
||||
</select>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='expense_date'>Date</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='date'
|
||||
name='expense_date'
|
||||
id='expense_date'
|
||||
value={newExpenseDate.value}
|
||||
onInput={(event) => {
|
||||
newExpenseDate.value = event.currentTarget.value;
|
||||
}}
|
||||
placeholder='2025-01-01'
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{expense
|
||||
? (
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='expense_is_recurring'>Is Recurring?</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='checkbox'
|
||||
name='expense_is_recurring'
|
||||
id='expense_is_recurring'
|
||||
value='true'
|
||||
checked={newExpenseIsRecurring.value}
|
||||
onInput={(event) => {
|
||||
newExpenseIsRecurring.value = event.currentTarget.checked;
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
)
|
||||
: null}
|
||||
</section>
|
||||
<footer class='flex justify-between'>
|
||||
{expense
|
||||
? (
|
||||
<button
|
||||
class='px-5 py-2 bg-red-600 text-white cursor-pointer rounded-md mr-2 opacity-30 hover:opacity-100'
|
||||
onClick={() => onClickDelete()}
|
||||
type='button'
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md mr-2'
|
||||
onClick={() => onClose()}
|
||||
type='button'
|
||||
>
|
||||
{expense ? 'Cancel' : 'Close'}
|
||||
</button>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-700 hover:bg-slate-500 text-white cursor-pointer rounded-md ml-2'
|
||||
onClick={() => {
|
||||
onClickSave(
|
||||
formatInputToNumber(newExpenseCost.value),
|
||||
newExpenseDescription.value,
|
||||
newExpenseBudget.value,
|
||||
newExpenseDate.value,
|
||||
newExpenseIsRecurring.value,
|
||||
);
|
||||
}}
|
||||
type='button'
|
||||
>
|
||||
{expense ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
138
components/expenses/ListBudgets.tsx
Normal file
138
components/expenses/ListBudgets.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect, useRef } from 'preact/hooks';
|
||||
import { Chart } from 'chart.js/auto';
|
||||
|
||||
import { formatNumber } from '/public/ts/utils/misc.ts';
|
||||
import { Budget, SupportedCurrencySymbol } from '/lib/types.ts';
|
||||
|
||||
interface ListBudgetsProps {
|
||||
budgets: Budget[];
|
||||
month: string;
|
||||
currency: SupportedCurrencySymbol;
|
||||
onClickEditBudget: (budgetId: string) => void;
|
||||
}
|
||||
|
||||
export default function ListBudgets(
|
||||
{
|
||||
budgets,
|
||||
month,
|
||||
currency,
|
||||
onClickEditBudget,
|
||||
}: ListBudgetsProps,
|
||||
) {
|
||||
const view = useSignal<'list' | 'chart'>('list');
|
||||
const chartRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
// Calculate a total budget to show before all others
|
||||
const totalBudget: Omit<Budget, 'user_id' | 'created_at'> = {
|
||||
id: 'total',
|
||||
name: 'Total',
|
||||
month,
|
||||
value: budgets.reduce((accumulatedValue, budget) => accumulatedValue + budget.value, 0),
|
||||
extra: {
|
||||
usedValue: budgets.reduce((accumulatedValue, budget) => accumulatedValue + budget.extra.usedValue, 0),
|
||||
availableValue: budgets.reduce((accumulatedValue, budget) => accumulatedValue + budget.extra.availableValue, 0),
|
||||
},
|
||||
};
|
||||
|
||||
function swapView(newView: 'list' | 'chart') {
|
||||
view.value = view.value === newView ? 'list' : newView;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (view.value === 'chart') {
|
||||
const budgetColors = [totalBudget, ...budgets].map((_, index) =>
|
||||
index === 0
|
||||
? 'rgba(59, 130, 246, 0.8)'
|
||||
: `hsl(${(index - 1) * (360 / budgets.length)}, ${index % 2 ? 85 : 70}%, ${index % 2 ? 55 : 65}%, 0.8)`
|
||||
);
|
||||
|
||||
new Chart(chartRef.current as HTMLCanvasElement, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: [{ name: 'Available' }, ...budgets].map((budget) => budget.name),
|
||||
datasets: [{
|
||||
label: '',
|
||||
data: [totalBudget, ...budgets].map((budget) =>
|
||||
budget.id === 'total' ? budget.extra.availableValue : budget.extra.usedValue
|
||||
),
|
||||
backgroundColor: budgetColors,
|
||||
borderWidth: 1,
|
||||
borderColor: '#222',
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
backgroundColor: '#222',
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
fullSize: true,
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
padding: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [view.value]);
|
||||
|
||||
return (
|
||||
<section class='mx-auto max-w-7xl my-8'>
|
||||
{budgets.length === 0
|
||||
? (
|
||||
<article class='px-6 py-4 font-normal text-center w-full'>
|
||||
<div class='font-medium text-slate-400 text-md'>No budgets to show for {month}</div>
|
||||
</article>
|
||||
)
|
||||
: (
|
||||
<section class='w-full flex flex-wrap gap-4 justify-center items-center'>
|
||||
{view.value === 'list'
|
||||
? [totalBudget, ...budgets].map((budget) => {
|
||||
let backgroundColorClass = 'bg-green-600';
|
||||
let usedValuePercentage = Math.ceil(100 * budget.extra.usedValue / budget.value);
|
||||
|
||||
if (usedValuePercentage >= 100) {
|
||||
usedValuePercentage = 100;
|
||||
backgroundColorClass = 'bg-red-600';
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => budget.id === 'total' ? swapView('chart') : onClickEditBudget(budget.id)}
|
||||
class='flex w-full md:w-auto max-w-sm gap-y-4 gap-x-4 rounded shadow-md bg-slate-700 relative cursor-pointer py-4 px-6 hover:opacity-80'
|
||||
>
|
||||
<article class='order-first tracking-tight flex flex-col text-base mr-4'>
|
||||
<span class='font-bold text-lg' title='Amount used from budgeted amount'>
|
||||
{formatNumber(currency, budget.extra.usedValue)} of {formatNumber(currency, budget.value)}
|
||||
</span>
|
||||
<span
|
||||
class='bg-gray-600 h-1.5 w-full block rounded-full mt-2 mx-0'
|
||||
title={`${usedValuePercentage}% of budget used`}
|
||||
>
|
||||
<span
|
||||
class={`${backgroundColorClass} w-0 block h-1.5 rounded-full`}
|
||||
style={{ width: `${usedValuePercentage}%` }}
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
<span class='mt-2 font-normal text-gray-400'>{budget.name}</span>
|
||||
</article>
|
||||
<span class='text-lg text-right text-gray-200' title='Amount available from budgeted amount'>
|
||||
{formatNumber(currency, budget.extra.availableValue)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: (
|
||||
<section class='p-4 rounded-lg shadow-sm cursor-pointer' onClick={() => swapView('list')}>
|
||||
<canvas ref={chartRef}></canvas>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
69
components/expenses/ListExpenses.tsx
Normal file
69
components/expenses/ListExpenses.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { Expense, SupportedCurrencySymbol } from '/lib/types.ts';
|
||||
import { formatNumber } from '/public/ts/utils/misc.ts';
|
||||
|
||||
interface ListExpensesProps {
|
||||
expenses: Expense[];
|
||||
currency: SupportedCurrencySymbol;
|
||||
onClickEditExpense: (expenseId: string) => void;
|
||||
}
|
||||
|
||||
export default function ListExpenses(
|
||||
{
|
||||
expenses,
|
||||
currency,
|
||||
onClickEditExpense,
|
||||
}: ListExpensesProps,
|
||||
) {
|
||||
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
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-US', dateFormatOptions);
|
||||
|
||||
return (
|
||||
<section class='mx-auto max-w-7xl my-8 mt-12'>
|
||||
{expenses.length === 0
|
||||
? (
|
||||
<article class='px-6 py-4 font-normal text-center w-full'>
|
||||
<div class='font-medium text-slate-400 text-md'>No expenses to show</div>
|
||||
</article>
|
||||
)
|
||||
: (
|
||||
<section class='w-full overflow-x-auto'>
|
||||
<table class='w-full border-collapse text-gray-200 rounded-lg overflow-hidden'>
|
||||
<thead class='bg-slate-900 hidden md:table-header-group'>
|
||||
<tr>
|
||||
<th class='px-6 py-3 text-left text-sm font-normal'>Description</th>
|
||||
<th class='px-6 py-3 text-left text-sm font-normal'>Budget</th>
|
||||
<th class='px-6 py-3 text-left text-sm font-normal'>Date</th>
|
||||
<th class='px-6 py-3 text-left text-sm font-normal'>Cost</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{expenses.map((expense) => (
|
||||
<tr
|
||||
key={expense.id}
|
||||
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'>
|
||||
{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'>
|
||||
{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'>
|
||||
{formatNumber(currency, expense.cost)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
840
components/expenses/MainExpenses.tsx
Normal file
840
components/expenses/MainExpenses.tsx
Normal file
|
|
@ -0,0 +1,840 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
import { useCallback, useEffect } from 'preact/hooks';
|
||||
|
||||
import { Budget, Expense, SupportedCurrencySymbol } from '/lib/types.ts';
|
||||
import {
|
||||
RequestBody as ImportRequestBody,
|
||||
ResponseBody as ImportResponseBody,
|
||||
} 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 '/pages/api/expenses/add-expense.ts';
|
||||
import {
|
||||
RequestBody as AddBudgetRequestBody,
|
||||
ResponseBody as AddBudgetResponseBody,
|
||||
} from '/pages/api/expenses/add-budget.ts';
|
||||
import {
|
||||
RequestBody as UpdateExpenseRequestBody,
|
||||
ResponseBody as UpdateExpenseResponseBody,
|
||||
} from '/pages/api/expenses/update-expense.ts';
|
||||
import {
|
||||
RequestBody as UpdateBudgetRequestBody,
|
||||
ResponseBody as UpdateBudgetResponseBody,
|
||||
} from '/pages/api/expenses/update-budget.ts';
|
||||
import {
|
||||
RequestBody as DeleteExpenseRequestBody,
|
||||
ResponseBody as DeleteExpenseResponseBody,
|
||||
} from '/pages/api/expenses/delete-expense.ts';
|
||||
import {
|
||||
RequestBody as DeleteBudgetRequestBody,
|
||||
ResponseBody as DeleteBudgetResponseBody,
|
||||
} 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';
|
||||
import BudgetModal from './BudgetModal.tsx';
|
||||
|
||||
interface MainExpensesProps {
|
||||
initialBudgets: Budget[];
|
||||
initialExpenses: Expense[];
|
||||
initialMonth: string;
|
||||
currency: SupportedCurrencySymbol;
|
||||
}
|
||||
|
||||
export default function MainExpenses({ initialBudgets, initialExpenses, initialMonth, currency }: MainExpensesProps) {
|
||||
const isSaving = useSignal<boolean>(false);
|
||||
const isImporting = useSignal<boolean>(false);
|
||||
const isExporting = useSignal<boolean>(false);
|
||||
const isSearching = useSignal<boolean>(false);
|
||||
const budgets = useSignal<Budget[]>(initialBudgets);
|
||||
const expenses = useSignal<Expense[]>(initialExpenses);
|
||||
const currentMonth = useSignal<string>(initialMonth);
|
||||
const areNewOptionsOption = useSignal<boolean>(false);
|
||||
const isExpenseModalOpen = useSignal<boolean>(false);
|
||||
const editingExpense = useSignal<Expense | null>(null);
|
||||
const isBudgetModalOpen = useSignal<boolean>(false);
|
||||
const editingBudget = useSignal<Budget | null>(null);
|
||||
const shouldResetExpenseModal = useSignal<boolean>(false);
|
||||
const shouldResetBudgetModal = useSignal<boolean>(false);
|
||||
const searchTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
||||
|
||||
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
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 thisMonth = new Date().toISOString().substring(0, 7);
|
||||
|
||||
function onClickImportFile() {
|
||||
areNewOptionsOption.value = false;
|
||||
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.multiple = true;
|
||||
fileInput.accept = 'text/pain,application/json,.json';
|
||||
fileInput.ariaLabel = 'Import your budgets and expenses';
|
||||
fileInput.click();
|
||||
|
||||
fileInput.onchange = async (event) => {
|
||||
const chosenFilesList = (event.target as HTMLInputElement)?.files!;
|
||||
|
||||
const chosenFiles = Array.from(chosenFilesList);
|
||||
|
||||
isImporting.value = true;
|
||||
|
||||
for (const chosenFile of chosenFiles) {
|
||||
if (!chosenFile) {
|
||||
continue;
|
||||
}
|
||||
|
||||
areNewOptionsOption.value = false;
|
||||
|
||||
let importedFileData: { budgets?: ImportRequestBody['budgets']; expenses?: ImportRequestBody['expenses'] } = {};
|
||||
|
||||
try {
|
||||
importedFileData = JSON.parse(await chosenFile.text());
|
||||
} catch (_error) {
|
||||
importedFileData = {};
|
||||
}
|
||||
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(importedFileData, 'budgets') &&
|
||||
!Object.prototype.hasOwnProperty.call(importedFileData, 'expenses')
|
||||
) {
|
||||
alert('Could not parse the file. Please confirm what you chose is correct.');
|
||||
return;
|
||||
}
|
||||
|
||||
const budgetsToImport = importedFileData.budgets || [];
|
||||
const expensesToImport = importedFileData.expenses || [];
|
||||
|
||||
const mergeOrReplace = prompt(
|
||||
'Do you want to merge or replace the existing expenses and budgets? (merge/replace)',
|
||||
);
|
||||
|
||||
if (!mergeOrReplace || (mergeOrReplace !== 'merge' && mergeOrReplace !== 'replace')) {
|
||||
alert('Invalid input. Please enter "merge" or "replace".');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const requestBody: ImportRequestBody = {
|
||||
budgets: budgetsToImport,
|
||||
expenses: expensesToImport,
|
||||
month: currentMonth.value,
|
||||
replace: mergeOrReplace === 'replace',
|
||||
};
|
||||
const response = await fetch(`/api/expenses/import-expenses`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to import expenses and budgets! ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as ImportResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to import expenses and budgets!');
|
||||
}
|
||||
|
||||
budgets.value = [...result.newBudgets];
|
||||
expenses.value = [...result.newExpenses];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert(error);
|
||||
}
|
||||
}
|
||||
|
||||
isImporting.value = false;
|
||||
};
|
||||
}
|
||||
|
||||
async function onClickExportFile() {
|
||||
areNewOptionsOption.value = false;
|
||||
|
||||
isExporting.value = true;
|
||||
|
||||
const fileName = `expenses-data-export-${new Date().toISOString().substring(0, 19).replace(/:/g, '-')}.json`;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/expenses/export-expenses`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to export expenses. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as ExportResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to export expenses!');
|
||||
}
|
||||
|
||||
const exportContents = JSON.stringify(result.jsonContents, null, 2);
|
||||
|
||||
// Add content-type
|
||||
const jsonContent = `data:application/json; charset=utf-8,${encodeURIComponent(exportContents)}`;
|
||||
|
||||
// Download the file
|
||||
const data = jsonContent;
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', data);
|
||||
link.setAttribute('download', fileName);
|
||||
link.click();
|
||||
link.remove();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert(error);
|
||||
}
|
||||
|
||||
isExporting.value = false;
|
||||
}
|
||||
|
||||
function onClickCreateExpense() {
|
||||
areNewOptionsOption.value = false;
|
||||
|
||||
if (isExpenseModalOpen.value) {
|
||||
isExpenseModalOpen.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
shouldResetExpenseModal.value = false;
|
||||
editingExpense.value = null;
|
||||
isExpenseModalOpen.value = true;
|
||||
}
|
||||
|
||||
function onClickCreateBudget() {
|
||||
areNewOptionsOption.value = false;
|
||||
|
||||
if (isBudgetModalOpen.value) {
|
||||
isBudgetModalOpen.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
shouldResetBudgetModal.value = false;
|
||||
editingBudget.value = null;
|
||||
isBudgetModalOpen.value = true;
|
||||
}
|
||||
|
||||
function onClickEditExpense(expenseId: string) {
|
||||
areNewOptionsOption.value = false;
|
||||
|
||||
if (isExpenseModalOpen.value) {
|
||||
isExpenseModalOpen.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
shouldResetExpenseModal.value = false;
|
||||
editingExpense.value = expenses.value.find((expense) => expense.id === expenseId)!;
|
||||
isExpenseModalOpen.value = true;
|
||||
}
|
||||
|
||||
function onClickEditBudget(budgetId: string) {
|
||||
areNewOptionsOption.value = false;
|
||||
|
||||
if (isBudgetModalOpen.value) {
|
||||
isBudgetModalOpen.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Can't edit the total budget
|
||||
if (budgetId === 'total') {
|
||||
return;
|
||||
}
|
||||
|
||||
shouldResetBudgetModal.value = false;
|
||||
editingBudget.value = budgets.value.find((budget) => budget.id === budgetId)!;
|
||||
isBudgetModalOpen.value = true;
|
||||
}
|
||||
|
||||
async function onClickSaveExpense(
|
||||
newExpenseCost: number,
|
||||
newExpenseDescription: string,
|
||||
newExpenseBudget: string,
|
||||
newExpenseDate: string,
|
||||
newExpenseIsRecurring: boolean,
|
||||
) {
|
||||
if (isSaving.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newExpenseCost || Number.isNaN(newExpenseCost) || !newExpenseDescription) {
|
||||
return;
|
||||
}
|
||||
|
||||
areNewOptionsOption.value = false;
|
||||
isSaving.value = true;
|
||||
|
||||
if (editingExpense.value) {
|
||||
const requestBody: UpdateExpenseRequestBody = {
|
||||
id: editingExpense.value.id,
|
||||
cost: newExpenseCost,
|
||||
description: newExpenseDescription,
|
||||
budget: newExpenseBudget,
|
||||
date: newExpenseDate,
|
||||
is_recurring: newExpenseIsRecurring,
|
||||
month: currentMonth.value,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/expenses/update-expense`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update expense! ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as UpdateExpenseResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to update expense!');
|
||||
}
|
||||
|
||||
expenses.value = [...result.newExpenses];
|
||||
budgets.value = [...result.newBudgets];
|
||||
|
||||
isExpenseModalOpen.value = false;
|
||||
editingExpense.value = null;
|
||||
shouldResetExpenseModal.value = true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert(error);
|
||||
}
|
||||
} else {
|
||||
const requestBody: AddExpenseRequestBody = {
|
||||
cost: newExpenseCost,
|
||||
description: newExpenseDescription,
|
||||
budget: newExpenseBudget,
|
||||
date: newExpenseDate,
|
||||
is_recurring: newExpenseIsRecurring,
|
||||
month: currentMonth.value,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/expenses/add-expense`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to add expense! ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as AddExpenseResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to add expense!');
|
||||
}
|
||||
|
||||
expenses.value = [...result.newExpenses];
|
||||
budgets.value = [...result.newBudgets];
|
||||
|
||||
isExpenseModalOpen.value = false;
|
||||
shouldResetExpenseModal.value = true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert(error);
|
||||
}
|
||||
}
|
||||
|
||||
isSaving.value = false;
|
||||
}
|
||||
|
||||
async function onClickSaveBudget(newBudgetName: string, newBudgetMonth: string, newBudgetValue: number) {
|
||||
if (isSaving.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!newBudgetName || !newBudgetMonth || !newBudgetMonth.match(/^\d{4}-\d{2}$/) || !newBudgetValue ||
|
||||
Number.isNaN(newBudgetValue)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
areNewOptionsOption.value = false;
|
||||
isSaving.value = true;
|
||||
|
||||
if (editingBudget.value) {
|
||||
const requestBody: UpdateBudgetRequestBody = {
|
||||
id: editingBudget.value.id,
|
||||
name: newBudgetName,
|
||||
month: newBudgetMonth,
|
||||
value: newBudgetValue,
|
||||
currentMonth: currentMonth.value,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/expenses/update-budget`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update budget! ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as UpdateBudgetResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to update budget!');
|
||||
}
|
||||
|
||||
budgets.value = [...result.newBudgets];
|
||||
|
||||
isBudgetModalOpen.value = false;
|
||||
editingBudget.value = null;
|
||||
shouldResetBudgetModal.value = true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert(error);
|
||||
}
|
||||
} else {
|
||||
const requestBody: AddBudgetRequestBody = {
|
||||
name: newBudgetName,
|
||||
month: newBudgetMonth,
|
||||
value: newBudgetValue,
|
||||
currentMonth: currentMonth.value,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/expenses/add-budget`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to add budget! ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as AddBudgetResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to add budget!');
|
||||
}
|
||||
|
||||
budgets.value = [...result.newBudgets];
|
||||
|
||||
isBudgetModalOpen.value = false;
|
||||
shouldResetBudgetModal.value = true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert(error);
|
||||
}
|
||||
}
|
||||
|
||||
isSaving.value = false;
|
||||
}
|
||||
|
||||
async function onClickDeleteExpense() {
|
||||
if (isSaving.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingExpense.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Are you sure you want to delete this expense?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
|
||||
const requestBody: DeleteExpenseRequestBody = {
|
||||
id: editingExpense.value.id,
|
||||
month: currentMonth.value,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/expenses/delete-expense`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete expense! ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as DeleteExpenseResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete expense!');
|
||||
}
|
||||
|
||||
expenses.value = [...result.newExpenses];
|
||||
budgets.value = [...result.newBudgets];
|
||||
|
||||
isExpenseModalOpen.value = false;
|
||||
editingExpense.value = null;
|
||||
shouldResetExpenseModal.value = true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert(error);
|
||||
}
|
||||
|
||||
isSaving.value = false;
|
||||
}
|
||||
|
||||
async function onClickDeleteBudget() {
|
||||
if (isSaving.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!editingBudget.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('Are you sure you want to delete this budget?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
isSaving.value = true;
|
||||
|
||||
const requestBody: DeleteBudgetRequestBody = {
|
||||
id: editingBudget.value.id,
|
||||
currentMonth: currentMonth.value,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/expenses/delete-budget`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete budget! ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as DeleteBudgetResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete budget!');
|
||||
}
|
||||
|
||||
budgets.value = [...result.newBudgets];
|
||||
|
||||
isBudgetModalOpen.value = false;
|
||||
editingBudget.value = null;
|
||||
shouldResetBudgetModal.value = true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert(error);
|
||||
}
|
||||
|
||||
isSaving.value = false;
|
||||
}
|
||||
|
||||
function onCloseExpense() {
|
||||
isExpenseModalOpen.value = false;
|
||||
editingExpense.value = null;
|
||||
shouldResetExpenseModal.value = true;
|
||||
}
|
||||
|
||||
function onCloseBudget() {
|
||||
isBudgetModalOpen.value = false;
|
||||
editingBudget.value = null;
|
||||
shouldResetBudgetModal.value = true;
|
||||
}
|
||||
|
||||
function toggleNewOptionsDropdown() {
|
||||
areNewOptionsOption.value = !areNewOptionsOption.value;
|
||||
}
|
||||
|
||||
function onClickChangeMonth(changeTo: 'previous' | 'next' | 'today') {
|
||||
const previousMonth = new Date(
|
||||
new Date(`${currentMonth.value}-15`).setUTCMonth(new Date(`${currentMonth.value}-15`).getUTCMonth() - 1),
|
||||
).toISOString()
|
||||
.substring(0, 7);
|
||||
const nextMonth = new Date(
|
||||
new Date(`${currentMonth.value}-15`).setUTCMonth(new Date(`${currentMonth.value}-15`).getUTCMonth() + 1),
|
||||
).toISOString()
|
||||
.substring(0, 7);
|
||||
|
||||
if (changeTo === 'today') {
|
||||
if (thisMonth === currentMonth.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/expenses?month=${thisMonth}`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (changeTo === 'previous') {
|
||||
const newStartDate = previousMonth;
|
||||
|
||||
if (newStartDate === currentMonth.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/expenses?month=${newStartDate}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const newStartDate = nextMonth;
|
||||
|
||||
if (newStartDate === currentMonth.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.href = `/expenses?month=${newStartDate}`;
|
||||
}
|
||||
|
||||
function searchExpenses(searchTerm: string) {
|
||||
if (searchTimeout.value) {
|
||||
clearTimeout(searchTimeout.value);
|
||||
}
|
||||
|
||||
if (searchTerm.trim().length < 2) {
|
||||
expenses.value = initialExpenses;
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeout.value = setTimeout(() => {
|
||||
isSearching.value = true;
|
||||
|
||||
try {
|
||||
const normalizedSearchTerm = searchTerm.trim().normalize().toLowerCase();
|
||||
const filteredExpenses = initialExpenses.filter((expense) => {
|
||||
const descriptionMatch = expense.description.toLowerCase().includes(normalizedSearchTerm);
|
||||
const budgetMatch = expense.budget.toLowerCase().includes(normalizedSearchTerm);
|
||||
return descriptionMatch || budgetMatch;
|
||||
});
|
||||
|
||||
expenses.value = filteredExpenses;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
alert(error);
|
||||
expenses.value = initialExpenses;
|
||||
}
|
||||
|
||||
isSearching.value = false;
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Open the expense modal if the window is small
|
||||
const handleWindowResize = useCallback(() => {
|
||||
if (globalThis.innerWidth < 600 && !isExpenseModalOpen.value && !editingExpense.value) {
|
||||
isExpenseModalOpen.value = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (searchTimeout.value) {
|
||||
clearTimeout(searchTimeout.value);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
handleWindowResize();
|
||||
globalThis.addEventListener('resize', handleWindowResize);
|
||||
|
||||
return () => {
|
||||
globalThis.removeEventListener('resize', handleWindowResize);
|
||||
};
|
||||
}, [handleWindowResize]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section class='block md:flex flex-row items-center justify-between mb-4'>
|
||||
<section class='relative inline-block text-left ml-2 md:ml-0 mr-0 md:mr-2 mb-4 md:mb-0'>
|
||||
<section class='flex flex-row items-center justify-start w-72'>
|
||||
<input
|
||||
class='input-field mr-2'
|
||||
type='search'
|
||||
name='search'
|
||||
placeholder='Filter expenses...'
|
||||
onInput={(event) => searchExpenses(event.currentTarget.value)}
|
||||
/>
|
||||
{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'>
|
||||
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'>
|
||||
<time datetime={`${currentMonth.value}-15`}>{dateFormat.format(new Date(`${currentMonth.value}-15`))}</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={() => onClickChangeMonth('previous')}
|
||||
>
|
||||
<span class='sr-only'>Previous month</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={() => onClickChangeMonth('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={() => onClickChangeMonth('next')}
|
||||
>
|
||||
<span class='sr-only'>Next month</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 mr-4 md:mr-0'>
|
||||
<div>
|
||||
<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 min-w-10'
|
||||
type='button'
|
||||
title='Add new expense or budget'
|
||||
id='new-button'
|
||||
aria-expanded='true'
|
||||
aria-haspopup='true'
|
||||
onClick={() => toggleNewOptionsDropdown()}
|
||||
>
|
||||
<img
|
||||
src='/public/images/add.svg'
|
||||
alt='Add new expense or budget'
|
||||
class={`white ${isSaving.value || isImporting.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</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 ${
|
||||
!areNewOptionsOption.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='new-button'
|
||||
tabindex={-1}
|
||||
>
|
||||
<div class='py-1'>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickCreateExpense()}
|
||||
type='button'
|
||||
>
|
||||
New Expense
|
||||
</button>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickCreateBudget()}
|
||||
type='button'
|
||||
>
|
||||
New Budget
|
||||
</button>
|
||||
|
||||
<section class='flex items-center justify-center my-1'>
|
||||
<div class='w-full border-t border-slate-600 mx-4' />
|
||||
</section>
|
||||
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickImportFile()}
|
||||
type='button'
|
||||
>
|
||||
Import
|
||||
</button>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickExportFile()}
|
||||
type='button'
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class='mx-auto max-w-7xl my-8'>
|
||||
<ListBudgets
|
||||
budgets={budgets.value}
|
||||
month={currentMonth.value}
|
||||
currency={currency}
|
||||
onClickEditBudget={onClickEditBudget}
|
||||
/>
|
||||
|
||||
<ListExpenses
|
||||
expenses={expenses.value}
|
||||
currency={currency}
|
||||
onClickEditExpense={onClickEditExpense}
|
||||
/>
|
||||
|
||||
<span
|
||||
class={`flex justify-end items-center text-sm mt-1 mx-2 text-slate-100`}
|
||||
>
|
||||
{isSaving.value
|
||||
? (
|
||||
<>
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Saving...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isImporting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Importing...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{isExporting.value
|
||||
? (
|
||||
<>
|
||||
<img src='/public/images/loading.svg' class='white mr-2' width={18} height={18} />Exporting...
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
{!isSaving.value && !isImporting.value && !isExporting.value ? <> </> : null}
|
||||
</span>
|
||||
</section>
|
||||
|
||||
<ExpenseModal
|
||||
isOpen={isExpenseModalOpen.value}
|
||||
expense={editingExpense.value}
|
||||
budgets={budgets.value}
|
||||
onClickSave={onClickSaveExpense}
|
||||
onClickDelete={onClickDeleteExpense}
|
||||
onClose={onCloseExpense}
|
||||
shouldResetForm={shouldResetExpenseModal.value}
|
||||
/>
|
||||
|
||||
<BudgetModal
|
||||
isOpen={isBudgetModalOpen.value}
|
||||
budget={editingBudget.value}
|
||||
onClickSave={onClickSaveBudget}
|
||||
onClickDelete={onClickDeleteBudget}
|
||||
onClose={onCloseBudget}
|
||||
shouldResetForm={shouldResetBudgetModal.value}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
@ -44,12 +44,14 @@ export default function CreateDirectoryModal(
|
|||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickSave(newDirectoryName.value)}
|
||||
type='button'
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
type='button'
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
|
|
|||
67
components/files/CreateShareModal.tsx
Normal file
67
components/files/CreateShareModal.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
|
||||
interface CreateShareModalProps {
|
||||
isOpen: boolean;
|
||||
filePath: string;
|
||||
password?: string;
|
||||
onClickSave: (filePath: string, password?: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function CreateShareModal(
|
||||
{ isOpen, filePath, password, onClickSave, onClose }: CreateShareModalProps,
|
||||
) {
|
||||
const newPassword = useSignal<string>(password || '');
|
||||
|
||||
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 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'>Create New Public Share Link</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='create-share-password'>Password</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='password'
|
||||
name='password'
|
||||
id='create-share-password'
|
||||
value={newPassword.value}
|
||||
onInput={(event) => {
|
||||
newPassword.value = event.currentTarget.value;
|
||||
}}
|
||||
autocomplete='off'
|
||||
/>
|
||||
</fieldset>
|
||||
</section>
|
||||
<footer class='flex justify-between'>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => {
|
||||
onClickSave(filePath, newPassword.peek());
|
||||
newPassword.value = '';
|
||||
}}
|
||||
type='button'
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
type='button'
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,22 +2,24 @@ interface FilesBreadcrumbProps {
|
|||
path: string;
|
||||
isShowingNotes?: boolean;
|
||||
isShowingPhotos?: boolean;
|
||||
fileShareId?: string;
|
||||
}
|
||||
|
||||
export default function FilesBreadcrumb({ path, isShowingNotes, isShowingPhotos }: FilesBreadcrumbProps) {
|
||||
let routePath = 'files';
|
||||
export default function FilesBreadcrumb({ path, isShowingNotes, isShowingPhotos, fileShareId }: FilesBreadcrumbProps) {
|
||||
let routePath = fileShareId ? `file-share/${fileShareId}` : 'files';
|
||||
let rootPath = '/';
|
||||
let itemPluralLabel = 'files';
|
||||
|
||||
if (isShowingNotes) {
|
||||
routePath = 'notes';
|
||||
itemPluralLabel = 'notes';
|
||||
rootPath = '/Notes/';
|
||||
} else if (isShowingPhotos) {
|
||||
routePath = 'photos';
|
||||
itemPluralLabel = 'photos';
|
||||
rootPath = '/Photos/';
|
||||
}
|
||||
|
||||
const itemPluralLabel = routePath;
|
||||
|
||||
if (path === rootPath) {
|
||||
return (
|
||||
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'>
|
||||
|
|
@ -30,7 +32,7 @@ export default function FilesBreadcrumb({ path, isShowingNotes, isShowingPhotos
|
|||
|
||||
return (
|
||||
<h3 class='text-base font-semibold text-white whitespace-nowrap mr-2'>
|
||||
{!isShowingNotes && !isShowingPhotos ? <a href={`/files?path=/`}>All files</a> : null}
|
||||
{!isShowingNotes && !isShowingPhotos ? <a href={`/${routePath}?path=/`}>All files</a> : null}
|
||||
{isShowingNotes ? <a href={`/notes?path=/Notes/`}>All notes</a> : null}
|
||||
{isShowingPhotos ? <a href={`/photos?path=/Photos/`}>All photos</a> : null}
|
||||
{pathParts.map((part, index) => {
|
||||
|
|
@ -57,7 +59,9 @@ export default function FilesBreadcrumb({ path, isShowingNotes, isShowingPhotos
|
|||
return (
|
||||
<>
|
||||
<span class='ml-2 text-xs'>/</span>
|
||||
<a href={`/${routePath}?path=/${fullPathForPart.join('/')}/`} class='ml-2'>{decodeURIComponent(part)}</a>
|
||||
<a href={`/${routePath}?path=/${encodeURIComponent(fullPathForPart.join('/'))}/`} class='ml-2'>
|
||||
{decodeURIComponent(part)}
|
||||
</a>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -1,43 +1,63 @@
|
|||
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[];
|
||||
files: DirectoryFile[];
|
||||
chosenDirectories?: Pick<Directory, 'parent_path' | 'directory_name'>[];
|
||||
chosenFiles?: Pick<DirectoryFile, 'parent_path' | 'file_name'>[];
|
||||
onClickChooseFile?: (parentPath: string, name: string) => void;
|
||||
onClickChooseDirectory?: (parentPath: string, name: string) => void;
|
||||
onClickOpenRenameDirectory?: (parentPath: string, name: string) => void;
|
||||
onClickOpenRenameFile?: (parentPath: string, name: string) => void;
|
||||
onClickOpenMoveDirectory?: (parentPath: string, name: string) => void;
|
||||
onClickOpenMoveFile?: (parentPath: string, name: string) => void;
|
||||
onClickDeleteDirectory?: (parentPath: string, name: string) => Promise<void>;
|
||||
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;
|
||||
}
|
||||
|
||||
export default function ListFiles(
|
||||
{
|
||||
directories,
|
||||
files,
|
||||
chosenDirectories = [],
|
||||
chosenFiles = [],
|
||||
onClickChooseFile,
|
||||
onClickChooseDirectory,
|
||||
onClickOpenRenameDirectory,
|
||||
onClickOpenRenameFile,
|
||||
onClickOpenMoveDirectory,
|
||||
onClickOpenMoveFile,
|
||||
onClickDeleteDirectory,
|
||||
onClickDeleteFile,
|
||||
onClickCreateShare,
|
||||
onClickOpenManageShare,
|
||||
onClickDownloadDirectory,
|
||||
isShowingNotes,
|
||||
isShowingPhotos,
|
||||
fileShareId,
|
||||
}: ListFilesProps,
|
||||
) {
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
let routePath = 'files';
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', dateFormatOptions);
|
||||
|
||||
let routePath = fileShareId ? `file-share/${fileShareId}` : 'files';
|
||||
let itemSingleLabel = 'file';
|
||||
let itemPluralLabel = 'files';
|
||||
|
||||
|
|
@ -55,17 +75,45 @@ export default function ListFiles(
|
|||
return null;
|
||||
}
|
||||
|
||||
const isAnyItemChosen = chosenDirectories.length > 0 || chosenFiles.length > 0;
|
||||
|
||||
function chooseAllItems() {
|
||||
if (typeof onClickChooseFile !== 'undefined') {
|
||||
files.forEach((files) => onClickChooseFile(files.parent_path, files.file_name));
|
||||
}
|
||||
|
||||
if (typeof onClickChooseDirectory !== 'undefined') {
|
||||
directories.forEach((directory) => onClickChooseDirectory(directory.parent_path, directory.directory_name));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section class='mx-auto max-w-7xl my-8'>
|
||||
<table class='w-full border-collapse bg-gray-900 text-left text-sm text-slate-500 shadow-sm rounded-md'>
|
||||
<thead>
|
||||
<tr class='border-b border-slate-600'>
|
||||
{(directories.length === 0 && files.length === 0) ||
|
||||
(typeof onClickChooseFile === 'undefined' && typeof onClickChooseDirectory === 'undefined') ||
|
||||
fileShareId
|
||||
? null
|
||||
: (
|
||||
<th scope='col' class='pl-6 pr-2 font-medium text-white w-3'>
|
||||
<input
|
||||
class='w-3 h-3 cursor-pointer text-[#51A4FB] bg-slate-100 border-slate-300 rounded dark:bg-slate-700 dark:border-slate-600'
|
||||
type='checkbox'
|
||||
onClick={() => chooseAllItems()}
|
||||
checked={isAnyItemChosen}
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white'>Name</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white w-56'>Last update</th>
|
||||
<th scope='col' class='px-6 py-4 font-medium text-white w-64'>Last update</th>
|
||||
{isShowingNotes || isShowingPhotos
|
||||
? null
|
||||
: <th scope='col' class='px-6 py-4 font-medium text-white w-32'>Size</th>}
|
||||
{isShowingPhotos ? null : <th scope='col' class='px-6 py-4 font-medium text-white w-20'></th>}
|
||||
{isShowingPhotos || fileShareId
|
||||
? null
|
||||
: <th scope='col' class='px-6 py-4 font-medium text-white w-24'></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class='divide-y divide-slate-600 border-t border-slate-600'>
|
||||
|
|
@ -74,13 +122,28 @@ export default function ListFiles(
|
|||
|
||||
return (
|
||||
<tr class='bg-slate-700 hover:bg-slate-600 group'>
|
||||
{typeof onClickChooseDirectory === 'undefined' || fileShareId ? null : (
|
||||
<td class='gap-3 pl-6 pr-2 py-4'>
|
||||
{fullPath === TRASH_PATH ? null : (
|
||||
<input
|
||||
class='w-3 h-3 cursor-pointer text-[#51A4FB] bg-slate-100 border-slate-300 rounded dark:bg-slate-700 dark:border-slate-600'
|
||||
type='checkbox'
|
||||
onClick={() => onClickChooseDirectory(directory.parent_path, directory.directory_name)}
|
||||
checked={Boolean(chosenDirectories.find((_directory) =>
|
||||
_directory.parent_path === directory.parent_path &&
|
||||
_directory.directory_name === directory.directory_name
|
||||
))}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td class='flex gap-3 px-6 py-4'>
|
||||
<a
|
||||
href={`/${routePath}?path=${fullPath}`}
|
||||
href={`/${routePath}?path=${encodeURIComponent(fullPath)}`}
|
||||
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}
|
||||
|
|
@ -95,22 +158,38 @@ export default function ListFiles(
|
|||
</td>
|
||||
{isShowingNotes || isShowingPhotos ? null : (
|
||||
<td class='px-6 py-4 text-slate-200'>
|
||||
-
|
||||
{humanFileSize(directory.size_in_bytes)}
|
||||
</td>
|
||||
)}
|
||||
{isShowingPhotos ? null : (
|
||||
{isShowingPhotos || fileShareId ? null : (
|
||||
<td class='px-6 py-4'>
|
||||
{(fullPath === TRASH_PATH || typeof onClickOpenRenameDirectory === 'undefined' ||
|
||||
typeof onClickOpenMoveDirectory === 'undefined')
|
||||
? null
|
||||
: (
|
||||
<section class='flex items-center justify-end w-20'>
|
||||
<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}
|
||||
|
|
@ -123,7 +202,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}
|
||||
|
|
@ -133,11 +212,11 @@ export default function ListFiles(
|
|||
</span>
|
||||
{typeof onClickDeleteDirectory === 'undefined' ? null : (
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100'
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
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}
|
||||
|
|
@ -146,6 +225,36 @@ export default function ListFiles(
|
|||
/>
|
||||
</span>
|
||||
)}
|
||||
{typeof onClickCreateShare === 'undefined' || directory.file_share_id ? null : (
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
onClick={() => onClickCreateShare(join(directory.parent_path, directory.directory_name))}
|
||||
>
|
||||
<img
|
||||
src='/public/images/share.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
alt='Create public share link'
|
||||
title='Create public share link'
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{typeof onClickOpenManageShare === 'undefined' || !directory.file_share_id ? null : (
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
onClick={() => onClickOpenManageShare(directory.file_share_id!)}
|
||||
>
|
||||
<img
|
||||
src='/public/images/share.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
alt='Manage public share link'
|
||||
title='Manage public share link'
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</td>
|
||||
|
|
@ -155,15 +264,31 @@ export default function ListFiles(
|
|||
})}
|
||||
{files.map((file) => (
|
||||
<tr class='bg-slate-700 hover:bg-slate-600 group'>
|
||||
{typeof onClickChooseFile === 'undefined' || fileShareId ? null : (
|
||||
<td class='gap-3 pl-6 pr-2 py-4'>
|
||||
<input
|
||||
class='w-3 h-3 cursor-pointer text-[#51A4FB] bg-slate-100 border-slate-300 rounded dark:bg-slate-700 dark:border-slate-600'
|
||||
type='checkbox'
|
||||
onClick={() => onClickChooseFile(file.parent_path, file.file_name)}
|
||||
checked={Boolean(
|
||||
chosenFiles.find((_file) =>
|
||||
_file.parent_path === file.parent_path && _file.file_name === file.file_name
|
||||
),
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
<td class='flex gap-3 px-6 py-4'>
|
||||
<a
|
||||
href={`/${routePath}/open/${file.file_name}?path=${file.parent_path}`}
|
||||
href={`/${routePath}/open/${encodeURIComponent(file.file_name)}?path=${
|
||||
encodeURIComponent(file.parent_path)
|
||||
}`}
|
||||
class='flex items-center font-normal text-white'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<img
|
||||
src='/images/file.svg'
|
||||
src='/public/images/file.svg'
|
||||
class='white drop-shadow-md mr-2'
|
||||
width={18}
|
||||
height={18}
|
||||
|
|
@ -181,16 +306,16 @@ export default function ListFiles(
|
|||
{humanFileSize(file.size_in_bytes)}
|
||||
</td>
|
||||
)}
|
||||
{isShowingPhotos ? null : (
|
||||
{isShowingPhotos || fileShareId ? null : (
|
||||
<td class='px-6 py-4'>
|
||||
<section class='flex items-center justify-end w-20'>
|
||||
<section class='flex items-center justify-end w-24'>
|
||||
{typeof onClickOpenRenameFile === 'undefined' ? null : (
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
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}
|
||||
|
|
@ -205,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}
|
||||
|
|
@ -216,11 +341,11 @@ export default function ListFiles(
|
|||
)}
|
||||
{typeof onClickDeleteFile === 'undefined' ? null : (
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100'
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
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}
|
||||
|
|
@ -229,6 +354,36 @@ export default function ListFiles(
|
|||
/>
|
||||
</span>
|
||||
)}
|
||||
{typeof onClickCreateShare === 'undefined' || file.file_share_id ? null : (
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
onClick={() => onClickCreateShare(join(file.parent_path, file.file_name))}
|
||||
>
|
||||
<img
|
||||
src='/public/images/share.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
alt='Create public share link'
|
||||
title='Create public share link'
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{typeof onClickOpenManageShare === 'undefined' || !file.file_share_id ? null : (
|
||||
<span
|
||||
class='invisible cursor-pointer group-hover:visible opacity-50 hover:opacity-100 mr-2'
|
||||
onClick={() => onClickOpenManageShare(file.file_share_id!)}
|
||||
>
|
||||
<img
|
||||
src='/public/images/share.svg'
|
||||
class='white drop-shadow-md'
|
||||
width={18}
|
||||
height={18}
|
||||
alt='Manage public share link'
|
||||
title='Manage public share link'
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</section>
|
||||
</td>
|
||||
)}
|
||||
|
|
@ -237,7 +392,7 @@ export default function ListFiles(
|
|||
{directories.length === 0 && files.length === 0
|
||||
? (
|
||||
<tr>
|
||||
<td class='flex gap-3 px-6 py-4 font-normal' colspan={4}>
|
||||
<td class='flex gap-3 px-6 py-4 font-normal' colspan={5}>
|
||||
<div class='text-md'>
|
||||
<div class='font-medium text-slate-400'>No {itemPluralLabel} to show</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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,16 +24,18 @@ export default function ListFiles(
|
|||
onClickDeleteDirectory,
|
||||
onClickDeleteFile,
|
||||
isShowingNotes,
|
||||
}: ListFilesProps,
|
||||
}: ListPhotosProps,
|
||||
) {
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', dateFormatOptions);
|
||||
|
||||
const routePath = isShowingNotes ? 'notes' : 'files';
|
||||
const itemSingleLabel = isShowingNotes ? 'note' : 'file';
|
||||
|
|
@ -58,11 +60,11 @@ export default function ListFiles(
|
|||
<tr class='bg-slate-700 hover:bg-slate-600 group'>
|
||||
<td class='flex gap-3 px-6 py-4'>
|
||||
<a
|
||||
href={`/${routePath}?path=${fullPath}`}
|
||||
href={`/${routePath}?path=${encodeURIComponent(fullPath)}`}
|
||||
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}
|
||||
|
|
@ -77,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'>
|
||||
|
|
@ -91,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}
|
||||
|
|
@ -104,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}
|
||||
|
|
@ -117,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}
|
||||
|
|
@ -135,13 +137,15 @@ export default function ListFiles(
|
|||
<tr class='bg-slate-700 hover:bg-slate-600 group'>
|
||||
<td class='flex gap-3 px-6 py-4'>
|
||||
<a
|
||||
href={`/${routePath}/open/${file.file_name}?path=${file.parent_path}`}
|
||||
href={`/${routePath}/open/${encodeURIComponent(file.file_name)}?path=${
|
||||
encodeURIComponent(file.parent_path)
|
||||
}`}
|
||||
class='flex items-center font-normal text-white'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
<img
|
||||
src='/images/file.svg'
|
||||
src='/public/images/file.svg'
|
||||
class='white drop-shadow-md mr-2'
|
||||
width={18}
|
||||
height={18}
|
||||
|
|
@ -167,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}
|
||||
|
|
@ -182,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}
|
||||
|
|
@ -196,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,41 +1,68 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
|
||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import { baseUrl } from '/lib/utils/misc.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 '/pages/api/files/create-share.ts';
|
||||
import {
|
||||
RequestBody as UpdateShareRequestBody,
|
||||
ResponseBody as UpdateShareResponseBody,
|
||||
} from '/pages/api/files/update-share.ts';
|
||||
import {
|
||||
RequestBody as DeleteShareRequestBody,
|
||||
ResponseBody as DeleteShareResponseBody,
|
||||
} from '/pages/api/files/delete-share.ts';
|
||||
import SearchFiles from './SearchFiles.tsx';
|
||||
import ListFiles from './ListFiles.tsx';
|
||||
import FilesBreadcrumb from './FilesBreadcrumb.tsx';
|
||||
import CreateDirectoryModal from './CreateDirectoryModal.tsx';
|
||||
import RenameDirectoryOrFileModal from './RenameDirectoryOrFileModal.tsx';
|
||||
import MoveDirectoryOrFileModal from './MoveDirectoryOrFileModal.tsx';
|
||||
import CreateShareModal from './CreateShareModal.tsx';
|
||||
import ManageShareModal from './ManageShareModal.tsx';
|
||||
|
||||
interface MainFilesProps {
|
||||
initialDirectories: Directory[];
|
||||
initialFiles: DirectoryFile[];
|
||||
initialPath: string;
|
||||
baseUrl: string;
|
||||
isFileSharingAllowed: boolean;
|
||||
areDirectoryDownloadsAllowed: boolean;
|
||||
fileShareId?: string;
|
||||
}
|
||||
|
||||
export default function MainFiles({ initialDirectories, initialFiles, initialPath }: MainFilesProps) {
|
||||
export default function MainFiles(
|
||||
{
|
||||
initialDirectories,
|
||||
initialFiles,
|
||||
initialPath,
|
||||
baseUrl,
|
||||
isFileSharingAllowed,
|
||||
areDirectoryDownloadsAllowed,
|
||||
fileShareId,
|
||||
}: MainFilesProps,
|
||||
) {
|
||||
const isAdding = useSignal<boolean>(false);
|
||||
const isUploading = useSignal<boolean>(false);
|
||||
const isDeleting = useSignal<boolean>(false);
|
||||
|
|
@ -43,7 +70,12 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
|
|||
const directories = useSignal<Directory[]>(initialDirectories);
|
||||
const files = useSignal<DirectoryFile[]>(initialFiles);
|
||||
const path = useSignal<string>(initialPath);
|
||||
const areNewOptionsOption = useSignal<boolean>(false);
|
||||
const chosenDirectories = useSignal<Pick<Directory, 'parent_path' | 'directory_name'>[]>([]);
|
||||
const chosenFiles = useSignal<Pick<DirectoryFile, 'parent_path' | 'file_name'>[]>([]);
|
||||
const isAnyItemChosen = chosenDirectories.value.length > 0 || chosenFiles.value.length > 0;
|
||||
const bulkItemsCount = chosenDirectories.value.length + chosenFiles.value.length;
|
||||
const areNewOptionsOpen = useSignal<boolean>(false);
|
||||
const areBulkOptionsOpen = useSignal<boolean>(false);
|
||||
const isNewDirectoryModalOpen = useSignal<boolean>(false);
|
||||
const renameDirectoryOrFileModal = useSignal<
|
||||
{ isOpen: boolean; isDirectory: boolean; parentPath: string; name: string } | null
|
||||
|
|
@ -51,11 +83,20 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
|
|||
const moveDirectoryOrFileModal = useSignal<
|
||||
{ isOpen: boolean; isDirectory: boolean; path: string; name: string } | null
|
||||
>(null);
|
||||
const createShareModal = useSignal<{ isOpen: boolean; filePath: string; password?: string } | null>(null);
|
||||
const manageShareModal = useSignal<{ isOpen: boolean; fileShareId: string } | null>(null);
|
||||
|
||||
function onClickUploadFile() {
|
||||
function onClickUploadFile(uploadDirectory = false) {
|
||||
const fileInput = document.createElement('input');
|
||||
fileInput.type = 'file';
|
||||
fileInput.multiple = true;
|
||||
if (uploadDirectory) {
|
||||
fileInput.webkitdirectory = true;
|
||||
// @ts-expect-error - mozdirectory is not typed
|
||||
fileInput.mozdirectory = true;
|
||||
// @ts-expect-error - directory is not typed
|
||||
fileInput.directory = true;
|
||||
}
|
||||
fileInput.click();
|
||||
|
||||
fileInput.onchange = async (event) => {
|
||||
|
|
@ -70,18 +111,32 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
|
|||
continue;
|
||||
}
|
||||
|
||||
areNewOptionsOption.value = false;
|
||||
areNewOptionsOpen.value = false;
|
||||
|
||||
const requestBody = new FormData();
|
||||
requestBody.set('path_in_view', path.value);
|
||||
requestBody.set('parent_path', path.value);
|
||||
requestBody.set('name', chosenFile.name);
|
||||
requestBody.set('contents', chosenFile);
|
||||
|
||||
// Keep directory structure if the file comes from a chosen directory
|
||||
if (chosenFile.webkitRelativePath) {
|
||||
const directoryPath = chosenFile.webkitRelativePath.replace(chosenFile.name, '');
|
||||
|
||||
// We don't need to worry about path joining here, the API will handle it (and make sure it's secure)
|
||||
requestBody.set('parent_path', `${path.value}${directoryPath}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/files/upload`, {
|
||||
method: 'POST',
|
||||
body: requestBody,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload file. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as UploadResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -89,6 +144,7 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
|
|||
}
|
||||
|
||||
files.value = [...result.newFiles];
|
||||
directories.value = [...result.newDirectories];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
|
@ -116,7 +172,7 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
|
|||
return;
|
||||
}
|
||||
|
||||
areNewOptionsOption.value = false;
|
||||
areNewOptionsOpen.value = false;
|
||||
isAdding.value = true;
|
||||
|
||||
try {
|
||||
|
|
@ -128,6 +184,11 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
|
|||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create directory. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as CreateDirectoryResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -149,7 +210,11 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
|
|||
}
|
||||
|
||||
function toggleNewOptionsDropdown() {
|
||||
areNewOptionsOption.value = !areNewOptionsOption.value;
|
||||
areNewOptionsOpen.value = !areNewOptionsOpen.value;
|
||||
}
|
||||
|
||||
function toggleBulkOptionsDropdown() {
|
||||
areBulkOptionsOpen.value = !areBulkOptionsOpen.value;
|
||||
}
|
||||
|
||||
function onClickOpenRenameDirectory(parentPath: string, name: string) {
|
||||
|
|
@ -193,6 +258,11 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
|
|||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to rename directory. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as RenameDirectoryResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -227,6 +297,11 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
|
|||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to rename file. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as RenameResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -281,6 +356,11 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
|
|||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to move directory. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as MoveDirectoryResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -313,6 +393,11 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
|
|||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to move file. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as MoveResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -328,9 +413,24 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
|
|||
moveDirectoryOrFileModal.value = null;
|
||||
}
|
||||
|
||||
async function onClickDeleteDirectory(parentPath: string, name: string) {
|
||||
if (confirm('Are you sure you want to delete this directory?')) {
|
||||
if (isDeleting.value) {
|
||||
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) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -345,6 +445,11 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
|
|||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete directory. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as DeleteDirectoryResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -360,9 +465,9 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
|
|||
}
|
||||
}
|
||||
|
||||
async function onClickDeleteFile(parentPath: string, name: string) {
|
||||
if (confirm('Are you sure you want to delete this file?')) {
|
||||
if (isDeleting.value) {
|
||||
async function onClickDeleteFile(parentPath: string, name: string, isBulkDeleting = false) {
|
||||
if (isBulkDeleting || confirm('Are you sure you want to delete this file?')) {
|
||||
if (!isBulkDeleting && isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -377,6 +482,11 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
|
|||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete file. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as DeleteResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -392,64 +502,341 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
|
|||
}
|
||||
}
|
||||
|
||||
function onClickChooseDirectory(parentPath: string, name: string) {
|
||||
if (parentPath === '/' && name === '.Trash') {
|
||||
return;
|
||||
}
|
||||
|
||||
const chosenDirectoryIndex = chosenDirectories.value.findIndex((directory) =>
|
||||
directory.parent_path === parentPath && directory.directory_name === name
|
||||
);
|
||||
|
||||
if (chosenDirectoryIndex === -1) {
|
||||
chosenDirectories.value = [...chosenDirectories.value, { parent_path: parentPath, directory_name: name }];
|
||||
} else {
|
||||
const newChosenDirectories = chosenDirectories.peek();
|
||||
newChosenDirectories.splice(chosenDirectoryIndex, 1);
|
||||
chosenDirectories.value = [...newChosenDirectories];
|
||||
}
|
||||
}
|
||||
|
||||
function onClickChooseFile(parentPath: string, name: string) {
|
||||
const chosenFileIndex = chosenFiles.value.findIndex((file) =>
|
||||
file.parent_path === parentPath && file.file_name === name
|
||||
);
|
||||
|
||||
if (chosenFileIndex === -1) {
|
||||
chosenFiles.value = [...chosenFiles.value, { parent_path: parentPath, file_name: name }];
|
||||
} else {
|
||||
const newChosenFiles = chosenFiles.peek();
|
||||
newChosenFiles.splice(chosenFileIndex, 1);
|
||||
chosenFiles.value = [...newChosenFiles];
|
||||
}
|
||||
}
|
||||
|
||||
async function onClickBulkDelete() {
|
||||
if (
|
||||
confirm(
|
||||
`Are you sure you want to delete ${bulkItemsCount === 1 ? 'this' : 'these'} ${bulkItemsCount} item${
|
||||
bulkItemsCount === 1 ? '' : 's'
|
||||
}?`,
|
||||
)
|
||||
) {
|
||||
if (isDeleting.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
for (const directory of chosenDirectories.value) {
|
||||
await onClickDeleteDirectory(directory.parent_path, directory.directory_name, true);
|
||||
}
|
||||
|
||||
for (const file of chosenFiles.value) {
|
||||
await onClickDeleteDirectory(file.parent_path, file.file_name, true);
|
||||
}
|
||||
|
||||
chosenDirectories.value = [];
|
||||
chosenFiles.value = [];
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onClickCreateShare(filePath: string) {
|
||||
if (createShareModal.value?.isOpen) {
|
||||
createShareModal.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
createShareModal.value = {
|
||||
isOpen: true,
|
||||
filePath,
|
||||
};
|
||||
}
|
||||
|
||||
async function onClickSaveFileShare(filePath: string, password?: string) {
|
||||
if (isAdding.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
isAdding.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: CreateShareRequestBody = {
|
||||
pathInView: path.value,
|
||||
filePath,
|
||||
password,
|
||||
};
|
||||
const response = await fetch(`/api/files/create-share`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create share. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as CreateShareResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to create share!');
|
||||
}
|
||||
|
||||
directories.value = [...result.newDirectories];
|
||||
files.value = [...result.newFiles];
|
||||
|
||||
createShareModal.value = null;
|
||||
|
||||
onClickOpenManageShare(result.createdFileShareId);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isAdding.value = false;
|
||||
}
|
||||
|
||||
function onClickCloseFileShare() {
|
||||
createShareModal.value = null;
|
||||
}
|
||||
|
||||
function onClickOpenManageShare(fileShareId: string) {
|
||||
manageShareModal.value = {
|
||||
isOpen: true,
|
||||
fileShareId,
|
||||
};
|
||||
}
|
||||
|
||||
async function onClickUpdateFileShare(fileShareId: string, password?: string) {
|
||||
if (isUpdating.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fileShareId) {
|
||||
return;
|
||||
}
|
||||
|
||||
isUpdating.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: UpdateShareRequestBody = {
|
||||
pathInView: path.value,
|
||||
fileShareId,
|
||||
password,
|
||||
};
|
||||
const response = await fetch(`/api/files/update-share`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update share. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as UpdateShareResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to update share!');
|
||||
}
|
||||
|
||||
directories.value = [...result.newDirectories];
|
||||
files.value = [...result.newFiles];
|
||||
|
||||
manageShareModal.value = null;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isUpdating.value = false;
|
||||
}
|
||||
|
||||
function onClickCloseManageShare() {
|
||||
manageShareModal.value = null;
|
||||
}
|
||||
|
||||
async function onClickDeleteFileShare(fileShareId: string) {
|
||||
if (!fileShareId || isDeleting.value || !confirm('Are you sure you want to delete this public share link?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: DeleteShareRequestBody = {
|
||||
pathInView: path.value,
|
||||
fileShareId,
|
||||
};
|
||||
const response = await fetch(`/api/files/delete-share`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete file share. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as DeleteShareResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to delete file share!');
|
||||
}
|
||||
|
||||
directories.value = [...result.newDirectories];
|
||||
files.value = [...result.newFiles];
|
||||
|
||||
manageShareModal.value = null;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
isDeleting.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'>
|
||||
<SearchFiles />
|
||||
{!fileShareId ? <SearchFiles /> : null}
|
||||
|
||||
{isAnyItemChosen
|
||||
? (
|
||||
<section class='relative inline-block text-left ml-2'>
|
||||
<div>
|
||||
<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 w-11 h-9'
|
||||
type='button'
|
||||
title='Bulk actions'
|
||||
id='bulk-button'
|
||||
aria-expanded='true'
|
||||
aria-haspopup='true'
|
||||
onClick={() => toggleBulkOptionsDropdown()}
|
||||
>
|
||||
<img
|
||||
src={`/public/images/${areBulkOptionsOpen.value ? 'hide-options' : 'show-options'}.svg`}
|
||||
alt='Bulk actions'
|
||||
class={`white w-5 max-w-5`}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</button>
|
||||
</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/15 focus:outline-none ${
|
||||
!areBulkOptionsOpen.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='bulk-button'
|
||||
tabindex={-1}
|
||||
>
|
||||
<div class='py-1'>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickBulkDelete()}
|
||||
type='button'
|
||||
>
|
||||
Delete {bulkItemsCount} item{bulkItemsCount === 1 ? '' : 's'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class='flex items-center justify-end'>
|
||||
<FilesBreadcrumb path={path.value} />
|
||||
<FilesBreadcrumb path={path.value} fileShareId={fileShareId} />
|
||||
|
||||
<section class='relative inline-block text-left ml-2'>
|
||||
<div>
|
||||
<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 file or directory'
|
||||
id='new-button'
|
||||
aria-expanded='true'
|
||||
aria-haspopup='true'
|
||||
onClick={() => toggleNewOptionsDropdown()}
|
||||
>
|
||||
<img
|
||||
src='/images/add.svg'
|
||||
alt='Add new file or directory'
|
||||
class={`white ${isAdding.value || isUploading.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{!fileShareId
|
||||
? (
|
||||
<section class='relative inline-block text-left ml-2'>
|
||||
<div>
|
||||
<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 file or directory'
|
||||
id='new-button'
|
||||
aria-expanded='true'
|
||||
aria-haspopup='true'
|
||||
onClick={() => toggleNewOptionsDropdown()}
|
||||
>
|
||||
<img
|
||||
src='/public/images/add.svg'
|
||||
alt='Add new file or directory'
|
||||
class={`white ${isAdding.value || isUploading.value ? 'animate-spin' : ''}`}
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
</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 ring-opacity-15 focus:outline-none ${
|
||||
!areNewOptionsOption.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='new-button'
|
||||
tabindex={-1}
|
||||
>
|
||||
<div class='py-1'>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickUploadFile()}
|
||||
<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 ${
|
||||
!areNewOptionsOpen.value ? 'hidden' : ''
|
||||
}`}
|
||||
role='menu'
|
||||
aria-orientation='vertical'
|
||||
aria-labelledby='new-button'
|
||||
tabindex={-1}
|
||||
>
|
||||
Upload File
|
||||
</button>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickCreateDirectory()}
|
||||
>
|
||||
New Directory
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class='py-1'>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickUploadFile()}
|
||||
type='button'
|
||||
>
|
||||
Upload Files
|
||||
</button>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickUploadFile(true)}
|
||||
type='button'
|
||||
>
|
||||
Upload Directory
|
||||
</button>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickCreateDirectory()}
|
||||
type='button'
|
||||
>
|
||||
New Directory
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
</section>
|
||||
</section>
|
||||
|
||||
|
|
@ -457,12 +844,20 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
|
|||
<ListFiles
|
||||
directories={directories.value}
|
||||
files={files.value}
|
||||
chosenDirectories={chosenDirectories.value}
|
||||
chosenFiles={chosenFiles.value}
|
||||
onClickChooseDirectory={onClickChooseDirectory}
|
||||
onClickChooseFile={onClickChooseFile}
|
||||
onClickOpenRenameDirectory={onClickOpenRenameDirectory}
|
||||
onClickOpenRenameFile={onClickOpenRenameFile}
|
||||
onClickOpenMoveDirectory={onClickOpenMoveDirectory}
|
||||
onClickOpenMoveFile={onClickOpenMoveFile}
|
||||
onClickDeleteDirectory={onClickDeleteDirectory}
|
||||
onClickDeleteFile={onClickDeleteFile}
|
||||
onClickCreateShare={isFileSharingAllowed ? onClickCreateShare : undefined}
|
||||
onClickOpenManageShare={isFileSharingAllowed ? onClickOpenManageShare : undefined}
|
||||
onClickDownloadDirectory={areDirectoryDownloadsAllowed ? onClickDownloadDirectory : undefined}
|
||||
fileShareId={fileShareId}
|
||||
/>
|
||||
|
||||
<span
|
||||
|
|
@ -471,28 +866,28 @@ export default function MainFiles({ 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}
|
||||
{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}
|
||||
|
|
@ -500,33 +895,76 @@ export default function MainFiles({ initialDirectories, initialFiles, initialPat
|
|||
</span>
|
||||
</section>
|
||||
|
||||
<section class='flex flex-row items-center justify-start my-12'>
|
||||
<span class='font-semibold'>WebDav URL:</span>{' '}
|
||||
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/dav</code>
|
||||
</section>
|
||||
{!fileShareId
|
||||
? (
|
||||
<section class='flex flex-row items-center justify-start my-12'>
|
||||
<span class='font-semibold'>WebDav URL:</span>{' '}
|
||||
<code class='bg-slate-600 mx-2 px-2 py-1 rounded-md'>{baseUrl}/dav</code>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
<CreateDirectoryModal
|
||||
isOpen={isNewDirectoryModalOpen.value}
|
||||
onClickSave={onClickSaveDirectory}
|
||||
onClose={onCloseCreateDirectory}
|
||||
/>
|
||||
{!fileShareId
|
||||
? (
|
||||
<CreateDirectoryModal
|
||||
isOpen={isNewDirectoryModalOpen.value}
|
||||
onClickSave={onClickSaveDirectory}
|
||||
onClose={onCloseCreateDirectory}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
<RenameDirectoryOrFileModal
|
||||
isOpen={renameDirectoryOrFileModal.value?.isOpen || false}
|
||||
isDirectory={renameDirectoryOrFileModal.value?.isDirectory || false}
|
||||
initialName={renameDirectoryOrFileModal.value?.name || ''}
|
||||
onClickSave={renameDirectoryOrFileModal.value?.isDirectory ? onClickSaveRenameDirectory : onClickSaveRenameFile}
|
||||
onClose={onClickCloseRename}
|
||||
/>
|
||||
{!fileShareId
|
||||
? (
|
||||
<RenameDirectoryOrFileModal
|
||||
isOpen={renameDirectoryOrFileModal.value?.isOpen || false}
|
||||
isDirectory={renameDirectoryOrFileModal.value?.isDirectory || false}
|
||||
initialName={renameDirectoryOrFileModal.value?.name || ''}
|
||||
onClickSave={renameDirectoryOrFileModal.value?.isDirectory
|
||||
? onClickSaveRenameDirectory
|
||||
: onClickSaveRenameFile}
|
||||
onClose={onClickCloseRename}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
<MoveDirectoryOrFileModal
|
||||
isOpen={moveDirectoryOrFileModal.value?.isOpen || false}
|
||||
isDirectory={moveDirectoryOrFileModal.value?.isDirectory || false}
|
||||
initialPath={moveDirectoryOrFileModal.value?.path || ''}
|
||||
name={moveDirectoryOrFileModal.value?.name || ''}
|
||||
onClickSave={moveDirectoryOrFileModal.value?.isDirectory ? onClickSaveMoveDirectory : onClickSaveMoveFile}
|
||||
onClose={onClickCloseMove}
|
||||
/>
|
||||
{!fileShareId
|
||||
? (
|
||||
<MoveDirectoryOrFileModal
|
||||
isOpen={moveDirectoryOrFileModal.value?.isOpen || false}
|
||||
isDirectory={moveDirectoryOrFileModal.value?.isDirectory || false}
|
||||
initialPath={moveDirectoryOrFileModal.value?.path || ''}
|
||||
name={moveDirectoryOrFileModal.value?.name || ''}
|
||||
onClickSave={moveDirectoryOrFileModal.value?.isDirectory ? onClickSaveMoveDirectory : onClickSaveMoveFile}
|
||||
onClose={onClickCloseMove}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
{!fileShareId && isFileSharingAllowed
|
||||
? (
|
||||
<CreateShareModal
|
||||
isOpen={createShareModal.value?.isOpen || false}
|
||||
filePath={createShareModal.value?.filePath || ''}
|
||||
password={createShareModal.value?.password || ''}
|
||||
onClickSave={onClickSaveFileShare}
|
||||
onClose={onClickCloseFileShare}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
{!fileShareId && isFileSharingAllowed
|
||||
? (
|
||||
<ManageShareModal
|
||||
baseUrl={baseUrl}
|
||||
isOpen={manageShareModal.value?.isOpen || false}
|
||||
fileShareId={manageShareModal.value?.fileShareId || ''}
|
||||
onClickSave={onClickUpdateFileShare}
|
||||
onClickDelete={onClickDeleteFileShare}
|
||||
onClose={onClickCloseManageShare}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
126
components/files/ManageShareModal.tsx
Normal file
126
components/files/ManageShareModal.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { useSignal } from '@preact/signals';
|
||||
import { useEffect } from 'preact/hooks';
|
||||
|
||||
import { RequestBody, ResponseBody } from '/pages/api/files/get-share.ts';
|
||||
import { FileShare } from '/lib/types.ts';
|
||||
|
||||
interface ManageShareModalProps {
|
||||
baseUrl: string;
|
||||
isOpen: boolean;
|
||||
fileShareId: string;
|
||||
onClickSave: (fileShareId: string, password?: string) => Promise<void>;
|
||||
onClickDelete: (fileShareId: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ManageShareModal(
|
||||
{ baseUrl, isOpen, fileShareId, onClickSave, onClickDelete, onClose }: ManageShareModalProps,
|
||||
) {
|
||||
const newPassword = useSignal<string>('');
|
||||
|
||||
const isLoading = useSignal<boolean>(false);
|
||||
const fileShare = useSignal<FileShare | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFileShare();
|
||||
}, [fileShareId]);
|
||||
|
||||
async function fetchFileShare() {
|
||||
if (!fileShareId || isLoading.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
const requestBody: RequestBody = {
|
||||
fileShareId,
|
||||
};
|
||||
const response = await fetch(`/api/files/get-share`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get file share. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as ResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error('Failed to get file share!');
|
||||
}
|
||||
|
||||
fileShare.value = result.fileShare;
|
||||
|
||||
isLoading.value = false;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
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 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'>Manage Public Share Link</h1>
|
||||
<section class='py-5 my-2 border-y border-slate-500'>
|
||||
<section class='block mb-2'>
|
||||
<span class='font-semibold my-2 block'>Public Share URL:</span>{' '}
|
||||
<code class='bg-slate-700 my-2 px-2 py-1 rounded-md'>{baseUrl}/file-share/{fileShareId}</code>
|
||||
</section>
|
||||
<fieldset class='block mb-2'>
|
||||
<label class='text-slate-300 block pb-1' for='manage-share-password'>
|
||||
{fileShare.value?.extra.hashed_password ? 'New Password' : 'Set Password'}
|
||||
</label>
|
||||
<input
|
||||
class='input-field'
|
||||
type='password'
|
||||
name='manage-share-password'
|
||||
id='manage-share-password'
|
||||
value={newPassword.value}
|
||||
onInput={(event) => {
|
||||
newPassword.value = event.currentTarget.value;
|
||||
}}
|
||||
autocomplete='off'
|
||||
/>
|
||||
</fieldset>
|
||||
</section>
|
||||
<footer class='flex justify-between'>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => {
|
||||
onClickSave(fileShareId, newPassword.peek());
|
||||
newPassword.value = '';
|
||||
}}
|
||||
type='button'
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
<button
|
||||
class='px-5 py-2 bg-red-600 hover:bg-red-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickDelete(fileShareId)}
|
||||
type='button'
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
type='button'
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</footer>
|
||||
</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 {
|
||||
|
|
@ -42,6 +42,11 @@ export default function MoveDirectoryOrFileModal(
|
|||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get directories. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as ResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -73,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>
|
||||
|
||||
|
|
@ -118,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}
|
||||
|
|
@ -129,12 +134,14 @@ export default function MoveDirectoryOrFileModal(
|
|||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickSave(newPath.value)}
|
||||
type='button'
|
||||
>
|
||||
Move {isDirectory ? 'directory' : 'file'} here
|
||||
</button>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
type='button'
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
@ -51,12 +51,14 @@ export default function RenameDirectoryOrFileModal(
|
|||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickSave(newName.value)}
|
||||
type='button'
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
type='button'
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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[]>([]);
|
||||
|
|
@ -13,13 +12,15 @@ export default function SearchFiles({}: SearchFilesProps) {
|
|||
const searchTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
||||
const closeTimeout = useSignal<ReturnType<typeof setTimeout>>(0);
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', {
|
||||
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', dateFormatOptions);
|
||||
|
||||
function searchFiles(searchTerm: string) {
|
||||
if (searchTimeout.value) {
|
||||
|
|
@ -41,6 +42,11 @@ export default function SearchFiles({}: SearchFilesProps) {
|
|||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to search files. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as ResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -100,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'
|
||||
|
|
@ -116,7 +122,9 @@ export default function SearchFiles({}: SearchFilesProps) {
|
|||
{matchingDirectories.value.map((directory) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href={`/files?path=${directory.parent_path}${directory.directory_name}`}
|
||||
href={`/files?path=${encodeURIComponent(directory.parent_path)}${
|
||||
encodeURIComponent(directory.directory_name)
|
||||
}`}
|
||||
class={`block px-2 py-2 hover:no-underline hover:opacity-60 bg-slate-700 cursor-pointer font-normal`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
|
|
@ -136,7 +144,9 @@ export default function SearchFiles({}: SearchFilesProps) {
|
|||
{matchingFiles.value.map((file) => (
|
||||
<li class='mb-1'>
|
||||
<a
|
||||
href={`/files/open/${file.file_name}?path=${file.parent_path}`}
|
||||
href={`/files/open/${encodeURIComponent(file.file_name)}?path=${
|
||||
encodeURIComponent(file.parent_path)
|
||||
}`}
|
||||
class={`block px-2 py-2 hover:no-underline hover:opacity-60 bg-slate-700 cursor-pointer font-normal`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
|
|
|
|||
58
components/files/ShareVerifyForm.tsx
Normal file
58
components/files/ShareVerifyForm.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
interface ShareVerifyFormProps {
|
||||
error?: { title: string; message: string };
|
||||
}
|
||||
|
||||
export default function ShareVerifyForm(
|
||||
{ error }: ShareVerifyFormProps,
|
||||
) {
|
||||
return (
|
||||
<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
|
||||
</h2>
|
||||
<p class='mt-2 text-center text-sm text-gray-300'>
|
||||
You are required to authenticate with a password
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{error
|
||||
? (
|
||||
<section class='notification-error'>
|
||||
<h3>{error.title}</h3>
|
||||
<p>{error.message}</p>
|
||||
</section>
|
||||
)
|
||||
: null}
|
||||
|
||||
<form
|
||||
class='mb-6'
|
||||
method='POST'
|
||||
>
|
||||
<fieldset class='block mb-4'>
|
||||
<label class='text-slate-300 block pb-1' for='verify-password'>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
id='verify-password'
|
||||
name='password'
|
||||
placeholder='Password'
|
||||
class='mt-1 input-field'
|
||||
autocomplete='off'
|
||||
required
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
<section class='flex justify-center mt-8 mb-4'>
|
||||
<button
|
||||
type='submit'
|
||||
class='button'
|
||||
>
|
||||
Verify Password
|
||||
</button>
|
||||
</section>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -22,7 +21,9 @@ export default function Articles({ initialArticles }: ArticlesProps) {
|
|||
const sessionReadArticleIds = useSignal<Set<string>>(new Set());
|
||||
const isFilterDropdownOpen = useSignal<boolean>(false);
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium' });
|
||||
const dateFormatOptions: Intl.DateTimeFormatOptions = { dateStyle: 'medium' };
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', dateFormatOptions);
|
||||
|
||||
async function refreshArticles() {
|
||||
if (isRefreshing.value) {
|
||||
|
|
@ -32,11 +33,15 @@ 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) {
|
||||
throw new Error(`Failed to refresh articles. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as RefreshResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -87,6 +92,11 @@ export default function Articles({ initialArticles }: ArticlesProps) {
|
|||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to mark article as read. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as ReadResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -114,6 +124,11 @@ export default function Articles({ initialArticles }: ArticlesProps) {
|
|||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to mark all articles as read. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as ReadResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -131,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;
|
||||
}
|
||||
|
||||
|
|
@ -161,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'
|
||||
|
|
@ -175,6 +195,7 @@ export default function Articles({ initialArticles }: ArticlesProps) {
|
|||
filter.value.status === 'unread' ? 'font-semibold' : ''
|
||||
}`}
|
||||
onClick={() => setNewFilter({ status: 'unread' })}
|
||||
type='button'
|
||||
>
|
||||
Show only unread
|
||||
</button>
|
||||
|
|
@ -183,6 +204,7 @@ export default function Articles({ initialArticles }: ArticlesProps) {
|
|||
filter.value.status === 'all' ? 'font-semibold' : ''
|
||||
}`}
|
||||
onClick={() => setNewFilter({ status: 'all' })}
|
||||
type='button'
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
|
|
@ -197,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}
|
||||
|
|
@ -212,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}
|
||||
|
|
@ -232,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)}
|
||||
|
|
@ -247,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[];
|
||||
|
|
@ -57,7 +54,9 @@ export default function Feeds({ initialFeeds }: FeedsProps) {
|
|||
const feeds = useSignal<NewsFeed[]>(initialFeeds);
|
||||
const isOptionsDropdownOpen = useSignal<boolean>(false);
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium', timeStyle: 'short' });
|
||||
const dateFormatOptions: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' };
|
||||
|
||||
const dateFormat = new Intl.DateTimeFormat('en-GB', dateFormatOptions);
|
||||
|
||||
async function onClickAddFeed() {
|
||||
if (isAdding.value) {
|
||||
|
|
@ -84,6 +83,11 @@ export default function Feeds({ initialFeeds }: FeedsProps) {
|
|||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to add feed. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as AddResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -116,6 +120,11 @@ export default function Feeds({ initialFeeds }: FeedsProps) {
|
|||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete feed. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as DeleteResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -168,6 +177,11 @@ export default function Feeds({ initialFeeds }: FeedsProps) {
|
|||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to import feeds. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as ImportResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -195,13 +209,12 @@ export default function Feeds({ initialFeeds }: FeedsProps) {
|
|||
|
||||
isExporting.value = true;
|
||||
|
||||
const fileName = ['feeds-', new Date().toISOString().substring(0, 19).replace(/:/g, '-'), '.opml']
|
||||
.join('');
|
||||
const fileName = `feeds-${new Date().toISOString().substring(0, 19).replace(/:/g, '-')}.opml`;
|
||||
|
||||
const exportContents = formatNewsFeedsToOpml([...feeds.peek()]);
|
||||
|
||||
// Add content-type
|
||||
const xmlContent = ['data:application/xml; charset=utf-8,', exportContents].join('');
|
||||
const xmlContent = `data:application/xml; charset=utf-8,${exportContents}`;
|
||||
|
||||
// Download the file
|
||||
const data = encodeURI(xmlContent);
|
||||
|
|
@ -241,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'
|
||||
|
|
@ -253,12 +266,14 @@ export default function Feeds({ initialFeeds }: FeedsProps) {
|
|||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickImportOpml()}
|
||||
type='button'
|
||||
>
|
||||
Import OPML
|
||||
</button>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickExportOpml()}
|
||||
type='button'
|
||||
>
|
||||
Export OPML
|
||||
</button>
|
||||
|
|
@ -272,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}
|
||||
|
|
@ -315,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}
|
||||
|
|
@ -346,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>
|
||||
|
||||
|
|
@ -44,12 +44,14 @@ export default function CreateNoteModal(
|
|||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClickSave(newNoteName.value)}
|
||||
type='button'
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
class='px-5 py-2 bg-slate-600 hover:bg-slate-500 text-white cursor-pointer rounded-md'
|
||||
onClick={() => onClose()}
|
||||
type='button'
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -55,6 +55,7 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
|
|||
|
||||
const requestBody = new FormData();
|
||||
requestBody.set('parent_path', path.value);
|
||||
requestBody.set('path_in_view', path.value);
|
||||
requestBody.set('name', `${newNoteName}.md`);
|
||||
requestBody.set('contents', `# ${newNoteName}\n\nStart your new note!\n`);
|
||||
|
||||
|
|
@ -63,6 +64,11 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
|
|||
method: 'POST',
|
||||
body: requestBody,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create note. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as UploadResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -113,6 +119,11 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
|
|||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create directory. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as CreateDirectoryResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -154,6 +165,11 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
|
|||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete directory. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as DeleteDirectoryResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -186,6 +202,11 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
|
|||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete note. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as DeleteResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -205,7 +226,7 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
|
|||
<>
|
||||
<section class='flex flex-row items-center justify-between mb-4'>
|
||||
<section class='flex items-center justify-end w-full'>
|
||||
<FilesBreadcrumb path={path.value} isShowingNotes={true} />
|
||||
<FilesBreadcrumb path={path.value} isShowingNotes />
|
||||
|
||||
<section class='relative inline-block text-left ml-2'>
|
||||
<div>
|
||||
|
|
@ -219,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}
|
||||
|
|
@ -229,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'
|
||||
|
|
@ -241,12 +262,14 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
|
|||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickCreateNote()}
|
||||
type='button'
|
||||
>
|
||||
New Note
|
||||
</button>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickCreateDirectory()}
|
||||
type='button'
|
||||
>
|
||||
New Directory
|
||||
</button>
|
||||
|
|
@ -262,7 +285,7 @@ export default function MainNotes({ initialDirectories, initialFiles, initialPat
|
|||
files={files.value}
|
||||
onClickDeleteDirectory={onClickDeleteDirectory}
|
||||
onClickDeleteFile={onClickDeleteFile}
|
||||
isShowingNotes={true}
|
||||
isShowingNotes
|
||||
/>
|
||||
|
||||
<span
|
||||
|
|
@ -271,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 {
|
||||
|
|
@ -31,6 +31,11 @@ export default function Note({ fileName, currentPath, contents }: NoteProps) {
|
|||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to save note. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as ResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -68,7 +73,7 @@ export default function Note({ fileName, currentPath, contents }: NoteProps) {
|
|||
return (
|
||||
<section class='flex flex-col'>
|
||||
<section class='mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 w-full flex flex-row items-center justify-start'>
|
||||
<FilesBreadcrumb path={currentPath} isShowingNotes={true} />
|
||||
<FilesBreadcrumb path={currentPath} isShowingNotes />
|
||||
<h3 class='text-base text-white font-semibold'>
|
||||
<span class='mr-2 text-xs'>/</span>
|
||||
{decodeURIComponent(fileName)}
|
||||
|
|
@ -91,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[];
|
||||
|
|
@ -30,7 +30,9 @@ export default function ListPhotos(
|
|||
return (
|
||||
<article class='hover:opacity-70'>
|
||||
<a
|
||||
href={`/files/open/${file.file_name}?path=${file.parent_path}`}
|
||||
href={`/files/open/${encodeURIComponent(file.file_name)}?path=${
|
||||
encodeURIComponent(file.parent_path)
|
||||
}`}
|
||||
class='flex items-center'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
|
|
@ -39,7 +41,9 @@ export default function ListPhotos(
|
|||
? (
|
||||
<video class='h-auto max-w-full rounded-md' title={file.file_name}>
|
||||
<source
|
||||
src={`/files/open/${file.file_name}?path=${file.parent_path}`}
|
||||
src={`/files/open/${encodeURIComponent(file.file_name)}?path=${
|
||||
encodeURIComponent(file.parent_path)
|
||||
}`}
|
||||
type={`video/${extensionName}`}
|
||||
/>
|
||||
</video>
|
||||
|
|
@ -48,7 +52,9 @@ export default function ListPhotos(
|
|||
{isImage
|
||||
? (
|
||||
<img
|
||||
src={`/files/open/${file.file_name}?path=${file.parent_path}`}
|
||||
src={`/photos/thumbnail/${encodeURIComponent(file.file_name)}?path=${
|
||||
encodeURIComponent(file.parent_path)
|
||||
}`}
|
||||
class='h-auto max-w-full rounded-md'
|
||||
alt={file.file_name}
|
||||
title={file.file_name}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -49,6 +49,7 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa
|
|||
|
||||
const requestBody = new FormData();
|
||||
requestBody.set('parent_path', path.value);
|
||||
requestBody.set('path_in_view', path.value);
|
||||
requestBody.set('name', chosenFile.name);
|
||||
requestBody.set('contents', chosenFile);
|
||||
|
||||
|
|
@ -57,6 +58,11 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa
|
|||
method: 'POST',
|
||||
body: requestBody,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to upload photo. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as UploadResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -103,6 +109,11 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa
|
|||
method: 'POST',
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create directory. ${response.statusText} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const result = await response.json() as CreateDirectoryResponseBody;
|
||||
|
||||
if (!result.success) {
|
||||
|
|
@ -131,7 +142,7 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa
|
|||
<>
|
||||
<section class='flex flex-row items-center justify-between mb-4'>
|
||||
<section class='flex items-center justify-end w-full'>
|
||||
<FilesBreadcrumb path={path.value} isShowingPhotos={true} />
|
||||
<FilesBreadcrumb path={path.value} isShowingPhotos />
|
||||
|
||||
<section class='relative inline-block text-left ml-2'>
|
||||
<div>
|
||||
|
|
@ -145,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}
|
||||
|
|
@ -155,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'
|
||||
|
|
@ -167,12 +178,14 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa
|
|||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickUploadFile()}
|
||||
type='button'
|
||||
>
|
||||
Upload Photo
|
||||
</button>
|
||||
<button
|
||||
class={`text-white block px-4 py-2 text-sm w-full text-left hover:bg-slate-600`}
|
||||
onClick={() => onClickCreateDirectory()}
|
||||
type='button'
|
||||
>
|
||||
New Directory
|
||||
</button>
|
||||
|
|
@ -186,7 +199,7 @@ export default function MainPhotos({ initialDirectories, initialFiles, initialPa
|
|||
<ListFiles
|
||||
directories={directories.value}
|
||||
files={[]}
|
||||
isShowingPhotos={true}
|
||||
isShowingPhotos
|
||||
/>
|
||||
|
||||
<ListPhotos
|
||||
|
|
@ -199,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,10 +1,10 @@
|
|||
import { Cron } from 'https://deno.land/x/croner@8.0.1/dist/croner.js';
|
||||
import { Cron } from '@hexagon/croner';
|
||||
|
||||
import { isAppEnabled } from '/lib/config.ts';
|
||||
import { cleanupSessions } from './cleanup.ts';
|
||||
import { fetchNewArticles } from './news.ts';
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
import { cleanupSessions } from './sessions.ts';
|
||||
import { cleanupOldArticles, fetchNewArticles } from './news.ts';
|
||||
|
||||
export function startCrons() {
|
||||
export async function startCrons() {
|
||||
new Cron(
|
||||
// At 03:06 every day.
|
||||
'6 3 * * *',
|
||||
|
|
@ -14,10 +14,14 @@ export function startCrons() {
|
|||
},
|
||||
async () => {
|
||||
await cleanupSessions();
|
||||
|
||||
if (await AppConfig.isAppEnabled('news')) {
|
||||
await cleanupOldArticles();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (isAppEnabled('news')) {
|
||||
if (await AppConfig.isAppEnabled('news')) {
|
||||
new Cron(
|
||||
// Every 30 minutes.
|
||||
'*/30 * * * *',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import { NewsFeed } from '/lib/types.ts';
|
||||
import { concurrentPromises } from '/lib/utils/misc.ts';
|
||||
import { crawlNewsFeed } from '/lib/data/news.ts';
|
||||
import { concurrentPromises } from '/public/ts/utils/misc.ts';
|
||||
import { FeedModel } from '/lib/models/news.ts';
|
||||
|
||||
const db = new Database();
|
||||
|
||||
|
|
@ -18,10 +18,51 @@ export async function fetchNewArticles(forceFetch = false) {
|
|||
|
||||
console.info('Will crawl', feedsToCrawl.length, 'news feeds');
|
||||
|
||||
await concurrentPromises(feedsToCrawl.map((newsFeed) => () => crawlNewsFeed(newsFeed)), 3);
|
||||
await concurrentPromises(feedsToCrawl.map((newsFeed) => () => FeedModel.crawl(newsFeed)), 3);
|
||||
|
||||
console.info('Crawled', feedsToCrawl.length, 'news feeds');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function cleanupOldArticles() {
|
||||
const oneMonthAgo = new Date(new Date().setUTCMonth(new Date().getUTCMonth() - 1));
|
||||
|
||||
try {
|
||||
console.info('Will cleanup old articles');
|
||||
|
||||
const feedIdsToSkip = new Set<string>();
|
||||
|
||||
const feeds = await db.query<Pick<NewsFeed, 'id' | 'feed_url'>>(
|
||||
sql`SELECT "id", "feed_url" FROM "bewcloud_news_feeds" ORDER BY "last_crawled_at" ASC`,
|
||||
);
|
||||
|
||||
for (const feed of feeds) {
|
||||
const recentArticlesCount = (await db.query<{ count: number }>(
|
||||
sql`SELECT COUNT("id") AS "count" FROM "bewcloud_news_feed_articles" WHERE "feed_id" = $1 AND "article_date" >= $2`,
|
||||
[feed.id, oneMonthAgo.toISOString().substring(0, 10)],
|
||||
))[0].count;
|
||||
|
||||
// Don't delete old articles if the feed doesn't have many recent articles (low frequency feeds)
|
||||
if (recentArticlesCount <= 5) {
|
||||
feedIdsToSkip.add(feed.id);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await db.query<{ count: number }>(
|
||||
sql`WITH "deleted" AS (
|
||||
DELETE FROM "bewcloud_news_feed_articles" WHERE "is_read" = TRUE AND "article_date" <= $1 AND NOT ("feed_id" != ANY($2)) RETURNING *
|
||||
)
|
||||
SELECT COUNT(*) FROM "deleted"`,
|
||||
[
|
||||
oneMonthAgo.toISOString().substring(0, 10),
|
||||
[...feedIdsToSkip],
|
||||
],
|
||||
);
|
||||
|
||||
console.info('Deleted', result[0].count, 'old articles');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,27 +9,6 @@ SET xmloption = content;
|
|||
SET client_min_messages = warning;
|
||||
SET row_security = off;
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_user_sessions; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TABLE public.bewcloud_user_sessions (
|
||||
id uuid DEFAULT gen_random_uuid(),
|
||||
user_id uuid DEFAULT gen_random_uuid(),
|
||||
expires_at timestamp with time zone NOT NULL,
|
||||
last_seen_at timestamp with time zone DEFAULT now(),
|
||||
created_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.bewcloud_user_sessions OWNER TO postgres;
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_users; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TABLE public.bewcloud_users (
|
||||
id uuid DEFAULT gen_random_uuid(),
|
||||
email character varying NOT NULL,
|
||||
|
|
@ -40,12 +19,20 @@ CREATE TABLE public.bewcloud_users (
|
|||
created_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_users ADD CONSTRAINT bewcloud_users_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE public.bewcloud_users OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: bewcloud_verification_codes; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
CREATE TABLE public.bewcloud_user_sessions (
|
||||
id uuid DEFAULT gen_random_uuid(),
|
||||
user_id uuid DEFAULT gen_random_uuid(),
|
||||
expires_at timestamp with time zone NOT NULL,
|
||||
last_seen_at timestamp with time zone DEFAULT now(),
|
||||
created_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_user_sessions ADD CONSTRAINT bewcloud_user_sessions_pkey PRIMARY KEY (id);
|
||||
ALTER TABLE ONLY public.bewcloud_user_sessions ADD CONSTRAINT bewcloud_user_sessions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||
|
||||
|
||||
CREATE TABLE public.bewcloud_verification_codes (
|
||||
id uuid DEFAULT gen_random_uuid(),
|
||||
|
|
@ -56,13 +43,9 @@ CREATE TABLE public.bewcloud_verification_codes (
|
|||
created_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_verification_codes ADD CONSTRAINT bewcloud_verification_codes_pkey PRIMARY KEY (id);
|
||||
ALTER TABLE ONLY public.bewcloud_verification_codes ADD CONSTRAINT bewcloud_verification_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||
|
||||
ALTER TABLE public.bewcloud_verification_codes OWNER TO postgres;
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_dashboards; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TABLE public.bewcloud_dashboards (
|
||||
id uuid DEFAULT gen_random_uuid(),
|
||||
|
|
@ -71,15 +54,11 @@ CREATE TABLE public.bewcloud_dashboards (
|
|||
created_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.bewcloud_dashboards OWNER TO postgres;
|
||||
|
||||
CREATE UNIQUE INDEX bewcloud_dashboards_unique_index ON public.bewcloud_dashboards ( user_id );
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_dashboards ADD CONSTRAINT bewcloud_dashboards_pkey PRIMARY KEY (id);
|
||||
ALTER TABLE ONLY public.bewcloud_dashboards ADD CONSTRAINT bewcloud_dashboards_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||
|
||||
--
|
||||
-- Name: bewcloud_migrations; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TABLE public.bewcloud_migrations (
|
||||
id uuid DEFAULT gen_random_uuid(),
|
||||
|
|
@ -87,123 +66,4 @@ CREATE TABLE public.bewcloud_migrations (
|
|||
executed_at timestamp without time zone DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
ALTER TABLE public.bewcloud_migrations OWNER TO postgres;
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_user_sessions bewcloud_user_sessions_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_user_sessions
|
||||
ADD CONSTRAINT bewcloud_user_sessions_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_verification_codes bewcloud_verification_codes_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_verification_codes
|
||||
ADD CONSTRAINT bewcloud_verification_codes_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_users bewcloud_users_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_users
|
||||
ADD CONSTRAINT bewcloud_users_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_dashboards bewcloud_dashboards_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_dashboards
|
||||
ADD CONSTRAINT bewcloud_dashboards_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_dashboards bewcloud_dashboards_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_dashboards
|
||||
ADD CONSTRAINT bewcloud_dashboards_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_migrations bewcloud_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_migrations
|
||||
ADD CONSTRAINT bewcloud_migrations_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_user_sessions bewcloud_user_sessions_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_user_sessions
|
||||
ADD CONSTRAINT bewcloud_user_sessions_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_verification_codes bewcloud_verification_codes_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_verification_codes
|
||||
ADD CONSTRAINT bewcloud_verification_codes_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: TABLE bewcloud_user_sessions; Type: ACL; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
GRANT ALL ON TABLE public.bewcloud_user_sessions TO postgres;
|
||||
|
||||
|
||||
--
|
||||
-- Name: TABLE bewcloud_users; Type: ACL; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
GRANT ALL ON TABLE public.bewcloud_users TO postgres;
|
||||
|
||||
|
||||
--
|
||||
-- Name: TABLE bewcloud_verification_codes; Type: ACL; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
GRANT ALL ON TABLE public.bewcloud_verification_codes TO postgres;
|
||||
|
||||
|
||||
--
|
||||
-- Name: TABLE bewcloud_dashboards; Type: ACL; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
GRANT ALL ON TABLE public.bewcloud_dashboards TO postgres;
|
||||
|
||||
--
|
||||
-- Name: TABLE bewcloud_migrations; Type: ACL; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
GRANT ALL ON TABLE public.bewcloud_migrations TO postgres;
|
||||
|
||||
|
||||
--
|
||||
-- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres;
|
||||
|
||||
|
||||
--
|
||||
-- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres;
|
||||
|
||||
|
||||
--
|
||||
-- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES TO postgres;
|
||||
ALTER TABLE ONLY public.bewcloud_migrations ADD CONSTRAINT bewcloud_migrations_pkey PRIMARY KEY (id);
|
||||
|
|
|
|||
|
|
@ -9,11 +9,6 @@ SET xmloption = content;
|
|||
SET client_min_messages = warning;
|
||||
SET row_security = off;
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_news_feeds; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TABLE public.bewcloud_news_feeds (
|
||||
id uuid DEFAULT gen_random_uuid(),
|
||||
user_id uuid DEFAULT gen_random_uuid(),
|
||||
|
|
@ -23,38 +18,11 @@ CREATE TABLE public.bewcloud_news_feeds (
|
|||
created_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.bewcloud_news_feeds OWNER TO postgres;
|
||||
|
||||
CREATE UNIQUE INDEX bewcloud_news_feeds_unique_index ON public.bewcloud_news_feeds ( user_id, feed_url );
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_news_feeds ADD CONSTRAINT bewcloud_news_feeds_pkey PRIMARY KEY (id);
|
||||
ALTER TABLE ONLY public.bewcloud_news_feeds ADD CONSTRAINT bewcloud_news_feeds_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||
|
||||
--
|
||||
-- Name: bewcloud_news_feeds bewcloud_news_feeds_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_news_feeds
|
||||
ADD CONSTRAINT bewcloud_news_feeds_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_news_feeds bewcloud_news_feeds_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_news_feeds
|
||||
ADD CONSTRAINT bewcloud_news_feeds_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: TABLE bewcloud_news_feeds; Type: ACL; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
GRANT ALL ON TABLE public.bewcloud_news_feeds TO postgres;
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_news_feed_articles; Type: TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
CREATE TABLE public.bewcloud_news_feed_articles (
|
||||
id uuid DEFAULT gen_random_uuid(),
|
||||
|
|
@ -69,38 +37,8 @@ CREATE TABLE public.bewcloud_news_feed_articles (
|
|||
created_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.bewcloud_news_feed_articles OWNER TO postgres;
|
||||
|
||||
CREATE UNIQUE INDEX bewcloud_news_feed_articles_unique_index ON public.bewcloud_news_feed_articles ( user_id, feed_id, article_url );
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_news_feed_articles bewcloud_news_feed_articles_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_news_feed_articles
|
||||
ADD CONSTRAINT bewcloud_news_feed_articles_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_news_feed_articles bewcloud_news_feed_articles_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_news_feed_articles
|
||||
ADD CONSTRAINT bewcloud_news_feed_articles_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: bewcloud_news_feed_articles bewcloud_news_feed_articles_feed_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_news_feed_articles
|
||||
ADD CONSTRAINT bewcloud_news_feed_articles_feed_id_fkey FOREIGN KEY (feed_id) REFERENCES public.bewcloud_news_feeds(id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: TABLE bewcloud_news_feed_articles; Type: ACL; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
GRANT ALL ON TABLE public.bewcloud_news_feed_articles TO postgres;
|
||||
ALTER TABLE ONLY public.bewcloud_news_feed_articles ADD CONSTRAINT bewcloud_news_feed_articles_pkey PRIMARY KEY (id);
|
||||
ALTER TABLE ONLY public.bewcloud_news_feed_articles ADD CONSTRAINT bewcloud_news_feed_articles_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||
ALTER TABLE ONLY public.bewcloud_news_feed_articles ADD CONSTRAINT bewcloud_news_feed_articles_feed_id_fkey FOREIGN KEY (feed_id) REFERENCES public.bewcloud_news_feeds(id);
|
||||
|
|
|
|||
40
db-migrations/003-expenses-app.pgsql
Normal file
40
db-migrations/003-expenses-app.pgsql
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
SET idle_in_transaction_session_timeout = 0;
|
||||
SET client_encoding = 'UTF8';
|
||||
SET standard_conforming_strings = on;
|
||||
SELECT pg_catalog.set_config('search_path', '', false);
|
||||
SET check_function_bodies = false;
|
||||
SET xmloption = content;
|
||||
SET client_min_messages = warning;
|
||||
SET row_security = off;
|
||||
|
||||
CREATE TABLE public.bewcloud_budgets (
|
||||
id uuid DEFAULT gen_random_uuid(),
|
||||
user_id uuid DEFAULT gen_random_uuid(),
|
||||
name text NOT NULL,
|
||||
month character varying NOT NULL,
|
||||
value numeric NOT NULL,
|
||||
extra jsonb NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_budgets ADD CONSTRAINT bewcloud_budgets_pkey PRIMARY KEY (id);
|
||||
ALTER TABLE ONLY public.bewcloud_budgets ADD CONSTRAINT bewcloud_budgets_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||
ALTER TABLE ONLY public.bewcloud_budgets ADD CONSTRAINT bewcloud_budgets_user_id_name_month_unique UNIQUE (user_id, name, month);
|
||||
|
||||
|
||||
CREATE TABLE public.bewcloud_expenses (
|
||||
id uuid DEFAULT gen_random_uuid(),
|
||||
user_id uuid DEFAULT gen_random_uuid(),
|
||||
cost numeric NOT NULL,
|
||||
description text NOT NULL,
|
||||
budget text NOT NULL,
|
||||
date character varying NOT NULL,
|
||||
is_recurring boolean NOT NULL,
|
||||
extra jsonb NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_expenses ADD CONSTRAINT bewcloud_expenses_pkey PRIMARY KEY (id);
|
||||
ALTER TABLE ONLY public.bewcloud_expenses ADD CONSTRAINT bewcloud_expenses_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||
22
db-migrations/004-public-file-sharing.pgsql
Normal file
22
db-migrations/004-public-file-sharing.pgsql
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
SET statement_timeout = 0;
|
||||
SET lock_timeout = 0;
|
||||
SET idle_in_transaction_session_timeout = 0;
|
||||
SET client_encoding = 'UTF8';
|
||||
SET standard_conforming_strings = on;
|
||||
SELECT pg_catalog.set_config('search_path', '', false);
|
||||
SET check_function_bodies = false;
|
||||
SET xmloption = content;
|
||||
SET client_min_messages = warning;
|
||||
SET row_security = off;
|
||||
|
||||
CREATE TABLE public.bewcloud_file_shares (
|
||||
id uuid DEFAULT gen_random_uuid(),
|
||||
user_id uuid DEFAULT gen_random_uuid(),
|
||||
file_path text NOT NULL,
|
||||
extra jsonb NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE ONLY public.bewcloud_file_shares ADD CONSTRAINT bewcloud_file_shares_pkey PRIMARY KEY (id);
|
||||
ALTER TABLE ONLY public.bewcloud_file_shares ADD CONSTRAINT bewcloud_file_shares_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.bewcloud_users(id);
|
||||
ALTER TABLE ONLY public.bewcloud_file_shares ADD CONSTRAINT bewcloud_file_shares_user_id_file_path_unique UNIQUE (user_id, file_path);
|
||||
112
deno.json
112
deno.json
|
|
@ -1,24 +1,98 @@
|
|||
{
|
||||
"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"
|
||||
},
|
||||
"fmt": { "useTabs": false, "lineWidth": 120, "indentWidth": 2, "singleQuote": true, "proseWrap": "preserve" },
|
||||
"lint": {
|
||||
"rules": {
|
||||
"tags": ["fresh", "recommended"],
|
||||
"exclude": ["no-explicit-any", "no-empty-interface", "ban-types", "no-window"]
|
||||
"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"]
|
||||
}
|
||||
},
|
||||
"exclude": ["./_fresh/*", "./node_modules/*", "**/_fresh/*"],
|
||||
"compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "preact" },
|
||||
"nodeModulesDir": true,
|
||||
"importMap": "./import_map.json"
|
||||
"fmt": {
|
||||
"useTabs": false,
|
||||
"lineWidth": 120,
|
||||
"indentWidth": 2,
|
||||
"singleQuote": true,
|
||||
"proseWrap": "preserve",
|
||||
"exclude": ["README.md", "lib/models/dav.js", "public/css/tailwind.css"]
|
||||
},
|
||||
"lint": {
|
||||
"rules": {
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"nodeModulesDir": "auto",
|
||||
"imports": {
|
||||
"/": "./",
|
||||
"./": "./",
|
||||
|
||||
"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,19 +1,42 @@
|
|||
services:
|
||||
postgresql:
|
||||
image: postgres:15
|
||||
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:
|
||||
memlock:
|
||||
soft: -1
|
||||
hard: -1
|
||||
mem_limit: '256m'
|
||||
|
||||
# NOTE: If you don't want to use the CardDav/CalDav servers, you can comment/remove this service.
|
||||
radicale:
|
||||
image: tomsquest/docker-radicale:3.6.0.0
|
||||
ports:
|
||||
- 5232:5232
|
||||
init: true
|
||||
read_only: true
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- SETUID
|
||||
- SETGID
|
||||
- CHOWN
|
||||
- KILL
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./data-radicale:/data
|
||||
- ./radicale-config:/config:ro
|
||||
mem_limit: '256m'
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
|
|
|
|||
|
|
@ -1,34 +1,62 @@
|
|||
services:
|
||||
website:
|
||||
build: ghcr.io/bewcloud/bewcloud
|
||||
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
|
||||
mem_limit: '256m'
|
||||
user: "${UID}:${GID}" # if you run into issues with permissions for the data-files volume below, see other options at https://stackoverflow.com/a/56904335
|
||||
env_file:
|
||||
- path: .env
|
||||
required: true
|
||||
volumes:
|
||||
- ./data-files:/app/data-files
|
||||
- ./bewcloud.config.ts:/app/bewcloud.config.ts
|
||||
|
||||
postgresql:
|
||||
image: postgres:15
|
||||
image: postgres:18.1
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=fake
|
||||
- POSTGRES_DB=bewcloud
|
||||
restart: on-failure
|
||||
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
|
||||
hard: -1
|
||||
mem_limit: '256m'
|
||||
|
||||
# NOTE: If you don't want to use the CardDav/CalDav servers, you can comment/remove this service.
|
||||
radicale:
|
||||
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:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- SETUID
|
||||
- SETGID
|
||||
- CHOWN
|
||||
- KILL
|
||||
restart: always
|
||||
volumes:
|
||||
- ./data-radicale:/data
|
||||
- ./radicale-config:/config:ro
|
||||
mem_limit: '256m'
|
||||
|
||||
volumes:
|
||||
bewcloud-db:
|
||||
driver: local
|
||||
|
|
|
|||
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) {
|
||||
startCrons();
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwind()],
|
||||
});
|
||||
105
fresh.gen.ts
105
fresh.gen.ts
|
|
@ -1,105 +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 $_404 from './routes/_404.tsx';
|
||||
import * as $_app from './routes/_app.tsx';
|
||||
import * as $_middleware from './routes/_middleware.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_files_create_directory from './routes/api/files/create-directory.tsx';
|
||||
import * as $api_files_delete_directory from './routes/api/files/delete-directory.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 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_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 $dashboard from './routes/dashboard.tsx';
|
||||
import * as $dav from './routes/dav.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 $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 $photos from './routes/photos.tsx';
|
||||
import * as $settings from './routes/settings.tsx';
|
||||
import * as $signup from './routes/signup.tsx';
|
||||
import * as $Settings from './islands/Settings.tsx';
|
||||
import * as $dashboard_Links from './islands/dashboard/Links.tsx';
|
||||
import * as $dashboard_Notes from './islands/dashboard/Notes.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/_404.tsx': $_404,
|
||||
'./routes/_app.tsx': $_app,
|
||||
'./routes/_middleware.tsx': $_middleware,
|
||||
'./routes/api/dashboard/save-links.tsx': $api_dashboard_save_links,
|
||||
'./routes/api/dashboard/save-notes.tsx': $api_dashboard_save_notes,
|
||||
'./routes/api/files/create-directory.tsx': $api_files_create_directory,
|
||||
'./routes/api/files/delete-directory.tsx': $api_files_delete_directory,
|
||||
'./routes/api/files/delete.tsx': $api_files_delete,
|
||||
'./routes/api/files/get-directories.tsx': $api_files_get_directories,
|
||||
'./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/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/dashboard.tsx': $dashboard,
|
||||
'./routes/dav.tsx': $dav,
|
||||
'./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/news.tsx': $news,
|
||||
'./routes/news/feeds.tsx': $news_feeds,
|
||||
'./routes/notes.tsx': $notes,
|
||||
'./routes/notes/open/[fileName].tsx': $notes_open_fileName_,
|
||||
'./routes/photos.tsx': $photos,
|
||||
'./routes/settings.tsx': $settings,
|
||||
'./routes/signup.tsx': $signup,
|
||||
},
|
||||
islands: {
|
||||
'./islands/Settings.tsx': $Settings,
|
||||
'./islands/dashboard/Links.tsx': $dashboard_Links,
|
||||
'./islands/dashboard/Notes.tsx': $dashboard_Notes,
|
||||
'./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,20 +0,0 @@
|
|||
{
|
||||
"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.6.8/",
|
||||
"$fresh/": "https://deno.land/x/fresh@1.6.8/",
|
||||
"preact": "https://esm.sh/preact@10.20.2",
|
||||
"preact/": "https://esm.sh/preact@10.20.2/",
|
||||
"@preact/signals": "https://esm.sh/*@preact/signals@1.2.3",
|
||||
"@preact/signals-core": "https://esm.sh/*@preact/signals-core@1.6.0",
|
||||
"tailwindcss": "npm:tailwindcss@3.4.3",
|
||||
"tailwindcss/": "npm:/tailwindcss@3.4.3/",
|
||||
"tailwindcss/plugin": "npm:/tailwindcss@3.4.3/plugin.js",
|
||||
"std/": "https://deno.land/std@0.223.0/",
|
||||
"$std/": "https://deno.land/std@0.223.0/"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,175 +0,0 @@
|
|||
import { convertObjectToFormData, helpEmail } from '/lib/utils/misc.ts';
|
||||
import { FormField, generateFieldHtml, getFormDataField } from '/lib/form-utils.tsx';
|
||||
|
||||
interface SettingsProps {
|
||||
formData: Record<string, any>;
|
||||
error?: {
|
||||
title: string;
|
||||
message: string;
|
||||
};
|
||||
notice?: {
|
||||
title: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| 'change-email'
|
||||
| 'verify-change-email'
|
||||
| 'change-password'
|
||||
| 'change-dav-password'
|
||||
| 'delete-account';
|
||||
|
||||
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'],
|
||||
]);
|
||||
|
||||
function formFields(action: Action, formData: FormData) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
export default function Settings({ formData: formDataObject, error, notice }: SettingsProps) {
|
||||
const formData = convertObjectToFormData(formDataObject);
|
||||
|
||||
const action = getFormDataField(formData, 'action') as Action;
|
||||
|
||||
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(
|
||||
action === 'change-email' && notice?.message.includes('verify') ? 'verify-change-email' : '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>
|
||||
|
||||
<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. 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))}
|
||||
<section class='flex justify-end mt-8 mb-4'>
|
||||
<button class='button-danger' type='submit'>Delete account</button>
|
||||
</section>
|
||||
</form>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import MainFiles from '/components/files/MainFiles.tsx';
|
||||
|
||||
interface FilesWrapperProps {
|
||||
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 FilesWrapper(
|
||||
{ initialDirectories, initialFiles, initialPath }: FilesWrapperProps,
|
||||
) {
|
||||
return (
|
||||
<MainFiles
|
||||
initialDirectories={initialDirectories}
|
||||
initialFiles={initialFiles}
|
||||
initialPath={initialPath}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
103
lib/auth.ts
103
lib/auth.ts
|
|
@ -1,14 +1,16 @@
|
|||
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 { baseUrl, generateHash, isRunningLocally } from './utils/misc.ts';
|
||||
import { User, UserSession } from './types.ts';
|
||||
import { createUserSession, deleteUserSession, getUserByEmail, validateUserAndSession } from './data/user.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';
|
||||
|
||||
const JWT_SECRET = Deno.env.get('JWT_SECRET') || '';
|
||||
export const JWT_SECRET = Deno.env.get('JWT_SECRET') || '';
|
||||
export const PASSWORD_SALT = Deno.env.get('PASSWORD_SALT') || '';
|
||||
export const MFA_KEY = Deno.env.get('MFA_KEY') || '';
|
||||
export const MFA_SALT = Deno.env.get('MFA_SALT') || '';
|
||||
export const COOKIE_NAME = 'bewcloud-app-v1';
|
||||
|
||||
export interface JwtData {
|
||||
|
|
@ -18,16 +20,16 @@ export interface JwtData {
|
|||
};
|
||||
}
|
||||
|
||||
const isBaseUrlAnIp = () => /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|$)){4}$/.test(baseUrl);
|
||||
const isUrlAnIp = (baseUrl: string) => /^(?!0)(?!.*\.$)((1?\d?\d|25[0-5]|2[0-4]\d)(\.|$)){4}$/.test(baseUrl);
|
||||
|
||||
const textToData = (text: string) => new TextEncoder().encode(text);
|
||||
|
||||
export const dataToText = (data: Uint8Array) => new TextDecoder().decode(data);
|
||||
|
||||
const generateKey = async (key: string) =>
|
||||
export const generateKey = async (key: string): Promise<CryptoKey> =>
|
||||
await crypto.subtle.importKey('raw', textToData(key), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
|
||||
|
||||
async function signAuthJwt(key: CryptoKey, data: JwtData) {
|
||||
async function signAuthJwt<T = JwtData>(key: CryptoKey, data: T): Promise<string> {
|
||||
const payload = encodeBase64Url(textToData(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))) + '.' +
|
||||
encodeBase64Url(textToData(JSON.stringify(data) || ''));
|
||||
const signature = encodeBase64Url(
|
||||
|
|
@ -36,7 +38,7 @@ async function signAuthJwt(key: CryptoKey, data: JwtData) {
|
|||
return `${payload}.${signature}`;
|
||||
}
|
||||
|
||||
async function verifyAuthJwt(key: CryptoKey, jwt: string) {
|
||||
export async function verifyAuthJwt<T = JwtData>(key: CryptoKey, jwt: string): Promise<T> {
|
||||
const jwtParts = jwt.split('.');
|
||||
if (jwtParts.length !== 3) {
|
||||
throw new Error('Malformed JWT');
|
||||
|
|
@ -44,13 +46,29 @@ async function verifyAuthJwt(key: CryptoKey, jwt: string) {
|
|||
|
||||
const data = textToData(jwtParts[0] + '.' + jwtParts[1]);
|
||||
if (await crypto.subtle.verify({ name: 'HMAC' }, key, decodeBase64Url(jwtParts[2]), data) === true) {
|
||||
return JSON.parse(dataToText(decodeBase64Url(jwtParts[1]))) as JwtData;
|
||||
return JSON.parse(dataToText(decodeBase64Url(jwtParts[1]))) as T;
|
||||
}
|
||||
|
||||
throw new Error('Invalid JWT');
|
||||
}
|
||||
|
||||
export async function getDataFromRequest(request: Request) {
|
||||
export async function resolveCookieDomain(request: Request) {
|
||||
const config = await AppConfig.getConfig();
|
||||
const baseUrl = config.auth.baseUrl;
|
||||
|
||||
if (!isUrlAnIp(baseUrl) || isRunningLocally(request)) {
|
||||
const domain = new URL(request.url).hostname;
|
||||
if (await AppConfig.isCookieDomainAllowed(domain)) {
|
||||
return domain;
|
||||
}
|
||||
return baseUrl.replace('https://', '').replace('http://', '').split(':')[0];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export async function getDataFromRequest(
|
||||
request: Request,
|
||||
): Promise<{ user: User; session: UserSession | undefined; tokenData?: JwtData['data'] } | null> {
|
||||
const cookies = getCookies(request.headers);
|
||||
const authorizationHeader = request.headers.get('authorization');
|
||||
|
||||
|
|
@ -90,7 +108,7 @@ async function getDataFromAuthorizationHeader(authorizationHeader: string) {
|
|||
|
||||
const hashedPassword = await generateHash(`${basicAuthPassword}:${PASSWORD_SALT}`, 'SHA-256');
|
||||
|
||||
const user = await getUserByEmail(basicAuthUsername);
|
||||
const user = await UserModel.getByEmail(basicAuthUsername);
|
||||
|
||||
if (!user || (user.hashed_password !== hashedPassword && user.extra.dav_hashed_password !== hashedPassword)) {
|
||||
throw new Error('Email not found or invalid password.');
|
||||
|
|
@ -104,7 +122,9 @@ async function getDataFromAuthorizationHeader(authorizationHeader: string) {
|
|||
return null;
|
||||
}
|
||||
|
||||
async function getDataFromCookie(cookieValue: string) {
|
||||
async function getDataFromCookie(
|
||||
cookieValue: string,
|
||||
): Promise<{ user: User; session: UserSession | undefined; tokenData?: JwtData['data'] } | null> {
|
||||
if (!cookieValue) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -124,10 +144,10 @@ async function getDataFromCookie(cookieValue: string) {
|
|||
return null;
|
||||
}
|
||||
|
||||
export async function generateToken(tokenData: JwtData['data']) {
|
||||
export async function generateToken<T = JwtData>(tokenData: T): Promise<string> {
|
||||
const key = await generateKey(JWT_SECRET);
|
||||
|
||||
const token = await signAuthJwt(key, { data: tokenData });
|
||||
const token = await signAuthJwt<{ data: T }>(key, { data: tokenData });
|
||||
|
||||
return token;
|
||||
}
|
||||
|
|
@ -147,7 +167,7 @@ export async function logoutUser(request: Request) {
|
|||
const { session_id } = tokenData;
|
||||
|
||||
// Delete user session
|
||||
await deleteUserSession(session_id);
|
||||
await UserSessionModel.delete(session_id);
|
||||
|
||||
// Generate response with empty and expiring cookie
|
||||
const cookie: Cookie = {
|
||||
|
|
@ -158,12 +178,11 @@ export async function logoutUser(request: Request) {
|
|||
secure: isRunningLocally(request) ? false : true,
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
domain: await resolveCookieDomain(request),
|
||||
};
|
||||
|
||||
if (!isBaseUrlAnIp()) {
|
||||
cookie.domain = isRunningLocally(request)
|
||||
? 'localhost'
|
||||
: baseUrl.replace('https://', '').replace('http://', '').split(':')[0];
|
||||
if (await AppConfig.isCookieDomainSecurityDisabled()) {
|
||||
delete cookie.domain;
|
||||
}
|
||||
|
||||
const response = new Response('Logged Out', {
|
||||
|
|
@ -199,7 +218,7 @@ export async function createSessionCookie(
|
|||
response: Response,
|
||||
isShortLived = false,
|
||||
) {
|
||||
const newSession = await createUserSession(user, isShortLived);
|
||||
const newSession = await UserSessionModel.create(user, isShortLived);
|
||||
|
||||
// Generate response with session cookie
|
||||
const token = await generateToken({ user_id: user.id, session_id: newSession.id });
|
||||
|
|
@ -212,41 +231,11 @@ export async function createSessionCookie(
|
|||
secure: isRunningLocally(request) ? false : true,
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
domain: await resolveCookieDomain(request),
|
||||
};
|
||||
|
||||
if (!isBaseUrlAnIp()) {
|
||||
cookie.domain = isRunningLocally(request)
|
||||
? 'localhost'
|
||||
: baseUrl.replace('https://', '').replace('http://', '').split(':')[0];
|
||||
}
|
||||
|
||||
setCookie(response.headers, cookie);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function updateSessionCookie(
|
||||
response: Response,
|
||||
request: Request,
|
||||
userSession: UserSession,
|
||||
newSessionData: JwtData['data'],
|
||||
) {
|
||||
const token = await generateToken(newSessionData);
|
||||
|
||||
const cookie: Cookie = {
|
||||
name: COOKIE_NAME,
|
||||
value: token,
|
||||
expires: userSession.expires_at,
|
||||
path: '/',
|
||||
secure: isRunningLocally(request) ? false : true,
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
};
|
||||
|
||||
if (!isBaseUrlAnIp()) {
|
||||
cookie.domain = isRunningLocally(request)
|
||||
? 'localhost'
|
||||
: baseUrl.replace('https://', '').replace('http://', '').split(':')[0];
|
||||
if (await AppConfig.isCookieDomainSecurityDisabled()) {
|
||||
delete cookie.domain;
|
||||
}
|
||||
|
||||
setCookie(response.headers, cookie);
|
||||
|
|
|
|||
258
lib/config.ts
258
lib/config.ts
|
|
@ -1,41 +1,227 @@
|
|||
import 'std/dotenv/load.ts';
|
||||
import { isAbsolute, join } from '@std/path';
|
||||
import { UserModel } from './models/user.ts';
|
||||
import { Config, OptionalApp } from './types.ts';
|
||||
|
||||
import { isThereAnAdmin } from './data/user.ts';
|
||||
export class AppConfig {
|
||||
private static config: Config;
|
||||
|
||||
export async function isSignupAllowed() {
|
||||
const areSignupsAllowed = Deno.env.get('CONFIG_ALLOW_SIGNUPS') === 'true';
|
||||
|
||||
const areThereAdmins = await isThereAnAdmin();
|
||||
|
||||
if (areSignupsAllowed || !areThereAdmins) {
|
||||
return true;
|
||||
private static getDefaultConfig(): Config {
|
||||
return {
|
||||
auth: {
|
||||
baseUrl: 'http://localhost:8000',
|
||||
allowSignups: false,
|
||||
enableEmailVerification: false,
|
||||
enableForeverSignup: true,
|
||||
enableMultiFactor: false,
|
||||
allowedCookieDomains: [],
|
||||
skipCookieDomainSecurity: false,
|
||||
enableSingleSignOn: false,
|
||||
allowSignupsViaSingleSignOn: false,
|
||||
singleSignOnUrl: '',
|
||||
singleSignOnEmailAttribute: 'email',
|
||||
singleSignOnScopes: ['openid', 'email'],
|
||||
},
|
||||
files: {
|
||||
rootPath: 'data-files',
|
||||
allowPublicSharing: false,
|
||||
allowDirectoryDownloads: false,
|
||||
maxUploadSizeInMegabytes: 100,
|
||||
},
|
||||
core: {
|
||||
enabledApps: ['dashboard', 'files', 'news', 'notes', 'photos', 'expenses', 'contacts', 'calendar'],
|
||||
maxRequestSizeInMegabytes: 12,
|
||||
},
|
||||
visuals: {
|
||||
title: '',
|
||||
description: '',
|
||||
helpEmail: 'help@bewcloud.com',
|
||||
},
|
||||
email: {
|
||||
from: 'help@bewcloud.com',
|
||||
host: 'localhost',
|
||||
port: 465,
|
||||
tlsMode: 'auto',
|
||||
tlsVerify: true,
|
||||
},
|
||||
contacts: {
|
||||
enableCardDavServer: true,
|
||||
cardDavUrl: 'http://radicale:5232',
|
||||
},
|
||||
calendar: {
|
||||
enableCalDavServer: true,
|
||||
calDavUrl: 'http://radicale:5232',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isAppEnabled(app: 'news' | 'notes' | 'photos') {
|
||||
const enabledApps = (Deno.env.get('CONFIG_ENABLED_APPS') || '').split(',') as typeof app[];
|
||||
|
||||
return enabledApps.includes(app);
|
||||
}
|
||||
|
||||
export function isEmailEnabled() {
|
||||
const areEmailsAllowed = Deno.env.get('CONFIG_ENABLE_EMAILS') === 'true';
|
||||
|
||||
return areEmailsAllowed;
|
||||
}
|
||||
|
||||
export function isForeverSignupEnabled() {
|
||||
const areForeverAccountsEnabled = Deno.env.get('CONFIG_ENABLE_FOREVER_SIGNUP') === 'true';
|
||||
|
||||
return areForeverAccountsEnabled;
|
||||
}
|
||||
|
||||
export function getFilesRootPath() {
|
||||
const configRootPath = Deno.env.get('CONFIG_FILES_ROOT_PATH') || '';
|
||||
|
||||
const filesRootPath = `${Deno.cwd()}/${configRootPath}`;
|
||||
|
||||
return filesRootPath;
|
||||
private static async loadConfig(): Promise<void> {
|
||||
if (this.config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const initialConfig = this.getDefaultConfig();
|
||||
|
||||
const config: Config = {
|
||||
...initialConfig,
|
||||
};
|
||||
|
||||
try {
|
||||
const configFromFile: Config = (await import(`${Deno.cwd()}/bewcloud.config.ts`)).default;
|
||||
|
||||
this.config = {
|
||||
...config,
|
||||
auth: {
|
||||
...config.auth,
|
||||
...configFromFile.auth,
|
||||
},
|
||||
files: {
|
||||
...config.files,
|
||||
...configFromFile.files,
|
||||
},
|
||||
core: {
|
||||
...config.core,
|
||||
...configFromFile.core,
|
||||
},
|
||||
visuals: {
|
||||
...config.visuals,
|
||||
...configFromFile.visuals,
|
||||
},
|
||||
email: {
|
||||
...config.email,
|
||||
...configFromFile.email,
|
||||
},
|
||||
contacts: {
|
||||
...config.contacts,
|
||||
...configFromFile.contacts,
|
||||
},
|
||||
calendar: {
|
||||
...config.calendar,
|
||||
...configFromFile.calendar,
|
||||
},
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
static async getConfig(): Promise<Config> {
|
||||
await this.loadConfig();
|
||||
|
||||
return this.config;
|
||||
}
|
||||
|
||||
static async isSignupAllowed({ viaSingleSignOn = false }: { viaSingleSignOn?: boolean } = {}): Promise<boolean> {
|
||||
await this.loadConfig();
|
||||
|
||||
const areSignupsAllowed = viaSingleSignOn && !this.config.auth.allowSignups
|
||||
? this.config.auth.allowSignupsViaSingleSignOn
|
||||
: this.config.auth.allowSignups;
|
||||
const areThereAdmins = await UserModel.isThereAnAdmin();
|
||||
|
||||
if (areSignupsAllowed || !areThereAdmins) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static async isAppEnabled(app: OptionalApp): Promise<boolean> {
|
||||
await this.loadConfig();
|
||||
|
||||
const enabledApps = this.config.core.enabledApps;
|
||||
|
||||
return enabledApps.includes(app);
|
||||
}
|
||||
|
||||
static async isCookieDomainAllowed(domain: string): Promise<boolean> {
|
||||
await this.loadConfig();
|
||||
|
||||
const allowedDomains = this.config.auth.allowedCookieDomains;
|
||||
|
||||
if (allowedDomains.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return allowedDomains.includes(domain);
|
||||
}
|
||||
|
||||
static async isCookieDomainSecurityDisabled(): Promise<boolean> {
|
||||
await this.loadConfig();
|
||||
|
||||
return this.config.auth.skipCookieDomainSecurity;
|
||||
}
|
||||
|
||||
static async isEmailVerificationEnabled(): Promise<boolean> {
|
||||
await this.loadConfig();
|
||||
|
||||
return this.config.auth.enableEmailVerification;
|
||||
}
|
||||
|
||||
static async isForeverSignupEnabled(): Promise<boolean> {
|
||||
await this.loadConfig();
|
||||
|
||||
return this.config.auth.enableForeverSignup;
|
||||
}
|
||||
|
||||
static async isMultiFactorAuthEnabled(): Promise<boolean> {
|
||||
await this.loadConfig();
|
||||
|
||||
return this.config.auth.enableMultiFactor;
|
||||
}
|
||||
|
||||
static async isSingleSignOnEnabled(): Promise<boolean> {
|
||||
await this.loadConfig();
|
||||
|
||||
return this.config.auth.enableSingleSignOn;
|
||||
}
|
||||
|
||||
static async isPublicFileSharingAllowed(): Promise<boolean> {
|
||||
await this.loadConfig();
|
||||
|
||||
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();
|
||||
|
||||
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']> {
|
||||
await this.loadConfig();
|
||||
|
||||
return this.config.email;
|
||||
}
|
||||
|
||||
static async getContactsConfig(): Promise<Config['contacts']> {
|
||||
await this.loadConfig();
|
||||
|
||||
return this.config.contacts;
|
||||
}
|
||||
|
||||
static async getCalendarConfig(): Promise<Config['calendar']> {
|
||||
await this.loadConfig();
|
||||
|
||||
return this.config.calendar;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import { Dashboard } from '/lib/types.ts';
|
||||
|
||||
const db = new Database();
|
||||
|
||||
export async function getDashboardByUserId(userId: string) {
|
||||
const dashboard = (await db.query<Dashboard>(sql`SELECT * FROM "bewcloud_dashboards" WHERE "user_id" = $1 LIMIT 1`, [
|
||||
userId,
|
||||
]))[0];
|
||||
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
export async function createDashboard(userId: string) {
|
||||
const data: Dashboard['data'] = { links: [], notes: '' };
|
||||
|
||||
const newDashboard = (await db.query<Dashboard>(
|
||||
sql`INSERT INTO "bewcloud_dashboards" (
|
||||
"user_id",
|
||||
"data"
|
||||
) VALUES ($1, $2)
|
||||
RETURNING *`,
|
||||
[
|
||||
userId,
|
||||
JSON.stringify(data),
|
||||
],
|
||||
))[0];
|
||||
|
||||
return newDashboard;
|
||||
}
|
||||
|
||||
export async function updateDashboard(dashboard: Dashboard) {
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_dashboards" SET
|
||||
"data" = $2
|
||||
WHERE "id" = $1`,
|
||||
[
|
||||
dashboard.id,
|
||||
JSON.stringify(dashboard.data),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
@ -1,456 +0,0 @@
|
|||
import { join } from 'std/path/join.ts';
|
||||
import { lookup } from 'mrmime';
|
||||
|
||||
import { getFilesRootPath } from '/lib/config.ts';
|
||||
import { Directory, DirectoryFile } from '/lib/types.ts';
|
||||
import { sortDirectoriesByName, sortEntriesByName, sortFilesByName, TRASH_PATH } from '/lib/utils/files.ts';
|
||||
|
||||
export async function getDirectories(userId: string, path: string): Promise<Directory[]> {
|
||||
const rootPath = join(getFilesRootPath(), userId, path);
|
||||
|
||||
const directories: Directory[] = [];
|
||||
|
||||
const directoryEntries = (await getPathEntries(userId, path)).filter((entry) => entry.isDirectory || entry.isSymlink);
|
||||
|
||||
for (const entry of directoryEntries) {
|
||||
const stat = await Deno.stat(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,
|
||||
updated_at: stat.mtime || new Date(),
|
||||
created_at: stat.birthtime || new Date(),
|
||||
};
|
||||
|
||||
directories.push(directory);
|
||||
}
|
||||
|
||||
directories.sort(sortDirectoriesByName);
|
||||
|
||||
return directories;
|
||||
}
|
||||
|
||||
export async function getFiles(userId: string, path: string): Promise<DirectoryFile[]> {
|
||||
const rootPath = join(getFilesRootPath(), userId, path);
|
||||
|
||||
const files: DirectoryFile[] = [];
|
||||
|
||||
const fileEntries = (await getPathEntries(userId, path)).filter((entry) => entry.isFile);
|
||||
|
||||
for (const entry of fileEntries) {
|
||||
const stat = await Deno.stat(join(rootPath, entry.name));
|
||||
|
||||
const file: DirectoryFile = {
|
||||
user_id: userId,
|
||||
parent_path: path,
|
||||
file_name: entry.name,
|
||||
has_write_access: true,
|
||||
size_in_bytes: stat.size,
|
||||
updated_at: stat.mtime || new Date(),
|
||||
created_at: stat.birthtime || new Date(),
|
||||
};
|
||||
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
files.sort(sortFilesByName);
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async function getPathEntries(userId: string, path: string): Promise<Deno.DirEntry[]> {
|
||||
const rootPath = join(getFilesRootPath(), userId, path);
|
||||
|
||||
// Ensure the user directory exists
|
||||
if (path === '/') {
|
||||
try {
|
||||
await Deno.stat(rootPath);
|
||||
} catch (error) {
|
||||
if (error.toString().includes('NotFound')) {
|
||||
await Deno.mkdir(join(rootPath, TRASH_PATH), { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the Notes or Photos directories exist, if being requested
|
||||
if (path === '/Notes/' || path === '/Photos/') {
|
||||
try {
|
||||
await Deno.stat(rootPath);
|
||||
} catch (error) {
|
||||
if (error.toString().includes('NotFound')) {
|
||||
await Deno.mkdir(rootPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const entries: Deno.DirEntry[] = [];
|
||||
|
||||
for await (const dirEntry of Deno.readDir(rootPath)) {
|
||||
entries.push(dirEntry);
|
||||
}
|
||||
|
||||
entries.sort(sortEntriesByName);
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export async function createDirectory(userId: string, path: string, name: string): Promise<boolean> {
|
||||
const rootPath = join(getFilesRootPath(), userId, path);
|
||||
|
||||
try {
|
||||
await Deno.mkdir(join(rootPath, name), { recursive: true });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function renameDirectoryOrFile(
|
||||
userId: string,
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): Promise<boolean> {
|
||||
const oldRootPath = join(getFilesRootPath(), userId, oldPath);
|
||||
const newRootPath = join(getFilesRootPath(), userId, newPath);
|
||||
|
||||
try {
|
||||
await Deno.rename(join(oldRootPath, oldName), join(newRootPath, newName));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function deleteDirectoryOrFile(userId: string, path: string, name: string): Promise<boolean> {
|
||||
const rootPath = join(getFilesRootPath(), userId, path);
|
||||
|
||||
try {
|
||||
if (path.startsWith(TRASH_PATH)) {
|
||||
await Deno.remove(join(rootPath, name), { recursive: true });
|
||||
} else {
|
||||
const trashPath = join(getFilesRootPath(), userId, TRASH_PATH);
|
||||
await Deno.rename(join(rootPath, name), join(trashPath, name));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function createFile(
|
||||
userId: string,
|
||||
path: string,
|
||||
name: string,
|
||||
contents: string | ArrayBuffer,
|
||||
): Promise<boolean> {
|
||||
const rootPath = join(getFilesRootPath(), userId, path);
|
||||
|
||||
try {
|
||||
if (typeof contents === 'string') {
|
||||
await Deno.writeTextFile(join(rootPath, name), contents, { append: false, createNew: true });
|
||||
} else {
|
||||
await Deno.writeFile(join(rootPath, name), new Uint8Array(contents), { append: false, createNew: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function updateFile(
|
||||
userId: string,
|
||||
path: string,
|
||||
name: string,
|
||||
contents: string,
|
||||
): Promise<boolean> {
|
||||
const rootPath = join(getFilesRootPath(), userId, path);
|
||||
|
||||
try {
|
||||
await Deno.writeTextFile(join(rootPath, name), contents, { append: false, createNew: false });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function getFile(
|
||||
userId: string,
|
||||
path: string,
|
||||
name?: string,
|
||||
): Promise<{ success: boolean; contents?: Uint8Array; contentType?: string; byteSize?: number }> {
|
||||
const rootPath = join(getFilesRootPath(), userId, path);
|
||||
|
||||
try {
|
||||
const stat = await Deno.stat(join(rootPath, name || ''));
|
||||
|
||||
if (stat) {
|
||||
const contents = await Deno.readFile(join(rootPath, name || ''));
|
||||
|
||||
const extension = (name || path).split('.').slice(-1).join('').toLowerCase();
|
||||
|
||||
const contentType = lookup(extension) || 'application/octet-stream';
|
||||
|
||||
return {
|
||||
success: true,
|
||||
contents,
|
||||
contentType,
|
||||
byteSize: stat.size,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchFilesAndDirectories(
|
||||
userId: string,
|
||||
searchTerm: string,
|
||||
): Promise<{ success: boolean; directories: Directory[]; files: DirectoryFile[] }> {
|
||||
const directoryNamesResult = await searchDirectoryNames(userId, searchTerm);
|
||||
const fileNamesResult = await searchFileNames(userId, searchTerm);
|
||||
const fileContentsResult = await searchFileContents(userId, searchTerm);
|
||||
|
||||
const success = directoryNamesResult.success && fileNamesResult.success && fileContentsResult.success;
|
||||
|
||||
const directories = [...directoryNamesResult.directories];
|
||||
directories.sort(sortDirectoriesByName);
|
||||
|
||||
const files = [...fileNamesResult.files, ...fileContentsResult.files];
|
||||
files.sort(sortFilesByName);
|
||||
|
||||
return {
|
||||
success,
|
||||
directories,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
async function searchDirectoryNames(
|
||||
userId: string,
|
||||
searchTerm: string,
|
||||
): Promise<{ success: boolean; directories: Directory[] }> {
|
||||
const rootPath = join(getFilesRootPath(), userId);
|
||||
|
||||
const directories: Directory[] = [];
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const commandTimeout = setTimeout(() => controller.abort(), 10_000);
|
||||
|
||||
const command = new Deno.Command(`find`, {
|
||||
args: [
|
||||
`.`, // proper cwd is sent below
|
||||
`-type`,
|
||||
`d,l`, // directories and symbolic links
|
||||
`-iname`,
|
||||
`*${searchTerm}*`,
|
||||
],
|
||||
cwd: rootPath,
|
||||
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 "find"`);
|
||||
}
|
||||
|
||||
const output = new TextDecoder().decode(stdout);
|
||||
const matchingDirectories = output.split('\n').map((directoryPath) => directoryPath.trim()).filter(Boolean);
|
||||
|
||||
for (const relativeDirectoryPath of matchingDirectories) {
|
||||
const stat = await Deno.stat(join(rootPath, relativeDirectoryPath));
|
||||
let parentPath = `/${relativeDirectoryPath.replace('./', '/').split('/').slice(0, -1).join('')}/`;
|
||||
const directoryName = relativeDirectoryPath.split('/').pop()!;
|
||||
|
||||
if (parentPath === '//') {
|
||||
parentPath = '/';
|
||||
}
|
||||
|
||||
const directory: Directory = {
|
||||
user_id: userId,
|
||||
parent_path: parentPath,
|
||||
directory_name: directoryName,
|
||||
has_write_access: true,
|
||||
size_in_bytes: stat.size,
|
||||
updated_at: stat.mtime || new Date(),
|
||||
created_at: stat.birthtime || new Date(),
|
||||
};
|
||||
|
||||
directories.push(directory);
|
||||
}
|
||||
|
||||
return { success: true, directories };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return { success: false, directories };
|
||||
}
|
||||
|
||||
async function searchFileNames(
|
||||
userId: string,
|
||||
searchTerm: string,
|
||||
): Promise<{ success: boolean; files: DirectoryFile[] }> {
|
||||
const rootPath = join(getFilesRootPath(), userId);
|
||||
|
||||
const files: DirectoryFile[] = [];
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const commandTimeout = setTimeout(() => controller.abort(), 10_000);
|
||||
|
||||
const command = new Deno.Command(`find`, {
|
||||
args: [
|
||||
`.`, // proper cwd is sent below
|
||||
`-type`,
|
||||
`f`,
|
||||
`-iname`,
|
||||
`*${searchTerm}*`,
|
||||
],
|
||||
cwd: rootPath,
|
||||
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 "find"`);
|
||||
}
|
||||
|
||||
const output = new TextDecoder().decode(stdout);
|
||||
const matchingFiles = output.split('\n').map((filePath) => filePath.trim()).filter(Boolean);
|
||||
|
||||
for (const relativeFilePath of matchingFiles) {
|
||||
const stat = await Deno.stat(join(rootPath, relativeFilePath));
|
||||
let parentPath = `/${relativeFilePath.replace('./', '/').split('/').slice(0, -1).join('')}/`;
|
||||
const fileName = relativeFilePath.split('/').pop()!;
|
||||
|
||||
if (parentPath === '//') {
|
||||
parentPath = '/';
|
||||
}
|
||||
|
||||
const file: DirectoryFile = {
|
||||
user_id: userId,
|
||||
parent_path: parentPath,
|
||||
file_name: fileName,
|
||||
has_write_access: true,
|
||||
size_in_bytes: stat.size,
|
||||
updated_at: stat.mtime || new Date(),
|
||||
created_at: stat.birthtime || new Date(),
|
||||
};
|
||||
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
return { success: true, files };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return { success: false, files };
|
||||
}
|
||||
|
||||
async function searchFileContents(
|
||||
userId: string,
|
||||
searchTerm: string,
|
||||
): Promise<{ success: boolean; files: DirectoryFile[] }> {
|
||||
const rootPath = join(getFilesRootPath(), userId);
|
||||
|
||||
const files: DirectoryFile[] = [];
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const commandTimeout = setTimeout(() => controller.abort(), 10_000);
|
||||
|
||||
const command = new Deno.Command(`grep`, {
|
||||
args: [
|
||||
`-rHisl`,
|
||||
`${searchTerm}`,
|
||||
`.`, // proper cwd is sent below
|
||||
],
|
||||
cwd: rootPath,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const { code, stdout, stderr } = await command.output();
|
||||
|
||||
if (commandTimeout) {
|
||||
clearTimeout(commandTimeout);
|
||||
}
|
||||
|
||||
if (code > 1) {
|
||||
if (stderr) {
|
||||
throw new Error(new TextDecoder().decode(stderr));
|
||||
}
|
||||
|
||||
throw new Error(`Unknown error running "grep"`);
|
||||
}
|
||||
|
||||
const output = new TextDecoder().decode(stdout);
|
||||
const matchingFiles = output.split('\n').map((filePath) => filePath.trim()).filter(Boolean);
|
||||
|
||||
for (const relativeFilePath of matchingFiles) {
|
||||
const stat = await Deno.stat(join(rootPath, relativeFilePath));
|
||||
let parentPath = `/${relativeFilePath.replace('./', '/').split('/').slice(0, -1).join('')}/`;
|
||||
const fileName = relativeFilePath.split('/').pop()!;
|
||||
|
||||
if (parentPath === '//') {
|
||||
parentPath = '/';
|
||||
}
|
||||
|
||||
const file: DirectoryFile = {
|
||||
user_id: userId,
|
||||
parent_path: parentPath,
|
||||
file_name: fileName,
|
||||
has_write_access: true,
|
||||
size_in_bytes: stat.size,
|
||||
updated_at: stat.mtime || new Date(),
|
||||
created_at: stat.birthtime || new Date(),
|
||||
};
|
||||
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
return { success: true, files };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return { success: false, files };
|
||||
}
|
||||
311
lib/data/news.ts
311
lib/data/news.ts
|
|
@ -1,311 +0,0 @@
|
|||
import { Feed } from 'https://deno.land/x/rss@1.0.0/mod.ts';
|
||||
|
||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import Locker from '/lib/interfaces/locker.ts';
|
||||
import { NewsFeed, NewsFeedArticle } from '/lib/types.ts';
|
||||
import {
|
||||
findFeedInUrl,
|
||||
getArticleUrl,
|
||||
getFeedInfo,
|
||||
JsonFeed,
|
||||
parseTextFromHtml,
|
||||
parseUrl,
|
||||
parseUrlAsGooglebot,
|
||||
parseUrlWithProxy,
|
||||
} from '/lib/feed.ts';
|
||||
|
||||
const db = new Database();
|
||||
|
||||
export async function getNewsFeeds(userId: string) {
|
||||
const newsFeeds = await db.query<NewsFeed>(sql`SELECT * FROM "bewcloud_news_feeds" WHERE "user_id" = $1`, [
|
||||
userId,
|
||||
]);
|
||||
|
||||
return newsFeeds;
|
||||
}
|
||||
|
||||
export async function getNewsFeed(id: string, userId: string) {
|
||||
const newsFeeds = await db.query<NewsFeed>(
|
||||
sql`SELECT * FROM "bewcloud_news_feeds" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return newsFeeds[0];
|
||||
}
|
||||
|
||||
export async function getNewsArticles(userId: string) {
|
||||
const articles = await db.query<NewsFeedArticle>(
|
||||
sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "user_id" = $1 ORDER BY "article_date" DESC`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return articles;
|
||||
}
|
||||
|
||||
export async function getNewsArticlesByFeedId(feedId: string) {
|
||||
const articles = await db.query<NewsFeedArticle>(
|
||||
sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "feed_id" = $1 ORDER BY "article_date" DESC`,
|
||||
[
|
||||
feedId,
|
||||
],
|
||||
);
|
||||
|
||||
return articles;
|
||||
}
|
||||
|
||||
export async function getNewsArticle(id: string, userId: string) {
|
||||
const articles = await db.query<NewsFeedArticle>(
|
||||
sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return articles[0];
|
||||
}
|
||||
|
||||
export async function createNewsFeed(userId: string, feedUrl: string) {
|
||||
const extra: NewsFeed['extra'] = {};
|
||||
|
||||
const newNewsFeed = (await db.query<NewsFeed>(
|
||||
sql`INSERT INTO "bewcloud_news_feeds" (
|
||||
"user_id",
|
||||
"feed_url",
|
||||
"extra"
|
||||
) VALUES ($1, $2, $3)
|
||||
RETURNING *`,
|
||||
[
|
||||
userId,
|
||||
feedUrl,
|
||||
JSON.stringify(extra),
|
||||
],
|
||||
))[0];
|
||||
|
||||
return newNewsFeed;
|
||||
}
|
||||
|
||||
export async function updateNewsFeed(newsFeed: NewsFeed) {
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_news_feeds" SET
|
||||
"feed_url" = $2,
|
||||
"last_crawled_at" = $3,
|
||||
"extra" = $4
|
||||
WHERE "id" = $1`,
|
||||
[
|
||||
newsFeed.id,
|
||||
newsFeed.feed_url,
|
||||
newsFeed.last_crawled_at,
|
||||
JSON.stringify(newsFeed.extra),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteNewsFeed(id: string, userId: string) {
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_news_feed_articles" WHERE "feed_id" = $1 AND "user_id" = $2`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_news_feeds" WHERE "id" = $1 AND "user_id" = $2`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function createsNewsArticle(
|
||||
userId: string,
|
||||
feedId: string,
|
||||
article: Omit<NewsFeedArticle, 'id' | 'user_id' | 'feed_id' | 'extra' | 'is_read' | 'created_at'>,
|
||||
) {
|
||||
const extra: NewsFeedArticle['extra'] = {};
|
||||
|
||||
const newNewsArticle = (await db.query<NewsFeedArticle>(
|
||||
sql`INSERT INTO "bewcloud_news_feed_articles" (
|
||||
"user_id",
|
||||
"feed_id",
|
||||
"article_url",
|
||||
"article_title",
|
||||
"article_summary",
|
||||
"article_date",
|
||||
"extra"
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[
|
||||
userId,
|
||||
feedId,
|
||||
article.article_url,
|
||||
article.article_title,
|
||||
article.article_summary,
|
||||
article.article_date,
|
||||
JSON.stringify(extra),
|
||||
],
|
||||
))[0];
|
||||
|
||||
return newNewsArticle;
|
||||
}
|
||||
|
||||
export async function updateNewsArticle(article: NewsFeedArticle) {
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_news_feed_articles" SET
|
||||
"is_read" = $2,
|
||||
"extra" = $3
|
||||
WHERE "id" = $1`,
|
||||
[
|
||||
article.id,
|
||||
article.is_read,
|
||||
JSON.stringify(article.extra),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function markAllArticlesRead(userId: string) {
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_news_feed_articles" SET
|
||||
"is_read" = TRUE
|
||||
WHERE "user_id" = $1`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchNewsArticles(newsFeed: NewsFeed): Promise<Feed['entries'] | JsonFeed['items']> {
|
||||
try {
|
||||
if (!newsFeed.extra.title || !newsFeed.extra.feed_type || !newsFeed.extra.crawl_type) {
|
||||
throw new Error('Invalid News Feed!');
|
||||
}
|
||||
|
||||
let feed: JsonFeed | Feed | null = null;
|
||||
|
||||
if (newsFeed.extra.crawl_type === 'direct') {
|
||||
feed = await parseUrl(newsFeed.feed_url);
|
||||
} else if (newsFeed.extra.crawl_type === 'googlebot') {
|
||||
feed = await parseUrlAsGooglebot(newsFeed.feed_url);
|
||||
} else if (newsFeed.extra.crawl_type === 'proxy') {
|
||||
feed = await parseUrlWithProxy(newsFeed.feed_url);
|
||||
}
|
||||
|
||||
return (feed as Feed)?.entries || (feed as JsonFeed)?.items || [];
|
||||
} catch (error) {
|
||||
console.error('Failed parsing feed to get articles', newsFeed.feed_url);
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
type FeedArticle = Feed['entries'][number];
|
||||
type JsonFeedArticle = JsonFeed['items'][number];
|
||||
|
||||
const MAX_ARTICLES_CRAWLED_PER_RUN = 10;
|
||||
|
||||
export async function crawlNewsFeed(newsFeed: NewsFeed) {
|
||||
const lock = new Locker(`feeds:${newsFeed.id}`);
|
||||
|
||||
await lock.acquire();
|
||||
|
||||
try {
|
||||
if (!newsFeed.extra.title || !newsFeed.extra.feed_type || !newsFeed.extra.crawl_type) {
|
||||
const feedUrl = await findFeedInUrl(newsFeed.feed_url);
|
||||
|
||||
if (!feedUrl) {
|
||||
throw new Error(
|
||||
`Invalid URL for feed: "${feedUrl}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (feedUrl !== newsFeed.feed_url) {
|
||||
newsFeed.feed_url = feedUrl;
|
||||
}
|
||||
|
||||
const feedInfo = await getFeedInfo(newsFeed.feed_url);
|
||||
|
||||
newsFeed.extra.title = feedInfo.title;
|
||||
newsFeed.extra.feed_type = feedInfo.feed_type;
|
||||
newsFeed.extra.crawl_type = feedInfo.crawl_type;
|
||||
}
|
||||
|
||||
const feedArticles = await fetchNewsArticles(newsFeed);
|
||||
|
||||
const articles: Omit<NewsFeedArticle, 'id' | 'user_id' | 'feed_id' | 'extra' | 'is_read' | 'created_at'>[] = [];
|
||||
|
||||
for (const feedArticle of feedArticles) {
|
||||
// Don't add too many articles per run
|
||||
if (articles.length >= MAX_ARTICLES_CRAWLED_PER_RUN) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = (feedArticle as JsonFeedArticle).url || getArticleUrl((feedArticle as FeedArticle).links) ||
|
||||
feedArticle.id;
|
||||
|
||||
const articleIsoDate = (feedArticle as JsonFeedArticle).date_published ||
|
||||
(feedArticle as FeedArticle).published?.toISOString() || (feedArticle as JsonFeedArticle).date_modified ||
|
||||
(feedArticle as FeedArticle).updated?.toISOString();
|
||||
|
||||
const articleDate = articleIsoDate ? new Date(articleIsoDate) : new Date();
|
||||
|
||||
const summary = await parseTextFromHtml(
|
||||
(feedArticle as FeedArticle).description?.value || (feedArticle as FeedArticle).content?.value ||
|
||||
(feedArticle as JsonFeedArticle).content_text || (feedArticle as JsonFeedArticle).content_html ||
|
||||
(feedArticle as JsonFeedArticle).summary || '',
|
||||
);
|
||||
|
||||
if (url) {
|
||||
articles.push({
|
||||
article_title: (feedArticle as FeedArticle).title?.value || (feedArticle as JsonFeedArticle).title ||
|
||||
url.replace('http://', '').replace('https://', ''),
|
||||
article_url: url,
|
||||
article_summary: summary,
|
||||
article_date: articleDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const existingArticles = await getNewsArticlesByFeedId(newsFeed.id);
|
||||
const existingArticleUrls = new Set<string>(existingArticles.map((article) => article.article_url));
|
||||
const previousLatestArticleUrl = existingArticles[0]?.article_url;
|
||||
let seenPreviousLatestArticleUrl = false;
|
||||
let addedArticlesCount = 0;
|
||||
|
||||
for (const article of articles) {
|
||||
// Stop looking after seeing the previous latest article
|
||||
if (article.article_url === previousLatestArticleUrl) {
|
||||
seenPreviousLatestArticleUrl = true;
|
||||
}
|
||||
|
||||
if (!seenPreviousLatestArticleUrl && !existingArticleUrls.has(article.article_url)) {
|
||||
try {
|
||||
await createsNewsArticle(newsFeed.user_id, newsFeed.id, article);
|
||||
++addedArticlesCount;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error(`Failed to add new article: "${article.article_url}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.info('Added', addedArticlesCount, 'new articles');
|
||||
|
||||
newsFeed.last_crawled_at = new Date();
|
||||
|
||||
await updateNewsFeed(newsFeed);
|
||||
|
||||
lock.release();
|
||||
} catch (error) {
|
||||
lock.release();
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
286
lib/data/user.ts
286
lib/data/user.ts
|
|
@ -1,286 +0,0 @@
|
|||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import { User, UserSession, VerificationCode } from '/lib/types.ts';
|
||||
import { generateRandomCode } from '/lib/utils/misc.ts';
|
||||
import { isEmailEnabled, isForeverSignupEnabled } from '/lib/config.ts';
|
||||
|
||||
const db = new Database();
|
||||
|
||||
export async function isThereAnAdmin() {
|
||||
const user =
|
||||
(await db.query<User>(sql`SELECT * FROM "bewcloud_users" WHERE ("extra" ->> 'is_admin')::boolean IS TRUE LIMIT 1`))[
|
||||
0
|
||||
];
|
||||
|
||||
return Boolean(user);
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: string) {
|
||||
const lowercaseEmail = email.toLowerCase().trim();
|
||||
|
||||
const user = (await db.query<User>(sql`SELECT * FROM "bewcloud_users" WHERE "email" = $1 LIMIT 1`, [
|
||||
lowercaseEmail,
|
||||
]))[0];
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function getUserById(id: string) {
|
||||
const user = (await db.query<User>(sql`SELECT * FROM "bewcloud_users" WHERE "id" = $1 LIMIT 1`, [
|
||||
id,
|
||||
]))[0];
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function createUser(email: User['email'], hashedPassword: User['hashed_password']) {
|
||||
const trialDays = isForeverSignupEnabled() ? 36_525 : 30;
|
||||
const now = new Date();
|
||||
const trialEndDate = new Date(new Date().setUTCDate(new Date().getUTCDate() + trialDays));
|
||||
|
||||
const subscription: User['subscription'] = {
|
||||
external: {},
|
||||
expires_at: trialEndDate.toISOString(),
|
||||
updated_at: now.toISOString(),
|
||||
};
|
||||
|
||||
const extra: User['extra'] = { is_email_verified: isEmailEnabled() ? false : true };
|
||||
|
||||
// First signup will be an admin "forever"
|
||||
if (!(await isThereAnAdmin())) {
|
||||
extra.is_admin = true;
|
||||
subscription.expires_at = new Date('2100-12-31').toISOString();
|
||||
}
|
||||
|
||||
const newUser = (await db.query<User>(
|
||||
sql`INSERT INTO "bewcloud_users" (
|
||||
"email",
|
||||
"subscription",
|
||||
"status",
|
||||
"hashed_password",
|
||||
"extra"
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[
|
||||
email,
|
||||
JSON.stringify(subscription),
|
||||
extra.is_admin || isForeverSignupEnabled() ? 'active' : 'trial',
|
||||
hashedPassword,
|
||||
JSON.stringify(extra),
|
||||
],
|
||||
))[0];
|
||||
|
||||
return newUser;
|
||||
}
|
||||
|
||||
export async function updateUser(user: User) {
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_users" SET
|
||||
"email" = $2,
|
||||
"subscription" = $3,
|
||||
"status" = $4,
|
||||
"hashed_password" = $5,
|
||||
"extra" = $6
|
||||
WHERE "id" = $1`,
|
||||
[
|
||||
user.id,
|
||||
user.email,
|
||||
JSON.stringify(user.subscription),
|
||||
user.status,
|
||||
user.hashed_password,
|
||||
JSON.stringify(user.extra),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteUser(userId: string) {
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_user_sessions" WHERE "user_id" = $1`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_verification_codes" WHERE "user_id" = $1`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_news_feed_articles" WHERE "user_id" = $1`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_news_feeds" WHERE "user_id" = $1`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_users" WHERE "id" = $1`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSessionById(id: string) {
|
||||
const session = (await db.query<UserSession>(
|
||||
sql`SELECT * FROM "bewcloud_user_sessions" WHERE "id" = $1 AND "expires_at" > now() LIMIT 1`,
|
||||
[
|
||||
id,
|
||||
],
|
||||
))[0];
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function createUserSession(user: User, isShortLived = false) {
|
||||
const oneMonthFromToday = new Date(new Date().setUTCMonth(new Date().getUTCMonth() + 1));
|
||||
const oneWeekFromToday = new Date(new Date().setUTCDate(new Date().getUTCDate() + 7));
|
||||
|
||||
const newSession: Omit<UserSession, 'id' | 'created_at'> = {
|
||||
user_id: user.id,
|
||||
expires_at: isShortLived ? oneWeekFromToday : oneMonthFromToday,
|
||||
last_seen_at: new Date(),
|
||||
};
|
||||
|
||||
const newUserSessionResult = (await db.query<UserSession>(
|
||||
sql`INSERT INTO "bewcloud_user_sessions" (
|
||||
"user_id",
|
||||
"expires_at",
|
||||
"last_seen_at"
|
||||
) VALUES ($1, $2, $3)
|
||||
RETURNING *`,
|
||||
[
|
||||
newSession.user_id,
|
||||
newSession.expires_at,
|
||||
newSession.last_seen_at,
|
||||
],
|
||||
))[0];
|
||||
|
||||
return newUserSessionResult;
|
||||
}
|
||||
|
||||
export async function updateSession(session: UserSession) {
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_user_sessions" SET
|
||||
"expires_at" = $2,
|
||||
"last_seen_at" = $3
|
||||
WHERE "id" = $1`,
|
||||
[
|
||||
session.id,
|
||||
session.expires_at,
|
||||
session.last_seen_at,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteUserSession(sessionId: string) {
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_user_sessions" WHERE "id" = $1`,
|
||||
[
|
||||
sessionId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function validateUserAndSession(userId: string, sessionId: string) {
|
||||
const user = await getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Not Found');
|
||||
}
|
||||
|
||||
const session = await getSessionById(sessionId);
|
||||
|
||||
if (!session || session.user_id !== user.id) {
|
||||
throw new Error('Not Found');
|
||||
}
|
||||
|
||||
const oneMonthFromToday = new Date(new Date().setUTCMonth(new Date().getUTCMonth() + 1));
|
||||
|
||||
session.last_seen_at = new Date();
|
||||
session.expires_at = oneMonthFromToday;
|
||||
|
||||
await updateSession(session);
|
||||
|
||||
return { user, session };
|
||||
}
|
||||
|
||||
export async function createVerificationCode(
|
||||
user: User,
|
||||
verificationId: string,
|
||||
type: VerificationCode['verification']['type'],
|
||||
) {
|
||||
const inThirtyMinutes = new Date(new Date().setUTCMinutes(new Date().getUTCMinutes() + 30));
|
||||
|
||||
const code = generateRandomCode();
|
||||
|
||||
const newVerificationCode: Omit<VerificationCode, 'id' | 'created_at'> = {
|
||||
user_id: user.id,
|
||||
code,
|
||||
expires_at: inThirtyMinutes,
|
||||
verification: {
|
||||
id: verificationId,
|
||||
type,
|
||||
},
|
||||
};
|
||||
|
||||
await db.query(
|
||||
sql`INSERT INTO "bewcloud_verification_codes" (
|
||||
"user_id",
|
||||
"code",
|
||||
"expires_at",
|
||||
"verification"
|
||||
) VALUES ($1, $2, $3, $4)
|
||||
RETURNING "id"`,
|
||||
[
|
||||
newVerificationCode.user_id,
|
||||
newVerificationCode.code,
|
||||
newVerificationCode.expires_at,
|
||||
JSON.stringify(newVerificationCode.verification),
|
||||
],
|
||||
);
|
||||
|
||||
return code;
|
||||
}
|
||||
|
||||
export async function validateVerificationCode(
|
||||
user: User,
|
||||
verificationId: string,
|
||||
code: string,
|
||||
type: VerificationCode['verification']['type'],
|
||||
) {
|
||||
const verificationCode = (await db.query<VerificationCode>(
|
||||
sql`SELECT * FROM "bewcloud_verification_codes"
|
||||
WHERE "user_id" = $1 AND
|
||||
"code" = $2 AND
|
||||
"verification" ->> 'type' = $3 AND
|
||||
"verification" ->> 'id' = $4 AND
|
||||
"expires_at" > now()
|
||||
LIMIT 1`,
|
||||
[
|
||||
user.id,
|
||||
code,
|
||||
type,
|
||||
verificationId,
|
||||
],
|
||||
))[0];
|
||||
|
||||
if (verificationCode) {
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_verification_codes" WHERE "id" = $1`,
|
||||
[
|
||||
verificationCode.id,
|
||||
],
|
||||
);
|
||||
} else {
|
||||
throw new Error('Not Found');
|
||||
}
|
||||
}
|
||||
22
lib/feed.ts
22
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;
|
||||
|
|
@ -157,7 +158,7 @@ export async function findFeedInUrl(url: string) {
|
|||
}
|
||||
} catch (error) {
|
||||
// This error can happen for huge responses, but that usually means the URL works
|
||||
if (error.toString().includes('RangeError: Maximum call stack size exceeded')) {
|
||||
if ((error as Error).toString().includes('RangeError: Maximum call stack size exceeded')) {
|
||||
return url;
|
||||
} else {
|
||||
console.error(error);
|
||||
|
|
@ -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 'https://deno.land/x/postgres@v0.19.2/mod.ts';
|
||||
import 'std/dotenv/load.ts';
|
||||
import { Client } from 'postgres';
|
||||
import '@std/dotenv/load';
|
||||
|
||||
const POSTGRESQL_HOST = Deno.env.get('POSTGRESQL_HOST') || '';
|
||||
const POSTGRESQL_USER = Deno.env.get('POSTGRESQL_USER') || '';
|
||||
|
|
@ -21,8 +21,14 @@ const tls = POSTGRESQL_CAFILE
|
|||
|
||||
export default class Database {
|
||||
protected db?: Client;
|
||||
protected throwOnConnectionError?: boolean;
|
||||
|
||||
constructor(
|
||||
{ connectNow = false, throwOnConnectionError = false }: { connectNow?: boolean; throwOnConnectionError?: boolean } =
|
||||
{},
|
||||
) {
|
||||
this.throwOnConnectionError = throwOnConnectionError;
|
||||
|
||||
constructor(connectNow = false) {
|
||||
if (connectNow) {
|
||||
this.connectToPostgres();
|
||||
}
|
||||
|
|
@ -33,18 +39,53 @@ export default class Database {
|
|||
return this.db;
|
||||
}
|
||||
|
||||
const postgresClient = new Client({
|
||||
user: POSTGRESQL_USER,
|
||||
password: POSTGRESQL_PASSWORD,
|
||||
database: POSTGRESQL_DBNAME,
|
||||
hostname: POSTGRESQL_HOST,
|
||||
port: POSTGRESQL_PORT,
|
||||
tls,
|
||||
});
|
||||
try {
|
||||
const postgresClient = new Client({
|
||||
user: POSTGRESQL_USER,
|
||||
password: POSTGRESQL_PASSWORD,
|
||||
database: POSTGRESQL_DBNAME,
|
||||
hostname: POSTGRESQL_HOST,
|
||||
port: POSTGRESQL_PORT,
|
||||
tls,
|
||||
});
|
||||
|
||||
await postgresClient.connect();
|
||||
await postgresClient.connect();
|
||||
|
||||
this.db = postgresClient;
|
||||
this.db = postgresClient;
|
||||
} catch (error) {
|
||||
// Try to connect without TLS, if the connection type is socket
|
||||
if ((error as Error).toString().includes('No TLS options are allowed when host type is set to "socket"')) {
|
||||
const postgresClient = new Client({
|
||||
user: POSTGRESQL_USER,
|
||||
password: POSTGRESQL_PASSWORD,
|
||||
database: POSTGRESQL_DBNAME,
|
||||
hostname: POSTGRESQL_HOST,
|
||||
port: POSTGRESQL_PORT,
|
||||
});
|
||||
|
||||
await postgresClient.connect();
|
||||
|
||||
this.db = postgresClient;
|
||||
} else {
|
||||
console.log('Failed to connect to Postgres!');
|
||||
console.error(error);
|
||||
|
||||
if (this.throwOnConnectionError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// This allows tests (and the app) to work even if Postgres is not available
|
||||
const mockPostgresClient = {
|
||||
queryObject: () => {
|
||||
return {
|
||||
rows: [],
|
||||
};
|
||||
},
|
||||
} as unknown as Client;
|
||||
|
||||
this.db = mockPostgresClient;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async disconnectFromPostgres() {
|
||||
|
|
|
|||
69
lib/interfaces/simple-cache.ts
Normal file
69
lib/interfaces/simple-cache.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
const CACHE_NAME_PREFIX = 'bewcloud-v1-';
|
||||
|
||||
const CURRENT_CACHES: Set<string> = new Set();
|
||||
|
||||
const FALLBACK_CACHE: Map<string, string> = new Map();
|
||||
|
||||
export default class SimpleCache {
|
||||
protected cacheName = `${CACHE_NAME_PREFIX}default`;
|
||||
|
||||
constructor(cacheName = 'default') {
|
||||
this.cacheName = `${CACHE_NAME_PREFIX}${cacheName}`;
|
||||
}
|
||||
|
||||
public async get() {
|
||||
if (!CURRENT_CACHES.has(this.cacheName)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const request = new Request(`https://fake.cache/${this.cacheName}`);
|
||||
const cache = await caches.open(this.cacheName);
|
||||
const response = await cache.match(request);
|
||||
|
||||
if (response) {
|
||||
return response.text();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
return FALLBACK_CACHE.get(this.cacheName) || '';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
public async set(value: string) {
|
||||
if (!CURRENT_CACHES.has(this.cacheName)) {
|
||||
CURRENT_CACHES.add(this.cacheName);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.clear();
|
||||
|
||||
const request = new Request(`https://fake.cache/${this.cacheName}`);
|
||||
const cache = await caches.open(this.cacheName);
|
||||
const response = new Response(value, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
|
||||
|
||||
await cache.put(request, response.clone());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
FALLBACK_CACHE.set(this.cacheName, value);
|
||||
}
|
||||
}
|
||||
|
||||
public async clear() {
|
||||
if (!CURRENT_CACHES.has(this.cacheName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
await caches.delete(this.cacheName);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
FALLBACK_CACHE.delete(this.cacheName);
|
||||
}
|
||||
}
|
||||
}
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
202
lib/models/contacts.ts
Normal file
202
lib/models/contacts.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { createDAVClient } from '/lib/models/dav.js';
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
import { parseVCard } from '/public/ts/utils/contacts.ts';
|
||||
|
||||
interface DAVObject extends Record<string, any> {
|
||||
data?: string;
|
||||
displayName?: string;
|
||||
ctag?: string;
|
||||
url: string;
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
export interface Contact extends DAVObject {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
middleNames?: string[];
|
||||
title?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface AddressBook extends DAVObject {}
|
||||
|
||||
const contactsConfig = await AppConfig.getContactsConfig();
|
||||
|
||||
async function getClient(userId: string) {
|
||||
const client = await createDAVClient({
|
||||
serverUrl: contactsConfig.cardDavUrl,
|
||||
credentials: {},
|
||||
authMethod: 'Custom',
|
||||
// deno-lint-ignore require-await
|
||||
authFunction: async () => {
|
||||
return {
|
||||
'X-Remote-User': userId,
|
||||
};
|
||||
},
|
||||
fetchOptions: {
|
||||
timeout: 15_000,
|
||||
},
|
||||
defaultAccountType: 'carddav',
|
||||
rootUrl: `${contactsConfig.cardDavUrl}/`,
|
||||
principalUrl: `${contactsConfig.cardDavUrl}/${userId}/`,
|
||||
homeUrl: `${contactsConfig.cardDavUrl}/${userId}/`,
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export class ContactModel {
|
||||
static async list(
|
||||
userId: string,
|
||||
addressBookId: string,
|
||||
): Promise<Contact[]> {
|
||||
const client = await getClient(userId);
|
||||
|
||||
const addressBookUrl = `${contactsConfig.cardDavUrl}/${userId}/${addressBookId}/`;
|
||||
|
||||
const davContacts: DAVObject[] = await client.fetchVCards({
|
||||
addressBook: {
|
||||
url: addressBookUrl,
|
||||
},
|
||||
});
|
||||
|
||||
const contacts: Contact[] = davContacts.map((davContact) => {
|
||||
return {
|
||||
...davContact,
|
||||
...parseVCard(davContact.data || '')[0],
|
||||
};
|
||||
});
|
||||
|
||||
return contacts;
|
||||
}
|
||||
|
||||
static async get(
|
||||
userId: string,
|
||||
addressBookId: string,
|
||||
contactId: string,
|
||||
): Promise<Contact | undefined> {
|
||||
const contacts = await this.list(userId, addressBookId);
|
||||
|
||||
return contacts.find((contact) => contact.uid === contactId);
|
||||
}
|
||||
|
||||
static async create(
|
||||
userId: string,
|
||||
addressBookId: string,
|
||||
contactId: string,
|
||||
vCard: string,
|
||||
): Promise<void> {
|
||||
const client = await getClient(userId);
|
||||
|
||||
const addressBookUrl = `${contactsConfig.cardDavUrl}/${userId}/${addressBookId}/`;
|
||||
|
||||
await client.createVCard({
|
||||
addressBook: {
|
||||
url: addressBookUrl,
|
||||
},
|
||||
vCardString: vCard,
|
||||
filename: `${contactId}.vcf`,
|
||||
});
|
||||
}
|
||||
|
||||
static async update(
|
||||
userId: string,
|
||||
contactUrl: string,
|
||||
vCard: string,
|
||||
): Promise<void> {
|
||||
const client = await getClient(userId);
|
||||
|
||||
await client.updateVCard({
|
||||
vCard: {
|
||||
url: contactUrl,
|
||||
data: vCard,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async delete(
|
||||
userId: string,
|
||||
contactUrl: string,
|
||||
): Promise<void> {
|
||||
const client = await getClient(userId);
|
||||
|
||||
await client.deleteVCard({
|
||||
vCard: {
|
||||
url: contactUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async listAddressBooks(
|
||||
userId: string,
|
||||
): Promise<AddressBook[]> {
|
||||
const client = await getClient(userId);
|
||||
|
||||
const davAddressBooks: DAVObject[] = await client.fetchAddressBooks();
|
||||
|
||||
const addressBooks: AddressBook[] = davAddressBooks.map((davAddressBook) => {
|
||||
const uid = davAddressBook.url.split('/').filter(Boolean).pop()!;
|
||||
|
||||
return {
|
||||
...davAddressBook,
|
||||
uid,
|
||||
};
|
||||
});
|
||||
|
||||
return addressBooks;
|
||||
}
|
||||
|
||||
static async createAddressBook(
|
||||
userId: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const addressBookId = crypto.randomUUID();
|
||||
const addressBookUrl = `${contactsConfig.cardDavUrl}/${userId}/${addressBookId}/`;
|
||||
|
||||
// For some reason this sends invalid XML
|
||||
// await client.makeCollection({
|
||||
// url: addressBookUrl,
|
||||
// props: {
|
||||
// displayName: name,
|
||||
// },
|
||||
// });
|
||||
|
||||
// Make "manual" request (https://www.rfc-editor.org/rfc/rfc6352.html#page-14)
|
||||
const xmlBody = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:mkcol xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
|
||||
<d:set>
|
||||
<d:prop>
|
||||
<d:displayname>${encodeURIComponent(name)}</d:displayname>
|
||||
<d:resourcetype>
|
||||
<d:collection/>
|
||||
<card:addressbook/>
|
||||
</d:resourcetype>
|
||||
</d:prop>
|
||||
</d:set>
|
||||
</d:mkcol>`;
|
||||
|
||||
await fetch(addressBookUrl, {
|
||||
method: 'MKCOL',
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'X-Remote-User': userId,
|
||||
},
|
||||
body: xmlBody,
|
||||
});
|
||||
}
|
||||
|
||||
static async deleteAddressBook(
|
||||
userId: string,
|
||||
addressBookId: string,
|
||||
): Promise<void> {
|
||||
const client = await getClient(userId);
|
||||
|
||||
const addressBookUrl = `${contactsConfig.cardDavUrl}/${userId}/${addressBookId}/`;
|
||||
|
||||
await client.deleteObject({
|
||||
url: addressBookUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
45
lib/models/dashboard.ts
Normal file
45
lib/models/dashboard.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import { Dashboard } from '/lib/types.ts';
|
||||
|
||||
const db = new Database();
|
||||
|
||||
export class DashboardModel {
|
||||
static async getByUserId(userId: string) {
|
||||
const dashboard =
|
||||
(await db.query<Dashboard>(sql`SELECT * FROM "bewcloud_dashboards" WHERE "user_id" = $1 LIMIT 1`, [
|
||||
userId,
|
||||
]))[0];
|
||||
|
||||
return dashboard;
|
||||
}
|
||||
|
||||
static async create(userId: string) {
|
||||
const data: Dashboard['data'] = { links: [], notes: '' };
|
||||
|
||||
const newDashboard = (await db.query<Dashboard>(
|
||||
sql`INSERT INTO "bewcloud_dashboards" (
|
||||
"user_id",
|
||||
"data"
|
||||
) VALUES ($1, $2)
|
||||
RETURNING *`,
|
||||
[
|
||||
userId,
|
||||
JSON.stringify(data),
|
||||
],
|
||||
))[0];
|
||||
|
||||
return newDashboard;
|
||||
}
|
||||
|
||||
static async update(dashboard: Dashboard) {
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_dashboards" SET
|
||||
"data" = $2
|
||||
WHERE "id" = $1`,
|
||||
[
|
||||
dashboard.id,
|
||||
JSON.stringify(dashboard.data),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
10891
lib/models/dav.js
Normal file
10891
lib/models/dav.js
Normal file
File diff suppressed because it is too large
Load diff
630
lib/models/email.ts
Normal file
630
lib/models/email.ts
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
import nodemailer from 'nodemailer';
|
||||
import '@std/dotenv/load';
|
||||
|
||||
import { escapeHtml } from '/public/ts/utils/misc.ts';
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
|
||||
const SMTP_USERNAME = Deno.env.get('SMTP_USERNAME') || '';
|
||||
const SMTP_PASSWORD = Deno.env.get('SMTP_PASSWORD') || '';
|
||||
|
||||
export class EmailModel {
|
||||
private static async send(to: string, subject: string, htmlBody: string, textBody: string) {
|
||||
const emailConfig = await AppConfig.getEmailConfig();
|
||||
|
||||
if (!emailConfig.from || !emailConfig.host || !emailConfig.port) {
|
||||
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: 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);
|
||||
|
||||
const mailOptions = {
|
||||
from: emailConfig.from,
|
||||
to,
|
||||
subject,
|
||||
html: htmlBody,
|
||||
text: textBody,
|
||||
};
|
||||
|
||||
try {
|
||||
await transporter.sendMail(mailOptions);
|
||||
console.log(`Email sent to "${to}", "${subject}"`);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
throw new Error(`Failed to send email to "${to}", "${subject}"`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Based off of https://github.com/ActiveCampaign/postmark-templates/tree/main/templates-inlined/basic/password-reset */
|
||||
private static getHtmlBody(title: string, htmlBody: string) {
|
||||
return `
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="x-apple-disable-message-reformatting" />
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="supported-color-schemes" content="light dark" />
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style type="text/css" rel="stylesheet" media="all">
|
||||
/* Base ------------------------------ */
|
||||
|
||||
body {
|
||||
width: 100% !important;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
-webkit-text-size-adjust: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #3869D4;
|
||||
}
|
||||
|
||||
a img {
|
||||
border: none;
|
||||
}
|
||||
|
||||
td {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.preheader {
|
||||
display: none !important;
|
||||
visibility: hidden;
|
||||
mso-hide: all;
|
||||
font-size: 1px;
|
||||
line-height: 1px;
|
||||
max-height: 0;
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
/* Type ------------------------------ */
|
||||
|
||||
body,
|
||||
td,
|
||||
th {
|
||||
/* 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 {
|
||||
margin-top: 0;
|
||||
color: #333333;
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
color: #333333;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
color: #333333;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
blockquote {
|
||||
margin: .4em 0 1.1875em;
|
||||
font-size: 16px;
|
||||
line-height: 1.625;
|
||||
}
|
||||
|
||||
p.sub {
|
||||
font-size: 13px;
|
||||
}
|
||||
/* Utilities ------------------------------ */
|
||||
|
||||
.align-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.align-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.align-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.u-margin-bottom-none {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
/* Buttons ------------------------------ */
|
||||
|
||||
.button {
|
||||
background-color: #3869D4;
|
||||
border-top: 10px solid #3869D4;
|
||||
border-right: 18px solid #3869D4;
|
||||
border-bottom: 10px solid #3869D4;
|
||||
border-left: 18px solid #3869D4;
|
||||
display: inline-block;
|
||||
color: #FFF;
|
||||
text-decoration: none;
|
||||
border-radius: 3px;
|
||||
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
|
||||
-webkit-text-size-adjust: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.button--green {
|
||||
background-color: #22BC66;
|
||||
border-top: 10px solid #22BC66;
|
||||
border-right: 18px solid #22BC66;
|
||||
border-bottom: 10px solid #22BC66;
|
||||
border-left: 18px solid #22BC66;
|
||||
}
|
||||
|
||||
.button--red {
|
||||
background-color: #FF6136;
|
||||
border-top: 10px solid #FF6136;
|
||||
border-right: 18px solid #FF6136;
|
||||
border-bottom: 10px solid #FF6136;
|
||||
border-left: 18px solid #FF6136;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 500px) {
|
||||
.button {
|
||||
width: 100% !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
}
|
||||
/* Attribute list ------------------------------ */
|
||||
|
||||
.attributes {
|
||||
margin: 0 0 21px;
|
||||
}
|
||||
|
||||
.attributes_content {
|
||||
background-color: #F4F4F7;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.attributes_item {
|
||||
padding: 0;
|
||||
}
|
||||
/* Related Items ------------------------------ */
|
||||
|
||||
.related {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 25px 0 0 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
|
||||
.related_item {
|
||||
padding: 10px 0;
|
||||
color: #CBCCCF;
|
||||
font-size: 15px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.related_item-title {
|
||||
display: block;
|
||||
margin: .5em 0 0;
|
||||
}
|
||||
|
||||
.related_item-thumb {
|
||||
display: block;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.related_heading {
|
||||
border-top: 1px solid #CBCCCF;
|
||||
text-align: center;
|
||||
padding: 25px 0 10px;
|
||||
}
|
||||
/* Discount Code ------------------------------ */
|
||||
|
||||
.discount {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 24px;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
background-color: #F4F4F7;
|
||||
border: 2px dashed #CBCCCF;
|
||||
}
|
||||
|
||||
.discount_heading {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.discount_body {
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
}
|
||||
/* Social Icons ------------------------------ */
|
||||
|
||||
.social {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.social td {
|
||||
padding: 0;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.social_icon {
|
||||
height: 20px;
|
||||
margin: 0 8px 10px 8px;
|
||||
padding: 0;
|
||||
}
|
||||
/* Data table ------------------------------ */
|
||||
|
||||
.purchase {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 35px 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
|
||||
.purchase_content {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 25px 0 0 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
|
||||
.purchase_item {
|
||||
padding: 10px 0;
|
||||
color: #51545E;
|
||||
font-size: 15px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.purchase_heading {
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #EAEAEC;
|
||||
}
|
||||
|
||||
.purchase_heading p {
|
||||
margin: 0;
|
||||
color: #85878E;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.purchase_footer {
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #EAEAEC;
|
||||
}
|
||||
|
||||
.purchase_total {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.purchase_total--label {
|
||||
padding: 0 15px 0 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #F2F4F6;
|
||||
color: #51545E;
|
||||
}
|
||||
|
||||
p {
|
||||
color: #51545E;
|
||||
}
|
||||
|
||||
.email-wrapper {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
background-color: #F2F4F6;
|
||||
}
|
||||
|
||||
.email-content {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
/* Masthead ----------------------- */
|
||||
|
||||
.email-masthead {
|
||||
padding: 25px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.email-masthead_logo {
|
||||
width: 94px;
|
||||
}
|
||||
|
||||
.email-masthead_name {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #A8AAAF;
|
||||
text-decoration: none;
|
||||
text-shadow: 0 1px 0 white;
|
||||
}
|
||||
/* Body ------------------------------ */
|
||||
|
||||
.email-body {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
}
|
||||
|
||||
.email-body_inner {
|
||||
width: 570px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
-premailer-width: 570px;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.email-footer {
|
||||
width: 570px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
-premailer-width: 570px;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.email-footer p {
|
||||
color: #A8AAAF;
|
||||
}
|
||||
|
||||
.body-action {
|
||||
width: 100%;
|
||||
margin: 30px auto;
|
||||
padding: 0;
|
||||
-premailer-width: 100%;
|
||||
-premailer-cellpadding: 0;
|
||||
-premailer-cellspacing: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.body-sub {
|
||||
margin-top: 25px;
|
||||
padding-top: 25px;
|
||||
border-top: 1px solid #EAEAEC;
|
||||
}
|
||||
|
||||
.content-cell {
|
||||
padding: 45px;
|
||||
}
|
||||
/*Media Queries ------------------------------ */
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.email-body_inner,
|
||||
.email-footer {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body,
|
||||
.email-body,
|
||||
.email-body_inner,
|
||||
.email-content,
|
||||
.email-wrapper,
|
||||
.email-masthead,
|
||||
.email-footer {
|
||||
background-color: #333333 !important;
|
||||
color: #FFF !important;
|
||||
}
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
blockquote,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
span,
|
||||
.purchase_item {
|
||||
color: #FFF !important;
|
||||
}
|
||||
.attributes_content,
|
||||
.discount {
|
||||
background-color: #222 !important;
|
||||
}
|
||||
.email-masthead_name {
|
||||
text-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
supported-color-schemes: light dark;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<style type="text/css">
|
||||
.f-fallback {
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
</style>
|
||||
<![endif]-->
|
||||
</head>
|
||||
<body>
|
||||
<span class="preheader">${escapeHtml(title)}</span>
|
||||
<table class="email-wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table class="email-content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td class="email-masthead">
|
||||
<a href="https://bewcloud.com" class="f-fallback email-masthead_name">
|
||||
bewCloud
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="email-body" width="570" cellpadding="0" cellspacing="0">
|
||||
<table class="email-body_inner" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td class="content-cell">
|
||||
<div class="f-fallback">
|
||||
${htmlBody}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
static async sendVerificationEmail(
|
||||
email: string,
|
||||
verificationCode: string,
|
||||
) {
|
||||
const emailTitle = 'Verify your email in bewCloud';
|
||||
|
||||
const textBody = `
|
||||
${emailTitle}
|
||||
------------------------
|
||||
|
||||
You or someone who knows your email is trying to verify it in bewCloud.
|
||||
|
||||
Here's the verification code:
|
||||
|
||||
**${verificationCode}**
|
||||
===============================
|
||||
|
||||
This code will expire in 30 minutes.
|
||||
`;
|
||||
|
||||
const htmlBody = this.getHtmlBody(
|
||||
emailTitle,
|
||||
`
|
||||
<h1>${escapeHtml(emailTitle)}</h1>
|
||||
<p>You or someone who knows your email is trying to verify it in bewCloud.</p>
|
||||
<p>Here's the verification code:</p>
|
||||
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<span class="f-fallback button button--green">${escapeHtml(verificationCode)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>This code will expire in 30 minutes.</p>
|
||||
`,
|
||||
);
|
||||
|
||||
await this.send(email, emailTitle, htmlBody, textBody);
|
||||
}
|
||||
|
||||
static async sendLoginVerificationEmail(
|
||||
email: string,
|
||||
verificationCode: string,
|
||||
) {
|
||||
const emailTitle = 'Verify your login in bewCloud';
|
||||
|
||||
const textBody = `
|
||||
${emailTitle}
|
||||
------------------------
|
||||
|
||||
You or someone who knows your email and password is trying to login to bewCloud.
|
||||
|
||||
Here's the verification code:
|
||||
|
||||
**${verificationCode}**
|
||||
===============================
|
||||
|
||||
This code will expire in 30 minutes.
|
||||
`;
|
||||
|
||||
const htmlBody = this.getHtmlBody(
|
||||
emailTitle,
|
||||
`
|
||||
<h1>${escapeHtml(emailTitle)}</h1>
|
||||
<p>You or someone who knows your email and password is trying to login to bewCloud.</p>
|
||||
<p>Here's the verification code:</p>
|
||||
<table class="body-action" align="center" width="100%" cellpadding="0" cellspacing="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" role="presentation">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<span class="f-fallback button button--green">${escapeHtml(verificationCode)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p>This code will expire in 30 minutes.</p>
|
||||
`,
|
||||
);
|
||||
|
||||
await this.send(email, emailTitle, htmlBody, textBody);
|
||||
}
|
||||
}
|
||||
479
lib/models/expenses.ts
Normal file
479
lib/models/expenses.ts
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import Locker from '/lib/interfaces/locker.ts';
|
||||
import { Budget, Expense } from '/lib/types.ts';
|
||||
|
||||
const db = new Database();
|
||||
|
||||
export class BudgetModel {
|
||||
static async list(
|
||||
userId: string,
|
||||
month: string,
|
||||
{ skipRecalculation = false }: { skipRecalculation?: boolean } = {},
|
||||
) {
|
||||
if (!skipRecalculation) {
|
||||
await recalculateMonthBudgets(userId, month);
|
||||
}
|
||||
|
||||
const budgets = await db.query<Budget>(
|
||||
sql`SELECT * FROM "bewcloud_budgets" WHERE "user_id" = $1 AND "month" = $2 ORDER BY cast("extra"->>'availableValue' as numeric) DESC, "value" DESC, "name" ASC`,
|
||||
[
|
||||
userId,
|
||||
month,
|
||||
],
|
||||
);
|
||||
|
||||
// Numeric values come as strings, so we need to convert them to numbers
|
||||
return budgets.map((budget) => ({
|
||||
...budget,
|
||||
value: Number(budget.value),
|
||||
}));
|
||||
}
|
||||
|
||||
static async getByName(userId: string, month: string, name: string) {
|
||||
const budget = (await db.query<Budget>(
|
||||
sql`SELECT * FROM "bewcloud_budgets" WHERE "user_id" = $1 AND "month" = $2 AND LOWER("name") = LOWER($3)`,
|
||||
[
|
||||
userId,
|
||||
month,
|
||||
name,
|
||||
],
|
||||
))[0];
|
||||
|
||||
if (!budget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Numeric values come as strings, so we need to convert them to numbers
|
||||
return {
|
||||
...budget,
|
||||
value: Number(budget.value),
|
||||
};
|
||||
}
|
||||
|
||||
static async getById(userId: string, id: string) {
|
||||
const budget = (await db.query<Budget>(
|
||||
sql`SELECT * FROM "bewcloud_budgets" WHERE "user_id" = $1 AND "id" = $2`,
|
||||
[userId, id],
|
||||
))[0];
|
||||
|
||||
if (!budget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Numeric values come as strings, so we need to convert them to numbers
|
||||
return {
|
||||
...budget,
|
||||
value: Number(budget.value),
|
||||
};
|
||||
}
|
||||
|
||||
static async getAllForExport(
|
||||
userId: string,
|
||||
): Promise<(Omit<Budget, 'id' | 'user_id' | 'created_at' | 'extra'> & { extra: Record<never, never> })[]> {
|
||||
const budgets = await db.query<Budget>(
|
||||
sql`SELECT * FROM "bewcloud_budgets" WHERE "user_id" = $1 ORDER BY "month" DESC, "name" ASC`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return budgets.map((budget) => ({
|
||||
name: budget.name,
|
||||
month: budget.month,
|
||||
// Numeric values come as strings, so we need to convert them to numbers
|
||||
value: Number(budget.value),
|
||||
extra: {},
|
||||
}));
|
||||
}
|
||||
|
||||
static async create(userId: string, name: string, month: string, value: number) {
|
||||
const extra: Budget['extra'] = {
|
||||
usedValue: 0,
|
||||
availableValue: value,
|
||||
};
|
||||
|
||||
const newBudget = (await db.query<Budget>(
|
||||
sql`INSERT INTO "bewcloud_budgets" (
|
||||
"user_id",
|
||||
"name",
|
||||
"month",
|
||||
"value",
|
||||
"extra"
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[
|
||||
userId,
|
||||
name,
|
||||
month,
|
||||
value,
|
||||
JSON.stringify(extra),
|
||||
],
|
||||
))[0];
|
||||
|
||||
// Numeric values come as strings, so we need to convert them to numbers
|
||||
return {
|
||||
...newBudget,
|
||||
value: Number(newBudget.value),
|
||||
};
|
||||
}
|
||||
|
||||
static async update(
|
||||
budget: Budget,
|
||||
{ skipRecalculation = false }: { skipRecalculation?: boolean } = {},
|
||||
) {
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_budgets" SET
|
||||
"name" = $2,
|
||||
"month" = $3,
|
||||
"value" = $4,
|
||||
"extra" = $5
|
||||
WHERE "id" = $1`,
|
||||
[
|
||||
budget.id,
|
||||
budget.name,
|
||||
budget.month,
|
||||
budget.value,
|
||||
JSON.stringify(budget.extra),
|
||||
],
|
||||
);
|
||||
|
||||
if (!skipRecalculation) {
|
||||
await recalculateMonthBudgets(budget.user_id, budget.month);
|
||||
}
|
||||
}
|
||||
|
||||
static async delete(userId: string, id: string) {
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_budgets" WHERE "id" = $1 AND "user_id" = $2`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ExpenseModel {
|
||||
static async list(userId: string, month: string) {
|
||||
const expenses = await db.query<Expense>(
|
||||
sql`SELECT * FROM "bewcloud_expenses" WHERE "user_id" = $1 AND "date" >= $2 AND "date" <= $3 ORDER BY "date" DESC, "created_at" DESC`,
|
||||
[
|
||||
userId,
|
||||
`${month}-01`,
|
||||
`${month}-31`,
|
||||
],
|
||||
);
|
||||
|
||||
// Numeric values come as strings, so we need to convert them to numbers
|
||||
return expenses.map((expense) => ({
|
||||
...expense,
|
||||
cost: Number(expense.cost),
|
||||
}));
|
||||
}
|
||||
|
||||
static async getByName(userId: string, name: string) {
|
||||
const expense = (await db.query<Expense>(
|
||||
sql`SELECT * FROM "bewcloud_expenses" WHERE "user_id" = $1 AND LOWER("description") = LOWER($2) ORDER BY "date" DESC, "created_at" DESC`,
|
||||
[
|
||||
userId,
|
||||
name,
|
||||
],
|
||||
))[0];
|
||||
|
||||
if (!expense) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Numeric values come as strings, so we need to convert them to numbers
|
||||
return {
|
||||
...expense,
|
||||
cost: Number(expense.cost),
|
||||
};
|
||||
}
|
||||
|
||||
static async listSuggestions(userId: string, name: string) {
|
||||
const expenses = await db.query<Pick<Expense, 'description'>>(
|
||||
sql`SELECT "description" FROM "bewcloud_expenses" WHERE "user_id" = $1 AND LOWER("description") ILIKE LOWER($2) GROUP BY "description" ORDER BY LENGTH("description") ASC, "description" ASC`,
|
||||
[
|
||||
userId,
|
||||
`%${name}%`,
|
||||
],
|
||||
);
|
||||
|
||||
return expenses.map((expense) => expense.description);
|
||||
}
|
||||
|
||||
static async getAllForExport(
|
||||
userId: string,
|
||||
): Promise<(Omit<Expense, 'id' | 'user_id' | 'created_at' | 'extra'> & { extra: Record<never, never> })[]> {
|
||||
const expenses = await db.query<Expense>(
|
||||
sql`SELECT * FROM "bewcloud_expenses" WHERE "user_id" = $1 ORDER BY "date" DESC, "created_at" DESC`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return expenses.map((expense) => ({
|
||||
description: expense.description,
|
||||
budget: expense.budget,
|
||||
date: expense.date,
|
||||
is_recurring: expense.is_recurring,
|
||||
// Numeric values come as strings, so we need to convert them to numbers
|
||||
cost: Number(expense.cost),
|
||||
extra: {},
|
||||
}));
|
||||
}
|
||||
|
||||
static async getById(userId: string, id: string) {
|
||||
const expense = (await db.query<Expense>(
|
||||
sql`SELECT * FROM "bewcloud_expenses" WHERE "user_id" = $1 AND "id" = $2`,
|
||||
[userId, id],
|
||||
))[0];
|
||||
|
||||
if (!expense) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Numeric values come as strings, so we need to convert them to numbers
|
||||
return {
|
||||
...expense,
|
||||
cost: Number(expense.cost),
|
||||
};
|
||||
}
|
||||
|
||||
static async create(
|
||||
userId: string,
|
||||
cost: number,
|
||||
description: string,
|
||||
budget: string,
|
||||
date: string,
|
||||
is_recurring: boolean,
|
||||
{ skipRecalculation = false, skipBudgetMatching = false, skipBudgetCreation = false }: {
|
||||
skipRecalculation?: boolean;
|
||||
skipBudgetMatching?: boolean;
|
||||
skipBudgetCreation?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const extra: Expense['extra'] = {};
|
||||
|
||||
if (!budget.trim()) {
|
||||
budget = 'Misc';
|
||||
}
|
||||
|
||||
// Match budget to an existing expense "by default"
|
||||
if (!skipBudgetMatching && budget === 'Misc') {
|
||||
const existingExpense = await this.getByName(userId, description);
|
||||
|
||||
if (existingExpense) {
|
||||
budget = existingExpense.budget;
|
||||
}
|
||||
}
|
||||
|
||||
if (!skipBudgetCreation) {
|
||||
const existingBudgetInMonth = await BudgetModel.getByName(userId, date.substring(0, 7), budget);
|
||||
|
||||
if (!existingBudgetInMonth) {
|
||||
await BudgetModel.create(userId, budget, date.substring(0, 7), 100);
|
||||
}
|
||||
}
|
||||
|
||||
const newExpense = (await db.query<Expense>(
|
||||
sql`INSERT INTO "bewcloud_expenses" (
|
||||
"user_id",
|
||||
"cost",
|
||||
"description",
|
||||
"budget",
|
||||
"date",
|
||||
"is_recurring",
|
||||
"extra"
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[
|
||||
userId,
|
||||
cost,
|
||||
description,
|
||||
budget,
|
||||
date,
|
||||
is_recurring,
|
||||
JSON.stringify(extra),
|
||||
],
|
||||
))[0];
|
||||
|
||||
if (!skipRecalculation) {
|
||||
await recalculateMonthBudgets(userId, date.substring(0, 7));
|
||||
}
|
||||
|
||||
// Numeric values come as strings, so we need to convert them to numbers
|
||||
return {
|
||||
...newExpense,
|
||||
cost: Number(newExpense.cost),
|
||||
};
|
||||
}
|
||||
|
||||
static async update(expense: Expense) {
|
||||
const existingBudgetInMonth = await BudgetModel.getByName(
|
||||
expense.user_id,
|
||||
expense.date.substring(0, 7),
|
||||
expense.budget,
|
||||
);
|
||||
|
||||
if (!existingBudgetInMonth) {
|
||||
await BudgetModel.create(expense.user_id, expense.budget, expense.date.substring(0, 7), 100);
|
||||
}
|
||||
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_expenses" SET
|
||||
"cost" = $2,
|
||||
"description" = $3,
|
||||
"budget" = $4,
|
||||
"date" = $5,
|
||||
"is_recurring" = $6,
|
||||
"extra" = $7
|
||||
WHERE "id" = $1`,
|
||||
[
|
||||
expense.id,
|
||||
expense.cost,
|
||||
expense.description,
|
||||
expense.budget,
|
||||
expense.date,
|
||||
expense.is_recurring,
|
||||
JSON.stringify(expense.extra),
|
||||
],
|
||||
);
|
||||
|
||||
await recalculateMonthBudgets(expense.user_id, expense.date.substring(0, 7));
|
||||
}
|
||||
|
||||
static async delete(userId: string, id: string) {
|
||||
const expense = await this.getById(userId, id);
|
||||
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_expenses" WHERE "id" = $1 AND "user_id" = $2`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
await recalculateMonthBudgets(userId, expense!.date.substring(0, 7));
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAllBudgetsAndExpenses(userId: string) {
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_expenses" WHERE "user_id" = $1`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_budgets" WHERE "user_id" = $1`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMonthlyBudgetsAndExpenses(userId: string, month: string) {
|
||||
const lock = new Locker(`expenses:${userId}:${month}`);
|
||||
|
||||
await lock.acquire();
|
||||
|
||||
let addedBudgetsCount = 0;
|
||||
let addedExpensesCount = 0;
|
||||
|
||||
try {
|
||||
// Confirm there are no budgets or expenses for this month
|
||||
const monthBudgets = await BudgetModel.list(userId, month, { skipRecalculation: true });
|
||||
const monthExpenses = await ExpenseModel.list(userId, month);
|
||||
|
||||
if (monthBudgets.length > 0 || monthExpenses.length > 0) {
|
||||
throw new Error('Budgets and expenses already exist for this month!');
|
||||
}
|
||||
|
||||
// Get the previous month's budgets, to copy over
|
||||
const previousMonthDate = new Date(month);
|
||||
previousMonthDate.setMonth(previousMonthDate.getMonth() - 1);
|
||||
|
||||
const previousMonth = previousMonthDate.toISOString().substring(0, 7);
|
||||
|
||||
const budgets = await BudgetModel.list(userId, previousMonth, { skipRecalculation: true });
|
||||
|
||||
for (const budget of budgets) {
|
||||
await BudgetModel.create(userId, budget.name, month, budget.value);
|
||||
|
||||
addedBudgetsCount++;
|
||||
}
|
||||
|
||||
// Get the recurring expenses for the previous month, to copy over
|
||||
const recurringExpenses = (await ExpenseModel.list(userId, previousMonth)).filter((expense) =>
|
||||
expense.is_recurring
|
||||
);
|
||||
|
||||
for (const expense of recurringExpenses) {
|
||||
await ExpenseModel.create(
|
||||
userId,
|
||||
expense.cost,
|
||||
expense.description,
|
||||
expense.budget,
|
||||
expense.date.replace(previousMonth, month),
|
||||
expense.is_recurring,
|
||||
{ skipRecalculation: true },
|
||||
);
|
||||
|
||||
addedExpensesCount++;
|
||||
}
|
||||
|
||||
console.info(`Added ${addedBudgetsCount} new budgets and ${addedExpensesCount} new expenses for ${month}`);
|
||||
|
||||
lock.release();
|
||||
} catch (error) {
|
||||
lock.release();
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function recalculateMonthBudgets(userId: string, month: string) {
|
||||
const lock = new Locker(`expenses:${userId}:${month}`);
|
||||
|
||||
await lock.acquire();
|
||||
|
||||
try {
|
||||
const budgets = await BudgetModel.list(userId, month, { skipRecalculation: true });
|
||||
const expenses = await ExpenseModel.list(userId, month);
|
||||
|
||||
// Calculate total expenses for each budget
|
||||
const budgetExpenses = new Map<string, number>();
|
||||
for (const expense of expenses) {
|
||||
const currentTotal = Number(budgetExpenses.get(expense.budget) || 0);
|
||||
budgetExpenses.set(expense.budget, Number(currentTotal + expense.cost));
|
||||
}
|
||||
|
||||
// Update each budget with new calculations
|
||||
for (const budget of budgets) {
|
||||
const usedValue = Number(budgetExpenses.get(budget.name) || 0);
|
||||
const availableValue = Number(budget.value - usedValue);
|
||||
|
||||
const updatedBudget: Budget = {
|
||||
...budget,
|
||||
extra: {
|
||||
...budget.extra,
|
||||
usedValue,
|
||||
availableValue,
|
||||
},
|
||||
};
|
||||
|
||||
if (budget.extra.usedValue !== usedValue || budget.extra.availableValue !== availableValue) {
|
||||
await BudgetModel.update(updatedBudget, { skipRecalculation: true });
|
||||
}
|
||||
}
|
||||
|
||||
lock.release();
|
||||
} catch (error) {
|
||||
lock.release();
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
818
lib/models/files.ts
Normal file
818
lib/models/files.ts
Normal file
|
|
@ -0,0 +1,818 @@
|
|||
import { join, resolve } from '@std/path';
|
||||
import { lookup } from 'mrmime';
|
||||
import { Cookie, getCookies, setCookie } from '@std/http';
|
||||
|
||||
import { AppConfig } from '/lib/config.ts';
|
||||
import { Directory, DirectoryFile, FileShare } from '/lib/types.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,
|
||||
generateKey,
|
||||
generateToken,
|
||||
JWT_SECRET,
|
||||
resolveCookieDomain,
|
||||
verifyAuthJwt,
|
||||
} from '/lib/auth.ts';
|
||||
import { isRunningLocally } from '/public/ts/utils/misc.ts';
|
||||
|
||||
const COOKIE_NAME = `${AUTH_COOKIE_NAME}-file-share`;
|
||||
|
||||
const db = new Database();
|
||||
|
||||
export class DirectoryModel {
|
||||
static async list(userId: string, path: string): Promise<Directory[]> {
|
||||
await ensureUserPathIsValidAndSecurelyAccessible(userId, path);
|
||||
|
||||
const rootPath = join(await AppConfig.getFilesRootPath(), userId, path);
|
||||
|
||||
const directories: Directory[] = [];
|
||||
|
||||
const directoryEntries = (await getPathEntries(userId, path)).filter((entry) =>
|
||||
entry.isDirectory || entry.isSymlink
|
||||
);
|
||||
|
||||
const fileShares = (await AppConfig.isPublicFileSharingAllowed())
|
||||
? await FileShareModel.getByParentFilePath(userId, path)
|
||||
: [];
|
||||
|
||||
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: 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(),
|
||||
};
|
||||
|
||||
directories.push(directory);
|
||||
}
|
||||
|
||||
directories.sort(sortDirectoriesByName);
|
||||
|
||||
return directories;
|
||||
}
|
||||
|
||||
static async create(userId: string, path: string, name: string): Promise<boolean> {
|
||||
await ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name));
|
||||
|
||||
const rootPath = join(await AppConfig.getFilesRootPath(), userId, path);
|
||||
|
||||
try {
|
||||
await Deno.mkdir(join(rootPath, name), { recursive: true });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static async rename(
|
||||
userId: string,
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): Promise<boolean> {
|
||||
return await renameDirectoryOrFile(userId, oldPath, newPath, oldName, newName);
|
||||
}
|
||||
|
||||
static async delete(userId: string, path: string, name: string): Promise<boolean> {
|
||||
return await deleteDirectoryOrFile(userId, path, name);
|
||||
}
|
||||
|
||||
static async searchNames(
|
||||
userId: string,
|
||||
searchTerm: string,
|
||||
): Promise<{ success: boolean; directories: Directory[] }> {
|
||||
const rootPath = join(await AppConfig.getFilesRootPath(), userId);
|
||||
|
||||
const directories: Directory[] = [];
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const commandTimeout = setTimeout(() => controller.abort(), 10_000);
|
||||
|
||||
const command = new Deno.Command(`find`, {
|
||||
args: [
|
||||
`.`, // proper cwd is sent below
|
||||
`-type`,
|
||||
`d,l`, // directories and symbolic links
|
||||
`-iname`,
|
||||
`*${searchTerm}*`,
|
||||
],
|
||||
cwd: rootPath,
|
||||
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 "find"`);
|
||||
}
|
||||
|
||||
const output = new TextDecoder().decode(stdout);
|
||||
const matchingDirectories = output.split('\n').map((directoryPath) => directoryPath.trim()).filter(Boolean);
|
||||
|
||||
for (const relativeDirectoryPath of matchingDirectories) {
|
||||
const fileShares = (await AppConfig.isPublicFileSharingAllowed())
|
||||
? await FileShareModel.getByParentFilePath(userId, relativeDirectoryPath)
|
||||
: [];
|
||||
|
||||
const stat = await Deno.stat(join(rootPath, relativeDirectoryPath));
|
||||
let parentPath = `/${relativeDirectoryPath.replace('./', '/').split('/').slice(0, -1).join('')}/`;
|
||||
const directoryName = relativeDirectoryPath.split('/').pop()!;
|
||||
|
||||
if (parentPath === '//') {
|
||||
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: directorySize || stat.size,
|
||||
file_share_id: fileShares.find((fileShare) =>
|
||||
fileShare.file_path === `${join(relativeDirectoryPath, directoryName)}/`
|
||||
)?.id || null,
|
||||
updated_at: stat.mtime || new Date(),
|
||||
created_at: stat.birthtime || new Date(),
|
||||
};
|
||||
|
||||
directories.push(directory);
|
||||
}
|
||||
|
||||
return { success: true, directories };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return { success: false, directories };
|
||||
}
|
||||
}
|
||||
|
||||
export class FileModel {
|
||||
static async list(userId: string, path: string): Promise<DirectoryFile[]> {
|
||||
await ensureUserPathIsValidAndSecurelyAccessible(userId, path);
|
||||
|
||||
const rootPath = join(await AppConfig.getFilesRootPath(), userId, path);
|
||||
|
||||
const files: DirectoryFile[] = [];
|
||||
|
||||
const fileEntries = (await getPathEntries(userId, path)).filter((entry) => entry.isFile);
|
||||
|
||||
const fileShares = (await AppConfig.isPublicFileSharingAllowed())
|
||||
? await FileShareModel.getByParentFilePath(userId, path)
|
||||
: [];
|
||||
|
||||
for (const entry of fileEntries) {
|
||||
const stat = await Deno.stat(join(rootPath, entry.name));
|
||||
|
||||
const file: DirectoryFile = {
|
||||
user_id: userId,
|
||||
parent_path: path,
|
||||
file_name: entry.name,
|
||||
has_write_access: true,
|
||||
size_in_bytes: 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(),
|
||||
};
|
||||
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
files.sort(sortFilesByName);
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
static async create(
|
||||
userId: string,
|
||||
path: string,
|
||||
name: string,
|
||||
contents: string | ArrayBuffer,
|
||||
): Promise<boolean> {
|
||||
await ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name));
|
||||
|
||||
const rootPath = join(await AppConfig.getFilesRootPath(), userId, path);
|
||||
|
||||
try {
|
||||
// Ensure the directory exist, if being requested
|
||||
try {
|
||||
await Deno.stat(rootPath);
|
||||
} catch (error) {
|
||||
if ((error as Error).toString().includes('NotFound')) {
|
||||
await Deno.mkdir(rootPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof contents === 'string') {
|
||||
await Deno.writeTextFile(join(rootPath, name), contents, { append: false, createNew: true });
|
||||
} else {
|
||||
await Deno.writeFile(join(rootPath, name), new Uint8Array(contents), { append: false, createNew: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static async update(
|
||||
userId: string,
|
||||
path: string,
|
||||
name: string,
|
||||
contents: string,
|
||||
): Promise<boolean> {
|
||||
await ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name));
|
||||
|
||||
const rootPath = join(await AppConfig.getFilesRootPath(), userId, path);
|
||||
|
||||
try {
|
||||
await Deno.writeTextFile(join(rootPath, name), contents, { append: false, createNew: false });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static async get(
|
||||
userId: string,
|
||||
path: string,
|
||||
name?: string,
|
||||
): Promise<{ success: boolean; contents?: Uint8Array; contentType?: string; byteSize?: number }> {
|
||||
await ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name || ''));
|
||||
|
||||
const rootPath = join(await AppConfig.getFilesRootPath(), userId, path);
|
||||
|
||||
try {
|
||||
const stat = await Deno.stat(join(rootPath, name || ''));
|
||||
|
||||
if (stat) {
|
||||
const contents = await Deno.readFile(join(rootPath, name || ''));
|
||||
|
||||
const extension = (name || path).split('.').slice(-1).join('').toLowerCase();
|
||||
|
||||
const contentType = lookup(extension) || 'application/octet-stream';
|
||||
|
||||
return {
|
||||
success: true,
|
||||
contents,
|
||||
contentType,
|
||||
byteSize: stat.size,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
static async rename(
|
||||
userId: string,
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): Promise<boolean> {
|
||||
return await renameDirectoryOrFile(userId, oldPath, newPath, oldName, newName);
|
||||
}
|
||||
|
||||
static async delete(userId: string, path: string, name: string): Promise<boolean> {
|
||||
return await deleteDirectoryOrFile(userId, path, name);
|
||||
}
|
||||
|
||||
static async searchNames(
|
||||
userId: string,
|
||||
searchTerm: string,
|
||||
): Promise<{ success: boolean; files: DirectoryFile[] }> {
|
||||
const rootPath = join(await AppConfig.getFilesRootPath(), userId);
|
||||
|
||||
const files: DirectoryFile[] = [];
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const commandTimeout = setTimeout(() => controller.abort(), 10_000);
|
||||
|
||||
const command = new Deno.Command(`find`, {
|
||||
args: [
|
||||
`.`, // proper cwd is sent below
|
||||
`-type`,
|
||||
`f`,
|
||||
`-iname`,
|
||||
`*${searchTerm}*`,
|
||||
],
|
||||
cwd: rootPath,
|
||||
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 "find"`);
|
||||
}
|
||||
|
||||
const output = new TextDecoder().decode(stdout);
|
||||
const matchingFiles = output.split('\n').map((filePath) => filePath.trim()).filter(Boolean);
|
||||
|
||||
for (const relativeFilePath of matchingFiles) {
|
||||
const fileShares = (await AppConfig.isPublicFileSharingAllowed())
|
||||
? await FileShareModel.getByParentFilePath(userId, relativeFilePath)
|
||||
: [];
|
||||
|
||||
const stat = await Deno.stat(join(rootPath, relativeFilePath));
|
||||
let parentPath = `/${relativeFilePath.replace('./', '/').split('/').slice(0, -1).join('')}/`;
|
||||
const fileName = relativeFilePath.split('/').pop()!;
|
||||
|
||||
if (parentPath === '//') {
|
||||
parentPath = '/';
|
||||
}
|
||||
|
||||
const file: DirectoryFile = {
|
||||
user_id: userId,
|
||||
parent_path: parentPath,
|
||||
file_name: fileName,
|
||||
has_write_access: true,
|
||||
size_in_bytes: stat.size,
|
||||
file_share_id: fileShares.find((fileShare) => fileShare.file_path === join(relativeFilePath, fileName))?.id ||
|
||||
null,
|
||||
updated_at: stat.mtime || new Date(),
|
||||
created_at: stat.birthtime || new Date(),
|
||||
};
|
||||
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
return { success: true, files };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return { success: false, files };
|
||||
}
|
||||
|
||||
static async searchContents(
|
||||
userId: string,
|
||||
searchTerm: string,
|
||||
): Promise<{ success: boolean; files: DirectoryFile[] }> {
|
||||
const rootPath = join(await AppConfig.getFilesRootPath(), userId);
|
||||
|
||||
const files: DirectoryFile[] = [];
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const commandTimeout = setTimeout(() => controller.abort(), 10_000);
|
||||
|
||||
const command = new Deno.Command(`grep`, {
|
||||
args: [
|
||||
`-rHisl`,
|
||||
`${searchTerm}`,
|
||||
`.`, // proper cwd is sent below
|
||||
],
|
||||
cwd: rootPath,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const { code, stdout, stderr } = await command.output();
|
||||
|
||||
if (commandTimeout) {
|
||||
clearTimeout(commandTimeout);
|
||||
}
|
||||
|
||||
if (code > 1) {
|
||||
if (stderr) {
|
||||
throw new Error(new TextDecoder().decode(stderr));
|
||||
}
|
||||
|
||||
throw new Error(`Unknown error running "grep"`);
|
||||
}
|
||||
|
||||
const output = new TextDecoder().decode(stdout);
|
||||
const matchingFiles = output.split('\n').map((filePath) => filePath.trim()).filter(Boolean);
|
||||
|
||||
for (const relativeFilePath of matchingFiles) {
|
||||
const fileShares = (await AppConfig.isPublicFileSharingAllowed())
|
||||
? await FileShareModel.getByParentFilePath(userId, relativeFilePath)
|
||||
: [];
|
||||
|
||||
const stat = await Deno.stat(join(rootPath, relativeFilePath));
|
||||
let parentPath = `/${relativeFilePath.replace('./', '/').split('/').slice(0, -1).join('')}/`;
|
||||
const fileName = relativeFilePath.split('/').pop()!;
|
||||
|
||||
if (parentPath === '//') {
|
||||
parentPath = '/';
|
||||
}
|
||||
|
||||
const file: DirectoryFile = {
|
||||
user_id: userId,
|
||||
parent_path: parentPath,
|
||||
file_name: fileName,
|
||||
has_write_access: true,
|
||||
size_in_bytes: stat.size,
|
||||
file_share_id: fileShares.find((fileShare) => fileShare.file_path === join(relativeFilePath, fileName))?.id ||
|
||||
null,
|
||||
updated_at: stat.mtime || new Date(),
|
||||
created_at: stat.birthtime || new Date(),
|
||||
};
|
||||
|
||||
files.push(file);
|
||||
}
|
||||
|
||||
return { success: true, files };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return { success: false, files };
|
||||
}
|
||||
}
|
||||
|
||||
export interface FileShareJwtData {
|
||||
data: {
|
||||
file_share_id: string;
|
||||
hashed_password: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class FileShareModel {
|
||||
static async getById(id: string): Promise<FileShare | null> {
|
||||
const fileShare = (await db.query<FileShare>(sql`SELECT * FROM "bewcloud_file_shares" WHERE "id" = $1 LIMIT 1`, [
|
||||
id,
|
||||
]))[0];
|
||||
|
||||
return fileShare;
|
||||
}
|
||||
|
||||
static async getByParentFilePath(userId: string, parentFilePath: string): Promise<FileShare[]> {
|
||||
const fileShares = await db.query<FileShare>(
|
||||
sql`SELECT * FROM "bewcloud_file_shares" WHERE "user_id" = $1 AND "file_path" LIKE $2`,
|
||||
[userId, `${parentFilePath}%`],
|
||||
);
|
||||
|
||||
return fileShares;
|
||||
}
|
||||
|
||||
static async create(fileShare: Omit<FileShare, 'id' | 'created_at'>): Promise<FileShare> {
|
||||
const newFileShare = (await db.query<FileShare>(
|
||||
sql`INSERT INTO "bewcloud_file_shares" (
|
||||
"user_id",
|
||||
"file_path",
|
||||
"extra"
|
||||
) VALUES ($1, $2, $3)
|
||||
RETURNING *`,
|
||||
[
|
||||
fileShare.user_id,
|
||||
fileShare.file_path,
|
||||
JSON.stringify(fileShare.extra),
|
||||
],
|
||||
))[0];
|
||||
|
||||
return newFileShare;
|
||||
}
|
||||
|
||||
static async update(fileShare: FileShare): Promise<void> {
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_file_shares" SET "extra" = $2 WHERE "id" = $1`,
|
||||
[fileShare.id, JSON.stringify(fileShare.extra)],
|
||||
);
|
||||
}
|
||||
|
||||
static async delete(fileShareId: string): Promise<void> {
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_file_shares" WHERE "id" = $1`,
|
||||
[fileShareId],
|
||||
);
|
||||
}
|
||||
|
||||
static async createSessionCookie(
|
||||
request: Request,
|
||||
response: Response,
|
||||
fileShareId: string,
|
||||
hashedPassword: string,
|
||||
) {
|
||||
const token = await generateToken<FileShareJwtData['data']>({
|
||||
file_share_id: fileShareId,
|
||||
hashed_password: hashedPassword,
|
||||
});
|
||||
|
||||
const cookie: Cookie = {
|
||||
name: COOKIE_NAME,
|
||||
value: token,
|
||||
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), // 7 days
|
||||
path: `/file-share/${fileShareId}`,
|
||||
secure: isRunningLocally(request) ? false : true,
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
domain: await resolveCookieDomain(request),
|
||||
};
|
||||
|
||||
if (await AppConfig.isCookieDomainSecurityDisabled()) {
|
||||
delete cookie.domain;
|
||||
}
|
||||
|
||||
setCookie(response.headers, cookie);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
static async getDataFromRequest(request: Request): Promise<{ fileShareId: string; hashedPassword: string } | null> {
|
||||
const cookies = getCookies(request.headers);
|
||||
|
||||
if (cookies[COOKIE_NAME]) {
|
||||
const result = await this.getDataFromCookie(cookies[COOKIE_NAME]);
|
||||
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async getDataFromCookie(
|
||||
cookieValue: string,
|
||||
): Promise<{ fileShareId: string; hashedPassword: string } | null> {
|
||||
if (!cookieValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = await generateKey(JWT_SECRET);
|
||||
|
||||
try {
|
||||
const token = await verifyAuthJwt<FileShareJwtData>(key, cookieValue);
|
||||
|
||||
if (!token.data.file_share_id || !token.data.hashed_password) {
|
||||
throw new Error('Not Found');
|
||||
}
|
||||
|
||||
return { fileShareId: token.data.file_share_id, hashedPassword: token.data.hashed_password };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the user path is valid and securely accessible (meaning it's not trying to access files outside of the user's root directory).
|
||||
* Does not check if the path exists.
|
||||
*
|
||||
* @param userId - The user ID
|
||||
* @param path - The relative path (user-provided) to check
|
||||
*/
|
||||
export async function ensureUserPathIsValidAndSecurelyAccessible(userId: string, path: string): Promise<void> {
|
||||
const userRootPath = join(await AppConfig.getFilesRootPath(), userId, '/');
|
||||
|
||||
const fullPath = join(userRootPath, path);
|
||||
|
||||
const resolvedFullPath = `${resolve(fullPath)}/`;
|
||||
|
||||
// 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');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the file share path is valid and securely accessible (meaning it's not trying to access files outside of the file share's root directory).
|
||||
* Does not check if the path exists.
|
||||
*
|
||||
* @param userId - The user ID
|
||||
* @param fileSharePath - The file share path
|
||||
* @param path - The relative path (user-provided) to check
|
||||
*/
|
||||
export async function ensureFileSharePathIsValidAndSecurelyAccessible(
|
||||
userId: string,
|
||||
fileSharePath: string,
|
||||
path: string,
|
||||
): Promise<void> {
|
||||
await ensureUserPathIsValidAndSecurelyAccessible(userId, fileSharePath);
|
||||
|
||||
const userRootPath = join(await AppConfig.getFilesRootPath(), userId, '/');
|
||||
|
||||
const fileShareRootPath = join(userRootPath, fileSharePath);
|
||||
|
||||
const fullPath = join(fileShareRootPath, path);
|
||||
|
||||
const resolvedFullPath = `${resolve(fullPath)}/`;
|
||||
|
||||
if (!resolvedFullPath.startsWith(fileShareRootPath)) {
|
||||
throw new Error('Invalid file path');
|
||||
}
|
||||
}
|
||||
|
||||
async function getPathEntries(userId: string, path: string): Promise<Deno.DirEntry[]> {
|
||||
await ensureUserPathIsValidAndSecurelyAccessible(userId, path);
|
||||
|
||||
const rootPath = join(await AppConfig.getFilesRootPath(), userId, path);
|
||||
|
||||
// Ensure the user directory exists
|
||||
if (path === '/') {
|
||||
try {
|
||||
await Deno.stat(rootPath);
|
||||
} catch (error) {
|
||||
if ((error as Error).toString().includes('NotFound')) {
|
||||
await Deno.mkdir(join(rootPath, TRASH_PATH), { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the Notes or Photos directories exist, if being requested
|
||||
if (path === '/Notes/' || path === '/Photos/') {
|
||||
try {
|
||||
await Deno.stat(rootPath);
|
||||
} catch (error) {
|
||||
if ((error as Error).toString().includes('NotFound')) {
|
||||
await Deno.mkdir(rootPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const entries: Deno.DirEntry[] = [];
|
||||
|
||||
for await (const dirEntry of Deno.readDir(rootPath)) {
|
||||
entries.push(dirEntry);
|
||||
}
|
||||
|
||||
entries.sort(sortEntriesByName);
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function renameDirectoryOrFile(
|
||||
userId: string,
|
||||
oldPath: string,
|
||||
newPath: string,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): Promise<boolean> {
|
||||
await ensureUserPathIsValidAndSecurelyAccessible(userId, join(oldPath, oldName));
|
||||
await ensureUserPathIsValidAndSecurelyAccessible(userId, join(newPath, newName));
|
||||
|
||||
const oldRootPath = join(await AppConfig.getFilesRootPath(), userId, oldPath);
|
||||
const newRootPath = join(await AppConfig.getFilesRootPath(), userId, newPath);
|
||||
|
||||
try {
|
||||
await Deno.rename(join(oldRootPath, oldName), join(newRootPath, newName));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function deleteDirectoryOrFile(userId: string, path: string, name: string): Promise<boolean> {
|
||||
await ensureUserPathIsValidAndSecurelyAccessible(userId, join(path, name));
|
||||
|
||||
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 });
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function searchFilesAndDirectories(
|
||||
userId: string,
|
||||
searchTerm: string,
|
||||
): Promise<{ success: boolean; directories: Directory[]; files: DirectoryFile[] }> {
|
||||
const directoryNamesResult = await DirectoryModel.searchNames(userId, searchTerm);
|
||||
const fileNamesResult = await FileModel.searchNames(userId, searchTerm);
|
||||
const fileContentsResult = await FileModel.searchContents(userId, searchTerm);
|
||||
|
||||
const success = directoryNamesResult.success && fileNamesResult.success && fileContentsResult.success;
|
||||
|
||||
const directories = [...directoryNamesResult.directories];
|
||||
directories.sort(sortDirectoriesByName);
|
||||
|
||||
const files = [...fileNamesResult.files, ...fileContentsResult.files];
|
||||
files.sort(sortFilesByName);
|
||||
|
||||
return {
|
||||
success,
|
||||
directories,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPathInfo(userId: string, path: string): Promise<{ isDirectory: boolean; isFile: boolean }> {
|
||||
await ensureUserPathIsValidAndSecurelyAccessible(userId, path);
|
||||
|
||||
const rootPath = join(await AppConfig.getFilesRootPath(), userId);
|
||||
|
||||
const stat = await Deno.stat(join(rootPath, path));
|
||||
|
||||
return {
|
||||
isDirectory: stat.isDirectory,
|
||||
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;
|
||||
}
|
||||
}
|
||||
158
lib/models/multi-factor-auth.ts
Normal file
158
lib/models/multi-factor-auth.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import { Cookie, getCookies, setCookie } from '@std/http';
|
||||
|
||||
import { MultiFactorAuthMethod, User } from '/lib/types.ts';
|
||||
import {
|
||||
getEnabledMultiFactorAuthMethodsFromUser,
|
||||
getMultiFactorAuthMethodByIdFromUser,
|
||||
} from '/public/ts/utils/multi-factor-auth.ts';
|
||||
import {
|
||||
COOKIE_NAME as AUTH_COOKIE_NAME,
|
||||
generateKey,
|
||||
generateToken,
|
||||
JWT_SECRET,
|
||||
JwtData,
|
||||
resolveCookieDomain,
|
||||
verifyAuthJwt,
|
||||
} from '/lib/auth.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';
|
||||
|
||||
const COOKIE_NAME = `${AUTH_COOKIE_NAME}-mfa`;
|
||||
const MFA_SESSION_ID = 'mfa';
|
||||
|
||||
export interface MultiFactorAuthSetup {
|
||||
method: MultiFactorAuthMethod;
|
||||
qrCodeUrl?: string;
|
||||
plainTextSecret?: string;
|
||||
plainTextBackupCodes?: string[];
|
||||
}
|
||||
|
||||
export class MultiFactorAuthModel {
|
||||
static generateMethodId(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
|
||||
return Array.from(bytes)
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
static enableMethodForUser(
|
||||
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> },
|
||||
methodId: string,
|
||||
): void {
|
||||
const method = getMultiFactorAuthMethodByIdFromUser(user, methodId);
|
||||
if (method) {
|
||||
method.enabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
static disableMethodFromUser(
|
||||
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> },
|
||||
methodId: string,
|
||||
): void {
|
||||
const method = getMultiFactorAuthMethodByIdFromUser(user, methodId);
|
||||
if (method) {
|
||||
method.enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
static async createSessionResponse(
|
||||
request: Request,
|
||||
user: User,
|
||||
{ urlToRedirectTo = '/' }: {
|
||||
urlToRedirectTo?: string;
|
||||
} = {},
|
||||
) {
|
||||
const response = new Response('MFA Required', {
|
||||
status: 303,
|
||||
headers: {
|
||||
'Location': `/mfa-verify?user=${user.id}&redirect=${encodeURIComponent(urlToRedirectTo)}`,
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const enabledMultiFactorAuthMethods = getEnabledMultiFactorAuthMethodsFromUser(user);
|
||||
|
||||
const emailMethod = enabledMultiFactorAuthMethods.find((method) => method.type === 'email');
|
||||
|
||||
if (emailMethod) {
|
||||
await EmailModel.createAndSendCode(emailMethod.id, user);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
const responseWithCookie = await this.createSessionCookie(request, user, response);
|
||||
|
||||
return responseWithCookie;
|
||||
}
|
||||
|
||||
private static async createSessionCookie(
|
||||
request: Request,
|
||||
user: User,
|
||||
response: Response,
|
||||
) {
|
||||
const token = await generateToken({ user_id: user.id, session_id: MFA_SESSION_ID });
|
||||
|
||||
const cookie: Cookie = {
|
||||
name: COOKIE_NAME,
|
||||
value: token,
|
||||
expires: new Date(Date.now() + 1000 * 60 * 30), // 30 minutes
|
||||
path: '/',
|
||||
secure: isRunningLocally(request) ? false : true,
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax',
|
||||
domain: await resolveCookieDomain(request),
|
||||
};
|
||||
|
||||
if (await AppConfig.isCookieDomainSecurityDisabled()) {
|
||||
delete cookie.domain;
|
||||
}
|
||||
|
||||
setCookie(response.headers, cookie);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
static async getDataFromRequest(request: Request): Promise<{ user: User } | null> {
|
||||
const cookies = getCookies(request.headers);
|
||||
|
||||
if (cookies[COOKIE_NAME]) {
|
||||
const result = await this.getDataFromCookie(cookies[COOKIE_NAME]);
|
||||
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async getDataFromCookie(cookieValue: string): Promise<{ user: User } | null> {
|
||||
if (!cookieValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const key = await generateKey(JWT_SECRET);
|
||||
|
||||
try {
|
||||
const token = await verifyAuthJwt(key, cookieValue) as JwtData;
|
||||
|
||||
const user = await UserModel.getById(token.data.user_id);
|
||||
|
||||
if (!user || token.data.session_id !== MFA_SESSION_ID) {
|
||||
throw new Error('Not Found');
|
||||
}
|
||||
|
||||
return { user };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
50
lib/models/multi-factor-auth/email.ts
Normal file
50
lib/models/multi-factor-auth/email.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { MultiFactorAuthMethod, User } from '/lib/types.ts';
|
||||
import { MultiFactorAuthSetup } from '/lib/models/multi-factor-auth.ts';
|
||||
import { VerificationCodeModel } from '/lib/models/user.ts';
|
||||
import { EmailModel as EmailTransportModel } from '/lib/models/email.ts';
|
||||
|
||||
export class EmailModel {
|
||||
static async createMethod(
|
||||
id: string,
|
||||
name: string,
|
||||
user: User,
|
||||
): Promise<MultiFactorAuthSetup> {
|
||||
const method: MultiFactorAuthMethod = {
|
||||
type: 'email',
|
||||
id,
|
||||
name,
|
||||
enabled: false,
|
||||
created_at: new Date(),
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
await this.createAndSendCode(id, user);
|
||||
|
||||
return {
|
||||
method,
|
||||
};
|
||||
}
|
||||
|
||||
static async createAndSendCode(
|
||||
id: string,
|
||||
user: User,
|
||||
): Promise<void> {
|
||||
const code = await VerificationCodeModel.create(user, `${user.email}-${id}`, 'email');
|
||||
|
||||
await EmailTransportModel.sendLoginVerificationEmail(user.email, code);
|
||||
}
|
||||
|
||||
static async verifyCode(
|
||||
methodId: string,
|
||||
code: string,
|
||||
user: User,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
await VerificationCodeModel.validate(user, `${user.email}-${methodId}`, code, 'email');
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
198
lib/models/multi-factor-auth/passkey.ts
Normal file
198
lib/models/multi-factor-auth/passkey.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import {
|
||||
AuthenticationResponseJSON,
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
PublicKeyCredentialCreationOptionsJSON,
|
||||
RegistrationResponseJSON,
|
||||
VerifiedAuthenticationResponse,
|
||||
VerifiedRegistrationResponse,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import { isoBase64URL } from '@simplewebauthn/server/helpers';
|
||||
|
||||
import { MultiFactorAuthMethod, User } from '/lib/types.ts';
|
||||
|
||||
export interface PasskeyCredential {
|
||||
credentialID: string;
|
||||
credentialPublicKey: string;
|
||||
counter: number;
|
||||
credentialDeviceType: string;
|
||||
credentialBackedUp: boolean;
|
||||
transports?: AuthenticatorTransport[];
|
||||
}
|
||||
|
||||
export interface PasskeySetupData {
|
||||
methodId: string;
|
||||
options: PublicKeyCredentialCreationOptionsJSON;
|
||||
}
|
||||
|
||||
export interface PasskeyAuthenticationData {
|
||||
options: PublicKeyCredentialCreationOptionsJSON;
|
||||
}
|
||||
|
||||
const RP_NAME = 'bewCloud';
|
||||
const RP_ID = (baseUrl: string) => {
|
||||
try {
|
||||
return new URL(baseUrl).hostname;
|
||||
} catch {
|
||||
return 'localhost';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Excludes Ed25519 as per https://simplewebauthn.dev/docs/packages/server#domexception-notsupportederror-unrecognized-name
|
||||
*/
|
||||
const SUPPORTED_ALGORITHM_IDS = [-7, -257];
|
||||
|
||||
export class PasskeyModel {
|
||||
static async generateRegistrationOptions(
|
||||
userId: string,
|
||||
email: string,
|
||||
baseUrl: string,
|
||||
existingCredentials: PasskeyCredential[] = [],
|
||||
): Promise<PublicKeyCredentialCreationOptionsJSON> {
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName: RP_NAME,
|
||||
rpID: RP_ID(baseUrl),
|
||||
userID: new TextEncoder().encode(userId),
|
||||
userName: email,
|
||||
userDisplayName: email,
|
||||
attestationType: 'none',
|
||||
excludeCredentials: existingCredentials.map((credential) => ({
|
||||
id: credential.credentialID,
|
||||
type: 'public-key',
|
||||
transports: credential.transports || [],
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
residentKey: 'preferred',
|
||||
userVerification: 'preferred',
|
||||
authenticatorAttachment: 'platform',
|
||||
},
|
||||
supportedAlgorithmIDs: SUPPORTED_ALGORITHM_IDS,
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
static async verifyRegistration(
|
||||
response: RegistrationResponseJSON,
|
||||
expectedChallenge: string,
|
||||
expectedOrigin: string,
|
||||
expectedRPID: string,
|
||||
): Promise<VerifiedRegistrationResponse> {
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin,
|
||||
expectedRPID,
|
||||
supportedAlgorithmIDs: SUPPORTED_ALGORITHM_IDS,
|
||||
});
|
||||
|
||||
return verification;
|
||||
}
|
||||
|
||||
static async generateAuthenticationOptions(
|
||||
baseUrl: string,
|
||||
allowedCredentials?: PasskeyCredential[],
|
||||
): Promise<PublicKeyCredentialCreationOptionsJSON> {
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID: RP_ID(baseUrl),
|
||||
allowCredentials: allowedCredentials?.map((credential) => ({
|
||||
id: credential.credentialID,
|
||||
type: 'public-key',
|
||||
transports: credential.transports,
|
||||
})),
|
||||
userVerification: 'preferred',
|
||||
});
|
||||
|
||||
return options as PublicKeyCredentialCreationOptionsJSON;
|
||||
}
|
||||
|
||||
static async verifyAuthentication(
|
||||
response: AuthenticationResponseJSON,
|
||||
expectedChallenge: string,
|
||||
expectedOrigin: string,
|
||||
expectedRPID: string,
|
||||
credential: PasskeyCredential,
|
||||
): Promise<VerifiedAuthenticationResponse> {
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
expectedChallenge,
|
||||
expectedOrigin,
|
||||
expectedRPID,
|
||||
credential: {
|
||||
id: credential.credentialID,
|
||||
publicKey: isoBase64URL.toBuffer(credential.credentialPublicKey),
|
||||
counter: credential.counter,
|
||||
transports: credential.transports,
|
||||
},
|
||||
});
|
||||
|
||||
return verification;
|
||||
}
|
||||
|
||||
static createMethod(
|
||||
id: string,
|
||||
name: string,
|
||||
credentialID: string,
|
||||
credentialPublicKey: string,
|
||||
counter: number,
|
||||
credentialDeviceType: string,
|
||||
credentialBackedUp: boolean,
|
||||
transports?: AuthenticatorTransport[],
|
||||
): MultiFactorAuthMethod {
|
||||
return {
|
||||
type: 'passkey',
|
||||
id,
|
||||
name,
|
||||
enabled: false,
|
||||
created_at: new Date(),
|
||||
metadata: {
|
||||
passkey: {
|
||||
credential_id: credentialID,
|
||||
public_key: credentialPublicKey,
|
||||
counter,
|
||||
device_type: credentialDeviceType,
|
||||
backed_up: credentialBackedUp,
|
||||
transports,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static getCredentialsFromUser(
|
||||
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> },
|
||||
): PasskeyCredential[] {
|
||||
if (!user.extra.multi_factor_auth_methods) return [];
|
||||
|
||||
return user.extra.multi_factor_auth_methods
|
||||
.filter((method) => method.type === 'passkey' && method.enabled && method.metadata.passkey)
|
||||
.map((method) => ({
|
||||
credentialID: method.metadata.passkey!.credential_id,
|
||||
credentialPublicKey: method.metadata.passkey!.public_key,
|
||||
counter: method.metadata.passkey!.counter || 0,
|
||||
credentialDeviceType: method.metadata.passkey!.device_type || 'unknown',
|
||||
credentialBackedUp: method.metadata.passkey!.backed_up || false,
|
||||
transports: method.metadata.passkey!.transports,
|
||||
}));
|
||||
}
|
||||
|
||||
static updateCounterForUser(
|
||||
user: { extra: Pick<User['extra'], 'multi_factor_auth_methods'> },
|
||||
credentialID: string,
|
||||
newCounter: number,
|
||||
): void {
|
||||
if (!user.extra.multi_factor_auth_methods) {
|
||||
return;
|
||||
}
|
||||
|
||||
const method = user.extra.multi_factor_auth_methods.find(
|
||||
(method) => method.type === 'passkey' && method.metadata.passkey?.credential_id === credentialID,
|
||||
);
|
||||
|
||||
if (method?.metadata.passkey) {
|
||||
method.metadata.passkey.counter = newCounter;
|
||||
}
|
||||
}
|
||||
}
|
||||
227
lib/models/multi-factor-auth/totp.ts
Normal file
227
lib/models/multi-factor-auth/totp.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import { Secret, TOTP } from 'otpauth';
|
||||
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 '/public/ts/utils/misc.ts';
|
||||
import { MultiFactorAuthSetup } from '/lib/models/multi-factor-auth.ts';
|
||||
|
||||
export class TOTPModel {
|
||||
private static async getEncryptionKey(): Promise<CryptoKey> {
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(MFA_KEY),
|
||||
{ name: 'PBKDF2' },
|
||||
false,
|
||||
['deriveKey'],
|
||||
);
|
||||
|
||||
return await crypto.subtle.deriveKey(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: new TextEncoder().encode(MFA_SALT),
|
||||
iterations: 100000,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
private static generateBackupCodes(count = 8): string[] {
|
||||
const codes: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const bytes = new Uint8Array(4);
|
||||
crypto.getRandomValues(bytes);
|
||||
const code = Array.from(bytes)
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
.substring(0, 8);
|
||||
codes.push(code);
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
|
||||
private static async hashBackupCodes(codes: string[]): Promise<string[]> {
|
||||
const hashedCodes: string[] = [];
|
||||
for (const code of codes) {
|
||||
const hashedCode = await generateHash(`${code}:${MFA_SALT}`, 'SHA-256');
|
||||
hashedCodes.push(hashedCode);
|
||||
}
|
||||
return hashedCodes;
|
||||
}
|
||||
|
||||
private static async verifyBackupCodeHash(
|
||||
code: string,
|
||||
hashedCodes: string[],
|
||||
): Promise<{ isValid: boolean; codeIndex: number }> {
|
||||
const hashedInput = await generateHash(`${code}:${MFA_SALT}`, 'SHA-256');
|
||||
const codeIndex = hashedCodes.indexOf(hashedInput);
|
||||
return { isValid: codeIndex !== -1, codeIndex };
|
||||
}
|
||||
|
||||
private static async verifyBackupCodeHashed(
|
||||
hashedBackupCodes: string[],
|
||||
providedCode: string,
|
||||
): Promise<{ isValid: boolean; remainingCodes: string[] }> {
|
||||
const { isValid, codeIndex } = await this.verifyBackupCodeHash(providedCode, hashedBackupCodes);
|
||||
|
||||
if (!isValid) {
|
||||
return { isValid: false, remainingCodes: hashedBackupCodes };
|
||||
}
|
||||
|
||||
const remainingCodes = [...hashedBackupCodes];
|
||||
remainingCodes.splice(codeIndex, 1);
|
||||
|
||||
return { isValid: true, remainingCodes };
|
||||
}
|
||||
|
||||
private static async encryptTOTPSecret(secret: string): Promise<string> {
|
||||
const key = await this.getEncryptionKey();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const encodedSecret = new TextEncoder().encode(secret);
|
||||
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
encodedSecret,
|
||||
);
|
||||
|
||||
const combined = new Uint8Array(iv.length + encrypted.byteLength);
|
||||
combined.set(iv);
|
||||
combined.set(new Uint8Array(encrypted), iv.length);
|
||||
|
||||
return encodeBase64(combined);
|
||||
}
|
||||
|
||||
static async decryptTOTPSecret(encryptedSecret: string): Promise<string> {
|
||||
const key = await this.getEncryptionKey();
|
||||
const combined = decodeBase64(encryptedSecret);
|
||||
const iv = combined.slice(0, 12);
|
||||
const encrypted = combined.slice(12);
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
encrypted,
|
||||
);
|
||||
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
|
||||
private static generateTOTPSecret(): string {
|
||||
const bytes = new Uint8Array(20);
|
||||
crypto.getRandomValues(bytes);
|
||||
return encodeBase32(bytes);
|
||||
}
|
||||
|
||||
private static createTOTP(secret: string, issuer: string, accountName: string): TOTP {
|
||||
return new TOTP({
|
||||
issuer,
|
||||
label: accountName,
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: Secret.fromBase32(secret),
|
||||
});
|
||||
}
|
||||
|
||||
private static async generateQRCodeDataURL(secret: string, issuer: string, accountName: string): Promise<string> {
|
||||
const totp = this.createTOTP(secret, issuer, accountName);
|
||||
const uri = totp.toString();
|
||||
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 {
|
||||
const totp = new TOTP({
|
||||
algorithm: 'SHA1',
|
||||
digits: 6,
|
||||
period: 30,
|
||||
secret: Secret.fromBase32(secret),
|
||||
});
|
||||
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
|
||||
for (let i = -window; i <= window; i++) {
|
||||
const testTime = currentTime + (i * 30);
|
||||
const expectedToken = totp.generate({ timestamp: testTime * 1000 });
|
||||
if (expectedToken === token) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static async createMethod(
|
||||
id: string,
|
||||
name: string,
|
||||
issuer: string,
|
||||
accountName: string,
|
||||
): Promise<MultiFactorAuthSetup> {
|
||||
const secret = this.generateTOTPSecret();
|
||||
const backupCodes = this.generateBackupCodes();
|
||||
const qrCodeUrl = await this.generateQRCodeDataURL(secret, issuer, accountName);
|
||||
|
||||
const encryptedSecret = await this.encryptTOTPSecret(secret);
|
||||
const hashedBackupCodes = await this.hashBackupCodes(backupCodes);
|
||||
|
||||
const method: MultiFactorAuthMethod = {
|
||||
type: 'totp',
|
||||
id,
|
||||
name,
|
||||
enabled: false,
|
||||
created_at: new Date(),
|
||||
metadata: {
|
||||
totp: {
|
||||
hashed_secret: encryptedSecret,
|
||||
hashed_backup_codes: hashedBackupCodes,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
method,
|
||||
qrCodeUrl,
|
||||
plainTextSecret: secret,
|
||||
plainTextBackupCodes: backupCodes,
|
||||
};
|
||||
}
|
||||
|
||||
static async verifyMethodToken(
|
||||
metadata: MultiFactorAuthMethod['metadata'],
|
||||
token: string,
|
||||
): Promise<{ isValid: boolean; remainingCodes?: string[] }> {
|
||||
if (!metadata.totp) {
|
||||
return { isValid: false };
|
||||
}
|
||||
|
||||
const { totp } = metadata;
|
||||
|
||||
if (token.length === 6 && /^\d+$/.test(token)) { // Try the TOTP first
|
||||
try {
|
||||
const decryptedSecret = await this.decryptTOTPSecret(totp.hashed_secret);
|
||||
const isValid = this.verifyTOTPToken(decryptedSecret, token);
|
||||
return { isValid };
|
||||
} catch {
|
||||
return { isValid: false };
|
||||
}
|
||||
} else if (token.length === 8 && /^[a-fA-F0-9]+$/.test(token)) { // Otherwise, try the backup codes
|
||||
const { isValid, remainingCodes } = await this.verifyBackupCodeHashed(
|
||||
totp.hashed_backup_codes,
|
||||
token.toLowerCase(),
|
||||
);
|
||||
return { isValid, remainingCodes };
|
||||
}
|
||||
|
||||
return { isValid: false };
|
||||
}
|
||||
|
||||
static verifyTOTP(secret: string, token: string): boolean {
|
||||
return this.verifyTOTPToken(secret, token);
|
||||
}
|
||||
}
|
||||
332
lib/models/news.ts
Normal file
332
lib/models/news.ts
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import { Feed } from '@mikaelporttila/rss';
|
||||
|
||||
import Database, { sql } from '/lib/interfaces/database.ts';
|
||||
import Locker from '/lib/interfaces/locker.ts';
|
||||
import { NewsFeed, NewsFeedArticle } from '/lib/types.ts';
|
||||
import {
|
||||
findFeedInUrl,
|
||||
getArticleUrl,
|
||||
getFeedInfo,
|
||||
JsonFeed,
|
||||
parseTextFromHtml,
|
||||
parseUrl,
|
||||
parseUrlAsGooglebot,
|
||||
parseUrlWithProxy,
|
||||
} from '/lib/feed.ts';
|
||||
|
||||
const db = new Database();
|
||||
|
||||
export class FeedModel {
|
||||
static async list(userId: string) {
|
||||
const newsFeeds = await db.query<NewsFeed>(sql`SELECT * FROM "bewcloud_news_feeds" WHERE "user_id" = $1`, [
|
||||
userId,
|
||||
]);
|
||||
|
||||
return newsFeeds;
|
||||
}
|
||||
|
||||
static async get(id: string, userId: string) {
|
||||
const newsFeeds = await db.query<NewsFeed>(
|
||||
sql`SELECT * FROM "bewcloud_news_feeds" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return newsFeeds[0];
|
||||
}
|
||||
|
||||
static async create(userId: string, feedUrl: string) {
|
||||
const extra: NewsFeed['extra'] = {};
|
||||
|
||||
const newNewsFeed = (await db.query<NewsFeed>(
|
||||
sql`INSERT INTO "bewcloud_news_feeds" (
|
||||
"user_id",
|
||||
"feed_url",
|
||||
"extra"
|
||||
) VALUES ($1, $2, $3)
|
||||
RETURNING *`,
|
||||
[
|
||||
userId,
|
||||
feedUrl,
|
||||
JSON.stringify(extra),
|
||||
],
|
||||
))[0];
|
||||
|
||||
return newNewsFeed;
|
||||
}
|
||||
|
||||
static async update(newsFeed: NewsFeed) {
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_news_feeds" SET
|
||||
"feed_url" = $2,
|
||||
"last_crawled_at" = $3,
|
||||
"extra" = $4
|
||||
WHERE "id" = $1`,
|
||||
[
|
||||
newsFeed.id,
|
||||
newsFeed.feed_url,
|
||||
newsFeed.last_crawled_at,
|
||||
JSON.stringify(newsFeed.extra),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static async delete(id: string, userId: string) {
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_news_feed_articles" WHERE "feed_id" = $1 AND "user_id" = $2`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
await db.query(
|
||||
sql`DELETE FROM "bewcloud_news_feeds" WHERE "id" = $1 AND "user_id" = $2`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static async crawl(newsFeed: NewsFeed) {
|
||||
type FeedArticle = Feed['entries'][number];
|
||||
type JsonFeedArticle = JsonFeed['items'][number];
|
||||
|
||||
const MAX_ARTICLES_CRAWLED_PER_RUN = 10;
|
||||
|
||||
const lock = new Locker(`feeds:${newsFeed.id}`);
|
||||
|
||||
await lock.acquire();
|
||||
|
||||
try {
|
||||
if (!newsFeed.extra.title || !newsFeed.extra.feed_type || !newsFeed.extra.crawl_type) {
|
||||
const feedUrl = await findFeedInUrl(newsFeed.feed_url);
|
||||
|
||||
if (!feedUrl) {
|
||||
throw new Error(
|
||||
`Invalid URL for feed: "${feedUrl}"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (feedUrl !== newsFeed.feed_url) {
|
||||
newsFeed.feed_url = feedUrl;
|
||||
}
|
||||
|
||||
const feedInfo = await getFeedInfo(newsFeed.feed_url);
|
||||
|
||||
newsFeed.extra.title = feedInfo.title;
|
||||
newsFeed.extra.feed_type = feedInfo.feed_type;
|
||||
newsFeed.extra.crawl_type = feedInfo.crawl_type;
|
||||
}
|
||||
|
||||
const feedArticles = await fetchNewsArticles(newsFeed);
|
||||
|
||||
const articles: Omit<NewsFeedArticle, 'id' | 'user_id' | 'feed_id' | 'extra' | 'is_read' | 'created_at'>[] = [];
|
||||
|
||||
for (const feedArticle of feedArticles) {
|
||||
// Don't add too many articles per run
|
||||
if (articles.length >= MAX_ARTICLES_CRAWLED_PER_RUN) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
const articleDate = articleIsoDate ? new Date(articleIsoDate) : new Date();
|
||||
|
||||
const summary = await parseTextFromHtml(
|
||||
(feedArticle as FeedArticle).description?.value || (feedArticle as FeedArticle).content?.value ||
|
||||
(feedArticle as JsonFeedArticle).content_text || (feedArticle as JsonFeedArticle).content_html ||
|
||||
(feedArticle as JsonFeedArticle).summary || '',
|
||||
);
|
||||
|
||||
if (url) {
|
||||
articles.push({
|
||||
article_title: (feedArticle as FeedArticle).title?.value || (feedArticle as JsonFeedArticle).title ||
|
||||
url.replace('http://', '').replace('https://', ''),
|
||||
article_url: url,
|
||||
article_summary: summary,
|
||||
article_date: articleDate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const existingArticles = await ArticleModel.listByFeedId(newsFeed.id);
|
||||
const existingArticleUrls = new Set<string>(existingArticles.map((article) => article.article_url));
|
||||
const previousLatestArticleUrl = existingArticles[0]?.article_url;
|
||||
let seenPreviousLatestArticleUrl = false;
|
||||
let addedArticlesCount = 0;
|
||||
|
||||
for (const article of articles) {
|
||||
// Stop looking after seeing the previous latest article
|
||||
if (article.article_url === previousLatestArticleUrl) {
|
||||
seenPreviousLatestArticleUrl = true;
|
||||
}
|
||||
|
||||
if (!seenPreviousLatestArticleUrl && !existingArticleUrls.has(article.article_url)) {
|
||||
try {
|
||||
await ArticleModel.create(newsFeed.user_id, newsFeed.id, article);
|
||||
++addedArticlesCount;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
console.error(`Failed to add new article: "${article.article_url}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.info('Added', addedArticlesCount, 'new articles');
|
||||
|
||||
newsFeed.last_crawled_at = new Date();
|
||||
|
||||
await this.update(newsFeed);
|
||||
|
||||
lock.release();
|
||||
} catch (error) {
|
||||
lock.release();
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ArticleModel {
|
||||
static async list(userId: string) {
|
||||
const articles = await db.query<NewsFeedArticle>(
|
||||
sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "user_id" = $1 ORDER BY "article_date" DESC`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
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`,
|
||||
[
|
||||
feedId,
|
||||
],
|
||||
);
|
||||
|
||||
return articles;
|
||||
}
|
||||
|
||||
static async get(id: string, userId: string) {
|
||||
const articles = await db.query<NewsFeedArticle>(
|
||||
sql`SELECT * FROM "bewcloud_news_feed_articles" WHERE "id" = $1 AND "user_id" = $2 LIMIT 1`,
|
||||
[
|
||||
id,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
return articles[0];
|
||||
}
|
||||
|
||||
static async create(
|
||||
userId: string,
|
||||
feedId: string,
|
||||
article: Omit<NewsFeedArticle, 'id' | 'user_id' | 'feed_id' | 'extra' | 'is_read' | 'created_at'>,
|
||||
) {
|
||||
const extra: NewsFeedArticle['extra'] = {};
|
||||
|
||||
const newNewsArticle = (await db.query<NewsFeedArticle>(
|
||||
sql`INSERT INTO "bewcloud_news_feed_articles" (
|
||||
"user_id",
|
||||
"feed_id",
|
||||
"article_url",
|
||||
"article_title",
|
||||
"article_summary",
|
||||
"article_date",
|
||||
"extra"
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *`,
|
||||
[
|
||||
userId,
|
||||
feedId,
|
||||
article.article_url,
|
||||
article.article_title,
|
||||
article.article_summary,
|
||||
article.article_date,
|
||||
JSON.stringify(extra),
|
||||
],
|
||||
))[0];
|
||||
|
||||
return newNewsArticle;
|
||||
}
|
||||
|
||||
static async update(article: NewsFeedArticle) {
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_news_feed_articles" SET
|
||||
"is_read" = $2,
|
||||
"extra" = $3
|
||||
WHERE "id" = $1`,
|
||||
[
|
||||
article.id,
|
||||
article.is_read,
|
||||
JSON.stringify(article.extra),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
static async markAllRead(userId: string) {
|
||||
await db.query(
|
||||
sql`UPDATE "bewcloud_news_feed_articles" SET
|
||||
"is_read" = TRUE
|
||||
WHERE "user_id" = $1`,
|
||||
[
|
||||
userId,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNewsArticles(newsFeed: NewsFeed): Promise<Feed['entries'] | JsonFeed['items']> {
|
||||
try {
|
||||
if (!newsFeed.extra.title || !newsFeed.extra.feed_type || !newsFeed.extra.crawl_type) {
|
||||
throw new Error('Invalid News Feed!');
|
||||
}
|
||||
|
||||
let feed: JsonFeed | Feed | null = null;
|
||||
|
||||
if (newsFeed.extra.crawl_type === 'direct') {
|
||||
feed = await parseUrl(newsFeed.feed_url);
|
||||
} else if (newsFeed.extra.crawl_type === 'googlebot') {
|
||||
feed = await parseUrlAsGooglebot(newsFeed.feed_url);
|
||||
} else if (newsFeed.extra.crawl_type === 'proxy') {
|
||||
feed = await parseUrlWithProxy(newsFeed.feed_url);
|
||||
}
|
||||
|
||||
return (feed as Feed)?.entries || (feed as JsonFeed)?.items || [];
|
||||
} catch (error) {
|
||||
console.error('Failed parsing feed to get articles', newsFeed.feed_url);
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue