diff --git a/.env.ci b/.env.ci
deleted file mode 100644
index 08d2fcd..0000000
--- a/.env.ci
+++ /dev/null
@@ -1,19 +0,0 @@
-############
-#### CI ####
-############
-# DB
-DATABASE_URL="postgres://admin:pass123@localhost:6500/sk-db"
-# Prisma: Data Proxy
-DATA_PROXY="" # Only used in Prod builds
-
-# REDIS
-REDIS_URL="redis://default:redispw@localhost:6379"
-REDIS_TOKEN="" # Only used in Prod builds
-
-# Images: Cloudflare
-IMAGE_API_TOKEN="" # Only used in Prod builds
-IMAGE_API="http://localhost:6501/image"
-PUBLIC_IMAGE_DELIVERY="http://localhost:6501/image"
-
-# CI Flag
-IS_CI=true
diff --git a/.env.development b/.env.development
index 806813e..ac18842 100644
--- a/.env.development
+++ b/.env.development
@@ -2,19 +2,15 @@
### DEV ###
############
# DB
-DATABASE_URL="postgres://admin:pass123@localhost:6500/cloudkit-db"
-DATA_PROXY="" # Only used in Prod builds
-
-# REDIS
-REDIS_URL="redis://default:redispw@localhost:6379"
-REDIS_TOKEN="" # Only used in Prod builds
-
-# Images: Cloudflare
+DATABASE_URL="postgres://admin:pass123@localhost:7500/cloudkit-db"
+RATE_LIMIT="ngDPzgdE2apsLdSXLLnQYjbgNkt8ODeX36mq1hnK3NcfmVYwvMrqqkKgHLHNcLdjkcjdaIK9yoAzx0NLf5GLPfYVHbcfWCt83aSet88kgxkAhySGBCwvVuJE4aSRKtOd"
+# Images with docker-compose for local development
+PUBLIC_IMAGE_API_URL="http://localhost:7501/image"
IMAGE_API_TOKEN="" # Only used in Prod builds
-IMAGE_API="http://localhost:6501/image"
-PUBLIC_IMAGE_DELIVERY="http://localhost:6501/image"
+IMAGE_API_ACCOUNT_IDENTIFIER="" # Only used in Prod builds
+
IS_CI=false
# prisma seed variables
-SEED_DEV=true
+SEED_DEV=true
\ No newline at end of file
diff --git a/.env.example b/.env.example
index 4424345..0b2e9de 100644
--- a/.env.example
+++ b/.env.example
@@ -2,7 +2,7 @@
### DEV ###
############
# DB
-DATABASE_URL="postgres://admin:pass123@localhost:6500/cloudkit-db"
+DATABASE_URL="postgres://admin:pass123@localhost:6500/sk-db"
DATA_PROXY="" # Only used in Prod builds
# REDIS
@@ -10,8 +10,8 @@ REDIS_URL="redis://default:redispw@localhost:6379"
REDIS_TOKEN="" # Only used in Prod builds
# Images: Cloudflare
-IMAGE_API_TOKEN="" # Only used in Prod builds
-IMAGE_API="http://localhost:6501/image"
+PUBLIC_IMAGE_API_TOKEN="" # Only used in Prod builds
+PUBLIC_IMAGE_API="http://localhost:6501/image"
PUBLIC_IMAGE_DELIVERY="http://localhost:6501/image"
IS_CI=false
diff --git a/.eslintignore b/.eslintignore
index 345fcc8..6d30b9b 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -1,17 +1 @@
-.DS_Store
-node_modules
-/build
-/.svelte-kit
-/package
-.env
-.env.*
-!.env.example
-prisma/seed.js
-# Ignore files for PNPM, NPM and YARN
-pnpm-lock.yaml
-package-lock.json
-yarn.lock
-prisma
-db
-**/*.postcss
-*.MD
+packages/eslint-config
diff --git a/.github/workflows/MERGE_MASTER.yml b/.github/workflows/MERGE_MASTER.yml
index 78f9a34..91008f2 100644
--- a/.github/workflows/MERGE_MASTER.yml
+++ b/.github/workflows/MERGE_MASTER.yml
@@ -9,32 +9,52 @@ on:
schedule:
- cron: '0 5 * * *'
env:
- DATABASE_URL: ${{ secrets.DATABASE_URL }}
- DIRECT_DATABASE_URL: ${{ secrets.DIRECT_DATABASE_URL }}
- REDIS_TOKEN: ${{ secrets.REDIS_TOKEN }}
- REDIS_URL: ${{ secrets.REDIS_URL }}
- IMAGE_API_TOKEN: ${{ secrets.IMAGE_API_TOKEN }}
- IMAGE_API: ${{ secrets.IMAGE_API }}
- PUBLIC_IMAGE_DELIVERY: ${{ secrets.PUBLIC_IMAGE_DELIVERY }}
- IS_CI: true
+ DIRECT_DATABASE_URL: ${{secrets.DIRECT_DATABASE_URL}}
+ DATABASE_URL: ${{secrets.DATABASE_URL}}
+ PUBLIC_IMAGE_API_URL: ${{secrets.PUBLIC_IMAGE_API_URL}}
+ IMAGE_API_TOKEN: ${{secrets.IMAGE_API_TOKEN}}
+ IMAGE_API_ACCOUNT_IDENTIFIER: ${{secrets.IMAGE_API_ACCOUNT_IDENTIFIER}}
+ IS_CI: ${{secrets.IS_CI}}
+ ENVIRONMENT: ${{secrets.ENVIRONMENT}}
+ CLOUDFLARE_ACCOUNT_ID: ${{secrets.CLOUDFLARE_ACCOUNT_ID}}
+ CLOUDFLARE_API_TOKEN: ${{secrets.CLOUDFLARE_API_TOKEN}}
jobs:
- PUBLISH:
- name: Publish to Production
- environment: PRODUCTION
+ INSTALL:
+ name: Install Dependencies
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: pnpm/action-setup@v2
+ - uses: pnpm/action-setup@v3
with:
- version: 8.9.2
+ version: 9.1.1
- uses: actions/setup-node@v3
with:
node-version: '20'
- cache: 'pnpm'
+ - uses: actions/cache@v3
+ id: cache-pnpm
+ with:
+ path: ./node_modules
+ key: ${{ runner.os }}-${{ hashFiles('./pnpm-lock.yaml', './prisma/dev.schema.prisma', './prisma/prod.schema.prisma') }}
- name: Install
+ if: steps.cache-pnpm.outputs.cache-hit != 'true'
run: |
- pnpm install
+ pnpm i --frozen-lockfile
+ pnpm prisma:gen:ci
+ PUBLISH:
+ name: Publish to Production
+ environment: PRODUCTION
+ runs-on: ubuntu-latest
+ needs: INSTALL
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v3
+ with:
+ version: 9.1.1
+ - uses: actions/cache@v3
+ with:
+ path: ./node_modules
+ key: ${{ runner.os }}-${{ hashFiles('./pnpm-lock.yaml', './prisma/dev.schema.prisma', './prisma/prod.schema.prisma') }}
- name: Build
env:
IS_CI: false
@@ -48,7 +68,6 @@ jobs:
projectName: cloudkit
directory: ./.svelte-kit/cloudflare
# Optional: Enable this if you want to have GitHub Deployments triggered
- gitHubToken: ${{ secrets.GITHUB_TOKEN }}
# Optional: Switch what branch you are publishing to.
# By default this will be the branch which triggered this workflow
# branch: master
diff --git a/.github/workflows/PR.yml b/.github/workflows/PR.yml
index ec4db5c..c26b6cf 100644
--- a/.github/workflows/PR.yml
+++ b/.github/workflows/PR.yml
@@ -9,140 +9,226 @@ concurrency:
cancel-in-progress: true
env:
- DATABASE_URL: ${{ secrets.DATABASE_URL }}
- DIRECT_DATABASE_URL: ${{ secrets.DIRECT_DATABASE_URL }}
- REDIS_TOKEN: ${{ secrets.REDIS_TOKEN }}
- REDIS_URL: ${{ secrets.REDIS_URL }}
- IMAGE_API_TOKEN: ${{ secrets.IMAGE_API_TOKEN }}
- IMAGE_API: ${{ secrets.IMAGE_API }}
- PUBLIC_IMAGE_DELIVERY: ${{ secrets.PUBLIC_IMAGE_DELIVERY }}
- IS_CI: true
-
+ DIRECT_DATABASE_URL: ${{secrets.DIRECT_DATABASE_URL}}
+ DATABASE_URL: ${{secrets.DATABASE_URL}}
+ PUBLIC_IMAGE_API_URL: ${{secrets.PUBLIC_IMAGE_API_URL}}
+ IMAGE_API_TOKEN: ${{secrets.IMAGE_API_TOKEN}}
+ IMAGE_API_ACCOUNT_IDENTIFIER: ${{secrets.IMAGE_API_ACCOUNT_IDENTIFIER}}
+ IS_CI: ${{secrets.IS_CI}}
+ ENVIRONMENT: ${{secrets.ENVIRONMENT}}
+ CLOUDFLARE_ACCOUNT_ID: ${{secrets.CLOUDFLARE_ACCOUNT_ID}}
+ CLOUDFLARE_API_TOKEN: ${{secrets.CLOUDFLARE_API_TOKEN}}
jobs:
INSTALL:
name: Install Dependencies
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: pnpm/action-setup@v2
+ - uses: pnpm/action-setup@v3
with:
- version: 8.9.2
+ version: 9.12.1
- uses: actions/setup-node@v3
with:
- node-version: '20'
- cache: 'pnpm'
+ node-version: '22.9.0'
+ - uses: actions/cache@v3
+ id: cache-pnpm
+ with:
+ path: ./node_modules
+ key: ${{ runner.os }}-${{ hashFiles('./pnpm-lock.yaml', '/.npmrc') }}
- name: Install
+ if: steps.cache-pnpm.outputs.cache-hit != 'true'
run: |
- pnpm install
+ pnpm i --frozen-lockfile
+ pnpm sync
LINT:
- name: ESLint & SvelteKit Check
+ name: Lint
runs-on: ubuntu-latest
needs: INSTALL
steps:
- uses: actions/checkout@v4
- - uses: pnpm/action-setup@v2
+ - uses: pnpm/action-setup@v3
with:
- version: 8.9.2
+ version: 9.12.1
- uses: actions/setup-node@v3
with:
- node-version: '20'
- cache: 'pnpm'
- - name: Install
- run: |
- pnpm install
- - name: Prisma Generate
+ node-version: '22.9.0'
+ - uses: actions/cache@v3
+ with:
+ path: ./node_modules
+ key: ${{ runner.os }}-${{ hashFiles('./pnpm-lock.yaml', '/.npmrc') }}
+ - name: Install Module Packages
run: |
- pnpm prisma:gen:ci
+ pnpm i --frozen-lockfile
+ pnpm sync
- name: Lint
run: |
pnpm lint
+ VERIFY:
+ name: Verify
+ runs-on: ubuntu-latest
+ needs: INSTALL
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v3
+ with:
+ version: 9.12.1
+ - uses: actions/setup-node@v3
+ with:
+ node-version: '22.9.0'
+ - uses: actions/cache@v3
+ with:
+ path: ./node_modules
+ key: ${{ runner.os }}-${{ hashFiles('./pnpm-lock.yaml', '/.npmrc') }}
+ - name: Install Module Packages
+ run: |
+ pnpm i --frozen-lockfile
+ pnpm sync
- name: Check
run: |
pnpm check
UNIT_TEST:
- name: Run Unit Tests
+ name: Unit Tests
runs-on: ubuntu-latest
needs: INSTALL
steps:
- uses: actions/checkout@v4
- - uses: pnpm/action-setup@v2
+ - uses: pnpm/action-setup@v3
with:
- version: 8.9.2
+ version: 9.12.1
- uses: actions/setup-node@v3
with:
- node-version: '20'
- cache: 'pnpm'
- - name: Install
- run: |
- pnpm install
- - name: Prisma Generate
+ node-version: '22.9.0'
+ - uses: actions/cache@v3
+ with:
+ path: ./node_modules
+ key: ${{ runner.os }}-${{ hashFiles('./pnpm-lock.yaml', '/.npmrc') }}
+ - name: Install Module Packages
run: |
- pnpm prisma:gen:dev
+ pnpm i --frozen-lockfile
+ pnpm sync
- name: Unit Test
run: |
- pnpm test:unit
- E2E_TEST:
- name: Run E2E Tests
- needs: [INSTALL]
+ pnpm test
+
+ ################ PUBLISH #################
+ PUBLISH_WEBAPP:
+ name: Publish WebApp
+ environment: PREVIEW
runs-on: ubuntu-latest
+ needs: [INSTALL, LINT, VERIFY, UNIT_TEST]
steps:
- uses: actions/checkout@v4
- - name: Set up Services
- run: |
- sudo docker-compose up -d
- - uses: pnpm/action-setup@v2
+ - uses: pnpm/action-setup@v3
with:
- version: 8.9.2
+ version: 9.12.1
- uses: actions/setup-node@v3
with:
- node-version: '20'
- cache: 'pnpm'
- - name: Install
- run: |
- pnpm install
- - name: Prisma Generate
- run: |
- pnpm prisma:gen:ci
- - name: Prisma Push
+ node-version: '22.9.0'
+ - uses: actions/cache@v3
+ with:
+ path: ./node_modules
+ key: ${{ runner.os }}-${{ hashFiles('./pnpm-lock.yaml', '/.npmrc') }}
+ - name: Install Module Packages
run: |
- pnpm prisma:push:dev
- - name: Install Playwright Browsers
+ pnpm i --frozen-lockfile
+ pnpm sync
+ - name: Build
+ env:
+ IS_CI: false
run: |
- pnpm playwright install-deps chromium
- - name: Seed Database
+ pnpm turbo @cloudkit/web#build
+ - name: Publish
+ uses: cloudflare/pages-action@v1
+ with:
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ projectName: cloudkit
+ workingDirectory: ./apps/web/
+ directory: ./.svelte-kit/cloudflare
+ # Optional: Enable this if you want to have GitHub Deployments triggered
+ gitHubToken: ${{ secrets.GITHUB_TOKEN }}
+ # Optional: Switch what branch you are publishing to.
+ # By default this will be the branch which triggered this workflow
+ branch: ${{ github.head_ref }}
+ # Optional: Change the working directory
+ # Optional: Change the Wrangler version, allows you to point to a specific version or a tag such as `beta`
+ wranglerVersion: '3'
+ PUBLISH_STORYBOOK:
+ name: Publish Storybook
+ environment: PREVIEW
+ runs-on: ubuntu-latest
+ needs: [INSTALL, LINT, VERIFY, UNIT_TEST]
+ steps:
+ - uses: actions/checkout@v4
+ - uses: pnpm/action-setup@v3
+ with:
+ version: 9.12.1
+ - uses: actions/setup-node@v3
+ with:
+ node-version: '22.9.0'
+ - uses: actions/cache@v3
+ with:
+ path: ./node_modules
+ key: ${{ runner.os }}-${{ hashFiles('./pnpm-lock.yaml', '/.npmrc') }}
+ - name: Install Module Packages
run: |
- pnpm psql:seed
- - name: e2e Test
+ pnpm i --frozen-lockfile
+ pnpm sync
+ - name: Build
+ env:
+ IS_CI: false
run: |
- pnpm run test:e2e:ci
- PUBLISH:
- name: Publish to Feature Branch
+ pnpm turbo run @cloudkit/styleguide#build
+ - name: Publish
+ uses: cloudflare/pages-action@v1
+ with:
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ projectName: cloudkit-styleguide
+ workingDirectory: ./apps/styleguide/
+ directory: ./storybook-static
+ # Optional: Enable this if you want to have GitHub Deployments triggered
+ gitHubToken: ${{ secrets.GITHUB_TOKEN }}
+ # Optional: Switch what branch you are publishing to.
+ # By default this will be the branch which triggered this workflow
+ branch: ${{ github.head_ref }}
+ # Optional: Change the working directory
+ # Optional: Change the Wrangler version, allows you to point to a specific version or a tag such as `beta`
+ wranglerVersion: '3'
+
+ PUBLISH_SWAGGER:
+ name: Publish Swagger UI
environment: PREVIEW
runs-on: ubuntu-latest
- needs: INSTALL
+ needs: [INSTALL, LINT, VERIFY, UNIT_TEST]
steps:
- uses: actions/checkout@v4
- - uses: pnpm/action-setup@v2
+ - uses: pnpm/action-setup@v3
with:
- version: 8.9.2
+ version: 9.12.1
- uses: actions/setup-node@v3
with:
- node-version: '20'
- cache: 'pnpm'
- - name: Install
+ node-version: '22.9.0'
+ - uses: actions/cache@v3
+ with:
+ path: ./node_modules
+ key: ${{ runner.os }}-${{ hashFiles('./pnpm-lock.yaml', '/.npmrc') }}
+ - name: Install Module Packages
run: |
- pnpm install
+ pnpm i --frozen-lockfile
+ pnpm sync
- name: Build
env:
IS_CI: false
run: |
- pnpm build:prod
- - name: Publish to Cloudflare Pages
+ pnpm turbo run @cloudkit/swagger-ui#build
+ - name: Publish
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- projectName: cloudkit
+ projectName: cloudkit-api
+ workingDirectory: ./apps/swagger-ui/
directory: ./.svelte-kit/cloudflare
# Optional: Enable this if you want to have GitHub Deployments triggered
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
@@ -150,6 +236,50 @@ jobs:
# By default this will be the branch which triggered this workflow
branch: ${{ github.head_ref }}
# Optional: Change the working directory
- # workingDirectory: my-site
# Optional: Change the Wrangler version, allows you to point to a specific version or a tag such as `beta`
wranglerVersion: '3'
+
+
+ # Disable until I can figure out a way to deal with the flaky tests
+ # E2E_TEST:
+ # name: Run E2E Tests
+ # needs: [INSTALL]
+ # runs-on: ubuntu-latest
+ # steps:
+ # - name: Setup FFmpeg
+ # uses: AnimMouse/setup-ffmpeg@v1
+ # - uses: actions/checkout@v4
+ # - name: Set up Services
+ # run: |
+ # sudo docker-compose up -d
+ # - uses: pnpm/action-setup@v3
+ # with:
+ # version: 9.12.1
+ # - uses: actions/setup-node@v3
+ # with:
+ # node-version: '22.9.0'
+ # cache: 'pnpm'
+ # - uses: actions/cache@v3
+ # with:
+ # path: ./node_modules
+ # key: ${{ runner.os }}-${{ hashFiles('./pnpm-lock.yaml', '/.npmrc) }}
+ # - name: Prisma Generate
+ # run: |
+ # pnpm prisma:gen:dev
+ # - name: Prisma Push
+ # run: |
+ # pnpm prisma:push:dev
+ # - name: Install Playwright Browsers
+ # run: |
+ # pnpm exec playwright install
+ # pnpm playwright install-deps chromium
+ # - name: e2e Test
+ # run: |
+ # pnpm run test:e2e:ci
+ # - uses: actions/upload-artifact@v4
+ # if: ${{ !cancelled() }}
+ # with:
+ # name: playwright-report
+ # path: test-results/
+ # retention-days: 5
+
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 292be16..dc8f3a2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,11 @@
.DS_Store
+generated
+db-schema/src/index.ts // generated by db-schema
node_modules
-/build
-/.svelte-kit
-/package
+.wrangler
+.svelte-kit
+.vscode
+build
.env
.env.*
!.env.example
@@ -15,4 +18,13 @@ test-results
./.wrangler/
.wrangler
db
-.env.development
+docker-images.tar
+.env.ci
+prisma/generated
+.eslintcache
+dist
+.turbo
+.idea
+
+*storybook.log
+storybook-static
\ No newline at end of file
diff --git a/.husky/pre-commit b/.husky/pre-commit
index 07056c5..9b7773d 100755
--- a/.husky/pre-commit
+++ b/.husky/pre-commit
@@ -1,5 +1,8 @@
#!/usr/bin/env sh
-. "$(dirname "$0")/_/husky.sh"
pnpm lint-staged
-pnpm check
\ No newline at end of file
+
+if git diff --cached --name-only | grep -q '\.svelte$'; then
+ pnpm run -r --filter=@cloudkit/web check
+ pnpm run -r --filter=@cloudkit/ui-core check
+fi
diff --git a/.lintstagedrc b/.lintstagedrc
deleted file mode 100644
index 60c0a0a..0000000
--- a/.lintstagedrc
+++ /dev/null
@@ -1,8 +0,0 @@
-{
- "*.{js,ts,svelte,css,scss,postcss,md,json,yml}": [
- "prettier --write --plugin-search-dir=./src",
- "prettier --check --plugin-search-dir=./src",
- "eslint --fix"
- ],
- "*.{js,ts,svelte}": "eslint"
-}
diff --git a/.npmrc b/.npmrc
index b6f27f1..2900a4a 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1 +1,8 @@
-engine-strict=true
+engine-strict=true # https://pnpm.io/npmrc#engine-strict
+auto-install-peers=true # https://pnpm.io/npmrc#auto-install-peers
+link-workspace-packages=true # https://pnpm.io/npmrc#link-workspace-packages
+shared-workspace-lockfile=true # https://pnpm.io/npmrc#shared-workspace-lockfile
+prefer-workspace-packages=true # https://pnpm.io/npmrc#prefer-workspace-packages
+save-workspace-protocol=rolling # https://pnpm.io/npmrc#save-workspace-protocol
+resolution-mode=highest # https://pnpm.io/npmrc#resolution-mode
+node-linker=hoisted # https://pnpm.io/npmrc#node-linker
\ No newline at end of file
diff --git a/.nvmrc b/.nvmrc
index 85aee5a..f483565 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-v20
\ No newline at end of file
+v22.9.0
\ No newline at end of file
diff --git a/.prettierignore b/.prettierignore
index 129a1c4..d76933e 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1,8 +1,11 @@
.DS_Store
node_modules
/build
-/.svelte-kit
-/package
+.svelte-kit
+.vscode
+.idea
+.wrangler
+build
.env
.env.*
!.env.example
@@ -10,6 +13,4 @@ node_modules
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
-yarn.lock
-prisma/migrations
-db
+.svelte-kit/**
diff --git a/.prettierrc b/.prettierrc
index a77fdde..8999f82 100644
--- a/.prettierrc
+++ b/.prettierrc
@@ -4,6 +4,6 @@
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
- "pluginSearchDirs": ["."],
- "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
+ "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }],
+ "htmlWhitespaceSensitivity": "ignore"
}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 2b1f99f..6490ed1 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -106,5 +106,17 @@
"width",
"zIndex"
],
- "terminal.integrated.scrollback": 100000
+ "terminal.integrated.scrollback": 100000,
+
+ "editor.codeActionsOnSave": {
+ "source.fixAll": "always"
+ },
+ "eslint.validate": [
+ "javascript",
+ "javascriptreact",
+ {
+ "language": "svelte",
+ "autoFix": true
+ }
+ ]
}
diff --git a/README.md b/README.md
index 01b5fb2..9a0629b 100644
--- a/README.md
+++ b/README.md
@@ -41,11 +41,12 @@ Becase PR.yaml action uses docker containers, you'll have to configure the follo
You'll need to configure some services and environment variables in the Github Actions to run this in Prod.
### Services
-
+- [Storybook](https://storybook.js.org/) - Storybook is a tool for building UI components and pages in isolation. It allows you to develop components in isolation, and publish them separately for use in different projects.
+- [Swagger-UI](https://swagger.io/tools/swagger-ui/) - Swagger UI is a collection of HTML, JavaScript, and CSS assets that dynamically generate beautiful documentation from a Swagger-compliant API.
+- [OpenAPI](https://swagger.io/specification/) - OpenAPI is a specification for machine-readable interface files based on the JSON-Schema standard. This template uses ZOD to generate the OpenAPI specification.
- [Cloudflare Pages](https://pages.cloudflare.com/) - There is a free tier but even the paid tier isn't very expensive.
- [Cloudflare Images](https://www.cloudflare.com/developer-platform/cloudflare-images/) - This one is paid only. It's 5 USD / Month / 100'000 Image served with 20 configured variations (the variations do not count to the 100'000 image limit). Delivering images is 1 USD for every 100'000 images. There's also a enterprise version. Check out the details [here](https://developers.cloudflare.com/images/pricing/)
- [Neon.Tech](https://neon.tech/) Database - I've been using their free tier for ages without hitting any limits so far. The only reason I went ahead and upgraded was because you can only have one project on the free tier. They don't charge you for a month if the bill is under 50 Cent. It's very affordable for what you're getting. Seriously, [check them out](https://neon.tech/pricing)
-- [Upstash](https://upstash.com/) - They have a generous free tier. Personally, I use the "Pay As You Go" option and set a low budget limit. Check out their pricing [here](https://upstash.com/pricing)
- [Prisma Cloud](https://cloud.prisma.io/) - You will not be able to hack your way around this one because prisma's edge client doesn't allow connecting to a database directly. They have a generous free tier and that's the only thing I use them for. Create a project [here](https://cloud.prisma.io/), create the Connection String and use this. Their DataExplorer will then be linked to the production database, which can be quite handy as well.
### Github Actions Environemnts
diff --git a/apps/styleguide/.eslintrc.cjs b/apps/styleguide/.eslintrc.cjs
new file mode 100644
index 0000000..fcc9cdb
--- /dev/null
+++ b/apps/styleguide/.eslintrc.cjs
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: ["@cloudkit/eslint-config"]
+};
diff --git a/apps/styleguide/.lintstagedrc b/apps/styleguide/.lintstagedrc
new file mode 100644
index 0000000..401a9ca
--- /dev/null
+++ b/apps/styleguide/.lintstagedrc
@@ -0,0 +1,4 @@
+{
+ "*.{js,ts,svelte,css,scss,postcss,md,json,yml}": [
+ "prettier --config ../../.prettierrc --write --cache ./src"
+ ]}
diff --git a/apps/styleguide/.storybook/main.ts b/apps/styleguide/.storybook/main.ts
new file mode 100644
index 0000000..e5843b1
--- /dev/null
+++ b/apps/styleguide/.storybook/main.ts
@@ -0,0 +1,47 @@
+import type { StorybookConfig } from '@storybook/sveltekit';
+import { dirname, join } from 'path';
+
+/**
+ * This function is used to resolve the absolute path of a package.
+ * It is needed in projects that use Yarn PnP or are set up within a monorepo.
+ */
+function getAbsolutePath(value: string) {
+ return dirname(require.resolve(join(value, 'package.json')));
+}
+
+const config: StorybookConfig = {
+ core: {
+ builder: '@storybook/builder-vite',
+ disableTelemetry: true
+ },
+ stories: [
+ '../src/**/*.mdx',
+ '../src/**/*.stories.@(js|ts)',
+ '../../../packages/ui-core/src/**/*.mdx',
+ '../../../packages/ui-core/src/**/*.stories.@(svelte|ts)',
+ '../tailwind.config.ts'
+ ],
+ addons: [
+ '@storybook/addon-svelte-csf',
+ 'storybook-addon-tailwind-autodocs',
+ '@storybook/addon-themes',
+ 'storybook-dark-mode',
+ getAbsolutePath('@storybook/addon-links'),
+ getAbsolutePath('@storybook/addon-essentials'),
+ getAbsolutePath('@storybook/addon-interactions'),
+ getAbsolutePath('storybook-addon-tailwind-autodocs'),
+ {
+ name: 'storybook-addon-sass-postcss',
+ options: {
+ sassLoaderOptions: {
+ implementation: require('postcss')
+ }
+ }
+ }
+ ],
+ framework: {
+ name: '@storybook/sveltekit',
+ options: {}
+ }
+};
+export default config;
diff --git a/apps/styleguide/.storybook/preview.ts b/apps/styleguide/.storybook/preview.ts
new file mode 100644
index 0000000..350476e
--- /dev/null
+++ b/apps/styleguide/.storybook/preview.ts
@@ -0,0 +1,27 @@
+import { withThemeByDataAttribute } from '@storybook/addon-themes';
+import { Preview, SvelteRenderer } from '@storybook/svelte';
+import '../src/app.postcss';
+
+const preview: Preview = {
+ parameters: {
+ darkMode: { stylePreview: true, classTarget: 'html' },
+ controls: {
+ matchers: {
+ color: /(background|color)$/i,
+ date: /Date$/i
+ }
+ }
+ },
+ decorators: [
+ withThemeByDataAttribute({
+ themes: {
+ wintry: 'wintry'
+ },
+ defaultTheme: 'wintry',
+ parentSelector: 'body',
+ attributeName: 'data-theme'
+ })
+ ]
+};
+
+export default preview;
diff --git a/apps/styleguide/package.json b/apps/styleguide/package.json
new file mode 100644
index 0000000..e1ca6be
--- /dev/null
+++ b/apps/styleguide/package.json
@@ -0,0 +1,47 @@
+{
+ "name": "@cloudkit/styleguide",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "type": "module",
+ "scripts": {
+ "dev": "storybook dev -p 4200",
+ "build": "storybook build"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "devDependencies": {
+ "@skeletonlabs/skeleton": "2.10.2",
+ "@skeletonlabs/tw-plugin": "^0.4.0",
+ "@storybook/addon-essentials": "^8.3.5",
+ "@storybook/addon-interactions": "^8.3.5",
+ "@storybook/addon-links": "^8.3.5",
+ "@storybook/addon-svelte-csf": "^4.1.7",
+ "@storybook/addon-themes": "^8.3.5",
+ "@storybook/blocks": "^8.3.5",
+ "@storybook/builder-vite": "^8.3.5",
+ "@storybook/svelte": "^8.3.5",
+ "@storybook/svelte-vite": "^8.3.5",
+ "@storybook/sveltekit": "^8.3.5",
+ "@storybook/test": "^8.3.5",
+ "@sveltejs/adapter-node": "5.2.2",
+ "@sveltejs/kit": "^2.7.0",
+ "@sveltejs/vite-plugin-svelte": "^3.1.2",
+ "postcss": "^8.4.47",
+ "postcss-load-config": "^6.0.1",
+ "sass": "^1.79.5",
+ "storybook": "^8.3.5",
+ "storybook-addon-sass-postcss": "^0.3.2",
+ "storybook-addon-tailwind-autodocs": "^1.0.8",
+ "storybook-dark-mode": "^4.0.2",
+ "svelte": "^4.2.19",
+ "tailwindcss": "^3.4.10",
+ "vite": "^5.0.3"
+ },
+ "dependencies": {
+ "@tailwindcss/container-queries": "^0.1.1",
+ "@tailwindcss/forms": "0.5.9",
+ "@tailwindcss/typography": "0.5.15"
+ }
+}
diff --git a/postcss.config.cjs b/apps/styleguide/postcss.config.cjs
similarity index 100%
rename from postcss.config.cjs
rename to apps/styleguide/postcss.config.cjs
diff --git a/apps/styleguide/src/app.html b/apps/styleguide/src/app.html
new file mode 100644
index 0000000..abf05de
--- /dev/null
+++ b/apps/styleguide/src/app.html
@@ -0,0 +1,18 @@
+
+
+
+ %sveltekit.head%
+
+
+
+
+
+ %sveltekit.body%
+
+
diff --git a/apps/styleguide/src/app.postcss b/apps/styleguide/src/app.postcss
new file mode 100644
index 0000000..c2b4e0c
--- /dev/null
+++ b/apps/styleguide/src/app.postcss
@@ -0,0 +1,55 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+@tailwind variants;
+@tailwind screens;
+
+:root [data-theme='wintry'] {
+ --theme-rounded-base: 4px;
+ --theme-rounded-container: 2px;
+ --theme-font-family-base: 'Quicksand', sans-serif;
+ --theme-font-family-heading: 'Quicksand';
+}
+/*place global styles here */
+html,
+body {
+ @apply h-full;
+ @apply min-h-full;
+ @apply flex;
+ @apply flex-col;
+}
+main {
+ @apply flex-1;
+}
+
+body {
+ background-image: radial-gradient(
+ at 0% 0%,
+ rgba(var(--color-secondary-500) / 0.33) 0px,
+ transparent 50%
+ ),
+ radial-gradient(at 98% 1%, rgba(var(--color-error-500) / 0.33) 0px, transparent 50%);
+ background-attachment: fixed;
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: cover;
+}
+@font-face {
+ font-family: 'Quicksand';
+ src: url('/fonts/Quicksand.ttf');
+ font-display: swap;
+}
+
+[data-popup] {
+ /* Display */
+ display: none;
+ /* Position */
+ position: absolute;
+ top: 0;
+ left: 0;
+ /* Transitions */
+ transition: none;
+}
+.tree-item-content {
+ @apply w-full;
+}
diff --git a/apps/styleguide/src/stories/Introduction.mdx b/apps/styleguide/src/stories/Introduction.mdx
new file mode 100644
index 0000000..0026a09
--- /dev/null
+++ b/apps/styleguide/src/stories/Introduction.mdx
@@ -0,0 +1,15 @@
+import { Meta } from '@storybook/blocks';
+
+
+Welcome to the cloudkit.dle.dev Styleguide!
+
+ This is a styleguide for the cloudkit.dle.dev project. It is a collection of components and
+ patterns that can be used to build the cloudkit.dle.dev website. It is built using Svelte and
+ Storybook.
+
+
+
+ This is currently a work in progress. The components and patterns are being developed and will be
+ added to the styleguide as they are completed. Some functions (such as the Theme documentation)
+ are not yet implemented. They will follow at a later date.
+
diff --git a/apps/styleguide/svelte.config.js b/apps/styleguide/svelte.config.js
new file mode 100644
index 0000000..2d78a5d
--- /dev/null
+++ b/apps/styleguide/svelte.config.js
@@ -0,0 +1,19 @@
+import nodeAdapter from '@sveltejs/adapter-node';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+
+const config = {
+ preprocess: [vitePreprocess()],
+
+ kit: {
+ adapter: nodeAdapter(),
+ csrf: {
+ checkOrigin: true
+ },
+ env: {
+ dir: './../../'
+ },
+ }
+};
+
+export default config;
diff --git a/apps/styleguide/tailwind.config.ts b/apps/styleguide/tailwind.config.ts
new file mode 100644
index 0000000..7a17bab
--- /dev/null
+++ b/apps/styleguide/tailwind.config.ts
@@ -0,0 +1,43 @@
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-expect-error
+import forms from '@tailwindcss/forms';
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-expect-error
+import typography from '@tailwindcss/typography';
+import { join } from 'path';
+import type { Config } from 'tailwindcss';
+
+// 1. Import the Skeleton plugin
+import { skeleton } from '@skeletonlabs/tw-plugin';
+
+const config = {
+ // 2. Opt for dark mode to be handled via the class method
+ darkMode: 'class',
+ content: [
+ './src/**/*.{html,js,svelte,ts}',
+ '../../packages/ui-core/src/**/*.{html,js,svelte,ts}',
+ // 3. Append the path to the Skeleton package
+ join(require.resolve('@skeletonlabs/skeleton'), '../**/*.{html,js,svelte,ts}'),
+ join(
+ require.resolve('@skeletonlabs/skeleton'),
+ '../../../packages/ui-core/src/**/*.{html,js,svelte,ts}'
+ )
+ ],
+ theme: {
+ extend: {}
+ },
+ plugins: [
+ forms,
+ typography,
+ // 4. Append the Skeleton plugin (after other plugins)
+ require('@tailwindcss/container-queries'),
+ skeleton({
+ themes: {
+ // Register each theme within this array:
+ preset: [{ name: 'wintry', enhancements: true }]
+ }
+ })
+ ]
+} satisfies Config;
+
+export default config;
diff --git a/apps/styleguide/vite.config.ts b/apps/styleguide/vite.config.ts
new file mode 100644
index 0000000..31d317a
--- /dev/null
+++ b/apps/styleguide/vite.config.ts
@@ -0,0 +1,6 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+
+import { defineConfig } from 'vitest/config';
+export default defineConfig({
+ plugins: [sveltekit()]
+});
diff --git a/apps/styleguide/wrangler.toml b/apps/styleguide/wrangler.toml
new file mode 100644
index 0000000..6753c05
--- /dev/null
+++ b/apps/styleguide/wrangler.toml
@@ -0,0 +1,86 @@
+#:schema node_modules/wrangler/config-schema.json
+name = "cloudkit-styleguide"
+compatibility_date = "2024-07-07"
+pages_build_output_dir = "storybook-static"
+compatibility_flags = [ "nodejs_compat" ]
+
+# Automatically place your workloads in an optimal location to minimize latency.
+# If you are running back-end logic in a Pages Function, running it closer to your back-end infrastructure
+# rather than the end user may result in better performance.
+# Docs: https://developers.cloudflare.com/pages/functions/smart-placement/#smart-placement
+# [placement]
+# mode = "smart"
+
+# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables)
+# Docs:
+# - https://developers.cloudflare.com/pages/functions/bindings/#environment-variables
+# Note: Use secrets to store sensitive data.
+# - https://developers.cloudflare.com/pages/functions/bindings/#secrets
+# [vars]
+# MY_VARIABLE = "production_value"
+
+# Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network
+# Docs: https://developers.cloudflare.com/pages/functions/bindings/#workers-ai
+# [ai]
+# binding = "AI"
+
+# Bind a D1 database. D1 is Cloudflare’s native serverless SQL database.
+# Docs: https://developers.cloudflare.com/pages/functions/bindings/#d1-databases
+# [[d1_databases]]
+# binding = "MY_DB"
+# database_name = "my-database"
+# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+
+# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model.
+# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps.
+# Docs: https://developers.cloudflare.com/workers/runtime-apis/durable-objects
+# [[durable_objects.bindings]]
+# name = "MY_DURABLE_OBJECT"
+# class_name = "MyDurableObject"
+# script_name = 'my-durable-object'
+
+# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs.
+# Docs: https://developers.cloudflare.com/pages/functions/bindings/#kv-namespaces
+# [[kv_namespaces]]
+# binding = "MY_KV_NAMESPACE"
+# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+
+# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer.
+# Docs: https://developers.cloudflare.com/pages/functions/bindings/#queue-producers
+# [[queues.producers]]
+# binding = "MY_QUEUE"
+# queue = "my-queue"
+
+# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files.
+# Docs: https://developers.cloudflare.com/pages/functions/bindings/#r2-buckets
+# [[r2_buckets]]
+# binding = "MY_BUCKET"
+# bucket_name = "my-bucket"
+
+# Bind another Worker service. Use this binding to call another Worker without network overhead.
+# Docs: https://developers.cloudflare.com/pages/functions/bindings/#service-bindings
+# [[services]]
+# binding = "MY_SERVICE"
+# service = "my-service"
+
+# To use different bindings for preview and production environments, follow the examples below.
+# When using environment-specific overrides for bindings, ALL bindings must be specified on a per-environment basis.
+# Docs: https://developers.cloudflare.com/pages/functions/wrangler-configuration#environment-specific-overrides
+
+######## PREVIEW environment config ########
+
+# [env.preview.vars]
+# API_KEY = "xyz789"
+
+# [[env.preview.kv_namespaces]]
+# binding = "MY_KV_NAMESPACE"
+# id = ""
+
+######## PRODUCTION environment config ########
+
+# [env.production.vars]
+# API_KEY = "abc123"
+
+# [[env.production.kv_namespaces]]
+# binding = "MY_KV_NAMESPACE"
+# id = ""
\ No newline at end of file
diff --git a/apps/swagger-ui/.eslintrc.cjs b/apps/swagger-ui/.eslintrc.cjs
new file mode 100644
index 0000000..f1c66a7
--- /dev/null
+++ b/apps/swagger-ui/.eslintrc.cjs
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: ['@cloudkit/eslint-config']
+};
diff --git a/apps/swagger-ui/.gitignore b/apps/swagger-ui/.gitignore
new file mode 100644
index 0000000..79518f7
--- /dev/null
+++ b/apps/swagger-ui/.gitignore
@@ -0,0 +1,21 @@
+node_modules
+
+# Output
+.output
+.vercel
+/.svelte-kit
+/build
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Env
+.env
+.env.*
+!.env.example
+!.env.test
+
+# Vite
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
diff --git a/apps/swagger-ui/.lintstagedrc b/apps/swagger-ui/.lintstagedrc
new file mode 100644
index 0000000..5e06bd8
--- /dev/null
+++ b/apps/swagger-ui/.lintstagedrc
@@ -0,0 +1,6 @@
+{
+ "*.{js,ts,svelte,css,scss,postcss,md,json,yml}": [
+ "prettier --config ../../.prettierrc --write --cache ./src"
+ ],
+ "*.{ts,svelte,css,scss,yml}": ["eslint --cache --fix"]
+}
diff --git a/apps/swagger-ui/.prettierignore b/apps/swagger-ui/.prettierignore
new file mode 100644
index 0000000..ab78a95
--- /dev/null
+++ b/apps/swagger-ui/.prettierignore
@@ -0,0 +1,4 @@
+# Package Managers
+package-lock.json
+pnpm-lock.yaml
+yarn.lock
diff --git a/apps/swagger-ui/package.json b/apps/swagger-ui/package.json
new file mode 100644
index 0000000..dee3588
--- /dev/null
+++ b/apps/swagger-ui/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@cloudkit/swagger-ui",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev",
+ "build": "vite build",
+ "preview": "vite preview",
+ "preview:cf": "pnpm build && wrangler pages dev ./.svelte-kit/cloudflare",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
+ "lint": "eslint ./src",
+ "format": "eslint ./src --fix && prettier --write ./src",
+ "sync": "svelte-kit sync"
+ },
+ "devDependencies": {
+ "@sveltejs/adapter-auto": "^3.0.0",
+ "@sveltejs/adapter-cloudflare": "4.7.2",
+ "@sveltejs/adapter-node": "5.2.2",
+ "@sveltejs/kit": "^2.0.0",
+ "@sveltejs/vite-plugin-svelte": "^3.1.2",
+ "@types/swagger-ui": "^3.52.4",
+ "@cloudkit/eslint-config": "workspace:^",
+ "globals": "^15.0.0",
+ "svelte": "^4.2.7",
+ "svelte-check": "^4.0.0",
+ "vite": "^5.0.3"
+ },
+ "dependencies": {
+ "@cloudkit/service-contract": "workspace:*",
+ "swagger-ui": "^5.17.14"
+ }
+}
diff --git a/apps/swagger-ui/src/app.d.ts b/apps/swagger-ui/src/app.d.ts
new file mode 100644
index 0000000..743f07b
--- /dev/null
+++ b/apps/swagger-ui/src/app.d.ts
@@ -0,0 +1,13 @@
+// See https://kit.svelte.dev/docs/types#app
+// for information about these interfaces
+declare global {
+ namespace App {
+ // interface Error {}
+ // interface Locals {}
+ // interface PageData {}
+ // interface PageState {}
+ // interface Platform {}
+ }
+}
+
+export {};
diff --git a/apps/swagger-ui/src/app.html b/apps/swagger-ui/src/app.html
new file mode 100644
index 0000000..c1524fb
--- /dev/null
+++ b/apps/swagger-ui/src/app.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+ %sveltekit.head%
+
+
+ %sveltekit.body%
+
+
diff --git a/apps/swagger-ui/src/lib/index.ts b/apps/swagger-ui/src/lib/index.ts
new file mode 100644
index 0000000..856f2b6
--- /dev/null
+++ b/apps/swagger-ui/src/lib/index.ts
@@ -0,0 +1 @@
+// place files you want to import through the `$lib` alias in this folder.
diff --git a/apps/swagger-ui/src/routes/+page.svelte b/apps/swagger-ui/src/routes/+page.svelte
new file mode 100644
index 0000000..964ac4b
--- /dev/null
+++ b/apps/swagger-ui/src/routes/+page.svelte
@@ -0,0 +1,19 @@
+
+
+
+ cloudkit API
+
+
+
diff --git a/apps/swagger-ui/static/android-chrome-192x192.png b/apps/swagger-ui/static/android-chrome-192x192.png
new file mode 100644
index 0000000..1e96ce0
Binary files /dev/null and b/apps/swagger-ui/static/android-chrome-192x192.png differ
diff --git a/apps/swagger-ui/static/android-chrome-512x512.png b/apps/swagger-ui/static/android-chrome-512x512.png
new file mode 100644
index 0000000..bc12fb8
Binary files /dev/null and b/apps/swagger-ui/static/android-chrome-512x512.png differ
diff --git a/apps/swagger-ui/static/apple-touch-icon.png b/apps/swagger-ui/static/apple-touch-icon.png
new file mode 100644
index 0000000..5ed02d9
Binary files /dev/null and b/apps/swagger-ui/static/apple-touch-icon.png differ
diff --git a/apps/swagger-ui/static/favicon-16x16.png b/apps/swagger-ui/static/favicon-16x16.png
new file mode 100644
index 0000000..7aa98b8
Binary files /dev/null and b/apps/swagger-ui/static/favicon-16x16.png differ
diff --git a/apps/swagger-ui/static/favicon-32x32.png b/apps/swagger-ui/static/favicon-32x32.png
new file mode 100644
index 0000000..5cdc723
Binary files /dev/null and b/apps/swagger-ui/static/favicon-32x32.png differ
diff --git a/apps/swagger-ui/static/favicon.ico b/apps/swagger-ui/static/favicon.ico
new file mode 100644
index 0000000..72ead40
Binary files /dev/null and b/apps/swagger-ui/static/favicon.ico differ
diff --git a/apps/swagger-ui/svelte.config.js b/apps/swagger-ui/svelte.config.js
new file mode 100644
index 0000000..e2921b0
--- /dev/null
+++ b/apps/swagger-ui/svelte.config.js
@@ -0,0 +1,37 @@
+import cloudflareAdapter from '@sveltejs/adapter-cloudflare';
+import nodeAdapter from '@sveltejs/adapter-node';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+const adapter =
+ process.env.ENVIRONMENT === 'ci'
+ ? nodeAdapter()
+ : cloudflareAdapter({
+ // See below for an explanation of these options
+
+ routes: {
+ include: ['/*'],
+ exclude: ['']
+ },
+ platformProxy: {
+ configPath: 'wrangler.toml',
+ environment: process.env.ENVIRONMENT,
+ experimentalJsonConfig: false,
+ persist: false
+ }
+ });
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ preprocess: [vitePreprocess()],
+
+ kit: {
+ adapter,
+ csrf: {
+ checkOrigin: true
+ },
+ env: {
+ dir: './../../'
+ }
+ }
+};
+
+export default config;
diff --git a/tsconfig.json b/apps/swagger-ui/tsconfig.json
similarity index 82%
rename from tsconfig.json
rename to apps/swagger-ui/tsconfig.json
index 815b719..fc93cbd 100644
--- a/tsconfig.json
+++ b/apps/swagger-ui/tsconfig.json
@@ -9,9 +9,10 @@
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
- "experimentalDecorators": true
+ "moduleResolution": "bundler"
}
// Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
+ // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
diff --git a/vite.config.ts b/apps/swagger-ui/vite.config.ts
similarity index 50%
rename from vite.config.ts
rename to apps/swagger-ui/vite.config.ts
index 68079b0..1ecd670 100644
--- a/vite.config.ts
+++ b/apps/swagger-ui/vite.config.ts
@@ -1,16 +1,12 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
-import tsconfigPaths from 'vite-tsconfig-paths';
-
-import { purgeCss } from 'vite-plugin-tailwind-purgecss';
-
export default defineConfig({
- plugins: [tsconfigPaths(), sveltekit(), purgeCss()],
+ plugins: [sveltekit()],
server: {
- port: 4000
+ port: 4100
},
preview: {
- port: 4000
+ port: 4100
},
optimizeDeps: {
esbuildOptions: {
@@ -19,8 +15,5 @@ export default defineConfig({
},
build: {
target: 'es2020'
- },
- test: {
- include: ['tests/unit/**/*.{test,spec}.{js,ts}']
}
});
diff --git a/apps/swagger-ui/wrangler.toml b/apps/swagger-ui/wrangler.toml
new file mode 100644
index 0000000..4d9c336
--- /dev/null
+++ b/apps/swagger-ui/wrangler.toml
@@ -0,0 +1,86 @@
+#:schema node_modules/wrangler/config-schema.json
+name = "cloudkit-api"
+compatibility_date = "2024-07-07"
+pages_build_output_dir = ".svelte-kit/cloudflare"
+compatibility_flags = [ "nodejs_compat" ]
+
+# Automatically place your workloads in an optimal location to minimize latency.
+# If you are running back-end logic in a Pages Function, running it closer to your back-end infrastructure
+# rather than the end user may result in better performance.
+# Docs: https://developers.cloudflare.com/pages/functions/smart-placement/#smart-placement
+# [placement]
+# mode = "smart"
+
+# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables)
+# Docs:
+# - https://developers.cloudflare.com/pages/functions/bindings/#environment-variables
+# Note: Use secrets to store sensitive data.
+# - https://developers.cloudflare.com/pages/functions/bindings/#secrets
+# [vars]
+# MY_VARIABLE = "production_value"
+
+# Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network
+# Docs: https://developers.cloudflare.com/pages/functions/bindings/#workers-ai
+# [ai]
+# binding = "AI"
+
+# Bind a D1 database. D1 is Cloudflare’s native serverless SQL database.
+# Docs: https://developers.cloudflare.com/pages/functions/bindings/#d1-databases
+# [[d1_databases]]
+# binding = "MY_DB"
+# database_name = "my-database"
+# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+
+# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model.
+# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps.
+# Docs: https://developers.cloudflare.com/workers/runtime-apis/durable-objects
+# [[durable_objects.bindings]]
+# name = "MY_DURABLE_OBJECT"
+# class_name = "MyDurableObject"
+# script_name = 'my-durable-object'
+
+# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs.
+# Docs: https://developers.cloudflare.com/pages/functions/bindings/#kv-namespaces
+# [[kv_namespaces]]
+# binding = "MY_KV_NAMESPACE"
+# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+
+# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer.
+# Docs: https://developers.cloudflare.com/pages/functions/bindings/#queue-producers
+# [[queues.producers]]
+# binding = "MY_QUEUE"
+# queue = "my-queue"
+
+# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files.
+# Docs: https://developers.cloudflare.com/pages/functions/bindings/#r2-buckets
+# [[r2_buckets]]
+# binding = "MY_BUCKET"
+# bucket_name = "my-bucket"
+
+# Bind another Worker service. Use this binding to call another Worker without network overhead.
+# Docs: https://developers.cloudflare.com/pages/functions/bindings/#service-bindings
+# [[services]]
+# binding = "MY_SERVICE"
+# service = "my-service"
+
+# To use different bindings for preview and production environments, follow the examples below.
+# When using environment-specific overrides for bindings, ALL bindings must be specified on a per-environment basis.
+# Docs: https://developers.cloudflare.com/pages/functions/wrangler-configuration#environment-specific-overrides
+
+######## PREVIEW environment config ########
+
+# [env.preview.vars]
+# API_KEY = "xyz789"
+
+# [[env.preview.kv_namespaces]]
+# binding = "MY_KV_NAMESPACE"
+# id = ""
+
+######## PRODUCTION environment config ########
+
+# [env.production.vars]
+# API_KEY = "abc123"
+
+# [[env.production.kv_namespaces]]
+# binding = "MY_KV_NAMESPACE"
+# id = ""
\ No newline at end of file
diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs
new file mode 100644
index 0000000..f1c66a7
--- /dev/null
+++ b/apps/web/.eslintrc.cjs
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: ['@cloudkit/eslint-config']
+};
diff --git a/apps/web/.lintstagedrc b/apps/web/.lintstagedrc
new file mode 100644
index 0000000..5e06bd8
--- /dev/null
+++ b/apps/web/.lintstagedrc
@@ -0,0 +1,6 @@
+{
+ "*.{js,ts,svelte,css,scss,postcss,md,json,yml}": [
+ "prettier --config ../../.prettierrc --write --cache ./src"
+ ],
+ "*.{ts,svelte,css,scss,yml}": ["eslint --cache --fix"]
+}
diff --git a/apps/web/package.json b/apps/web/package.json
new file mode 100644
index 0000000..f456b2f
--- /dev/null
+++ b/apps/web/package.json
@@ -0,0 +1,80 @@
+{
+ "name": "@cloudkit/web",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "prep": "pnpm prisma:gen:dev && pnpm prisma:push:dev && pnpm psql:seed",
+ "dev": "vite dev",
+ "clean": "rimraf ./playwright-report && rimraf ./.wrangler && rimraf ./.svelte-kit",
+ "run-ci": "pnpm clean && dotenv -e .env.development -- prisma generate --schema ./prisma/dev.schema.prisma && dotenv -e .env.ci -- vite build --mode ci && vite preview",
+ "build": "vite build --mode production",
+ "build:ci": "dotenv -e .env.ci -- pnpm clean && pnpm prisma:gen:ci && vite build --mode ci",
+ "preview": "pnpm build:ci && vite preview",
+ "preview:cf": "dotenv -e ../../.env.production -- pnpm build && wrangler pages dev ./.svelte-kit/cloudflare",
+ "test:integration:debug": "playwright test --debug",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
+ "lint": "eslint ./src",
+ "format": "eslint ./src --fix && prettier --write ./src",
+ "test:unit": "vitest --run",
+ "test:e2e": "playwright test",
+ "test:e2e:report": "playwright test --config ./playwright.config.ci.ts && npx playwright show-report",
+ "test:e2e:dev": "playwright test --ui --project=chromium --config ./playwright.config.dev.ts",
+ "test:e2e:ci": "dotenv -e .env.development -- playwright test --project=chromium --config ./playwright.config.ci.ts",
+ "cloudflare:init": "dotenv -e .env.cloudflare -- node createCloudflareProject.js",
+ "tail:dev": "wrangler pages deployment tail --environment preview --project-name cloudkit",
+ "tail:prod": "wrangler pages deployment tail --environment production --project-name cloudkit",
+ "sync": "svelte-kit sync",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "@cloudkit/db-schema": "workspace:^",
+ "@cloudkit/eslint-config": "workspace:^",
+ "@cloudkit/ui-core": "workspace:^",
+ "@floating-ui/dom": "^1.6.10",
+ "@lucia-auth/adapter-prisma": "^4.0.1",
+ "@prisma/extension-accelerate": "^1.1.0",
+ "@skeletonlabs/skeleton": "2.10.2",
+ "@skeletonlabs/tw-plugin": "^0.4.0",
+ "@svelte-put/qr": "^1.2.1",
+ "@sveltejs/adapter-cloudflare": "4.7.2",
+ "@sveltejs/adapter-node": "5.2.2",
+ "@sveltejs/kit": "^2.5.26",
+ "@tailwindcss/container-queries": "^0.1.1",
+ "@tailwindcss/forms": "0.5.9",
+ "@tailwindcss/typography": "0.5.15",
+ "@tanstack/svelte-query": "^5.59.13",
+ "@tanstack/svelte-query-devtools": "^5.55.1",
+ "axios": "^1.7.7",
+ "browser-image-compression": "^2.0.2",
+ "classnames": "^2.5.1",
+ "lucia": "^3.2.0",
+ "sass": "^1.78.0",
+ "set-cookie-parser": "^2.7.0",
+ "svelte": "^4.2.19",
+ "sveltekit-rate-limiter": "^0.6.1",
+ "sveltekit-superforms": "^2.19.1",
+ "tailwind-merge": "^2.5.2",
+ "tailwindcss": "^3.4.10",
+ "uuid": "^10.0.0",
+ "zod": "^3.23.8"
+ },
+ "devDependencies": {
+ "@sveltejs/vite-plugin-svelte": "^3.1.2",
+ "@tanstack/eslint-plugin-query": "^5.53.0",
+ "@testing-library/jest-dom": "^6.6.2",
+ "@testing-library/svelte": "^5.2.4",
+ "@testing-library/user-event": "^14.5.2",
+ "@types/jest": "^29.5.13",
+ "@vitest/ui": "^2.1.3",
+ "autoprefixer": "^10.4.20",
+ "jsdom": "^25.0.1",
+ "postcss": "^8.4.45",
+ "postcss-load-config": "^6.0.1",
+ "svelte-check": "^4.0.1",
+ "svelte-preprocess": "^6.0.2",
+ "type-fest": "^4.26.1",
+ "vitest": "2.1.3"
+ }
+}
diff --git a/apps/web/postcss.config.cjs b/apps/web/postcss.config.cjs
new file mode 100644
index 0000000..054c147
--- /dev/null
+++ b/apps/web/postcss.config.cjs
@@ -0,0 +1,6 @@
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {}
+ }
+};
diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts
new file mode 100644
index 0000000..77cfe1a
--- /dev/null
+++ b/apps/web/src/app.d.ts
@@ -0,0 +1,50 @@
+declare global {
+ namespace App {
+ interface Locals {
+ user: import('lucia').User | null;
+ session: import('lucia').Session | null;
+ }
+ }
+}
+
+export {};
+
+// ///
+
+// import type { Group, Image } from '@prisma/client';
+
+// ///
+// declare global {
+// namespace App {
+// interface Locals {
+// auth: import('lucia').AuthRequest;
+// user: Lucia.UserAttributes;
+// }
+// }
+// namespace Lucia {
+// type Auth = import('$lib/server/auth/lucia').Auth;
+// type DatabaseUserAttributes = {
+// firstName: string;
+// lastName: string;
+// email: string;
+// verified: boolean;
+// createdAt: Date;
+// updatedAt: Date;
+// groups: Group[];
+// avatar: Image | null;
+// };
+// type DatabaseSessionAttributes = {
+// firstName: string;
+// lastName: string;
+// email: string;
+// verified: boolean;
+// createdAt: Date;
+// updatedAt: Date;
+// groups: Group[];
+// avatar: Image | null;
+// };
+// }
+// }
+
+// // THIS IS IMPORTANT!!!
+// export {};
diff --git a/apps/web/src/app.html b/apps/web/src/app.html
new file mode 100644
index 0000000..abf05de
--- /dev/null
+++ b/apps/web/src/app.html
@@ -0,0 +1,18 @@
+
+
+
+ %sveltekit.head%
+
+
+
+
+
+ %sveltekit.body%
+
+
diff --git a/apps/web/src/app.postcss b/apps/web/src/app.postcss
new file mode 100644
index 0000000..c2b4e0c
--- /dev/null
+++ b/apps/web/src/app.postcss
@@ -0,0 +1,55 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+@tailwind variants;
+@tailwind screens;
+
+:root [data-theme='wintry'] {
+ --theme-rounded-base: 4px;
+ --theme-rounded-container: 2px;
+ --theme-font-family-base: 'Quicksand', sans-serif;
+ --theme-font-family-heading: 'Quicksand';
+}
+/*place global styles here */
+html,
+body {
+ @apply h-full;
+ @apply min-h-full;
+ @apply flex;
+ @apply flex-col;
+}
+main {
+ @apply flex-1;
+}
+
+body {
+ background-image: radial-gradient(
+ at 0% 0%,
+ rgba(var(--color-secondary-500) / 0.33) 0px,
+ transparent 50%
+ ),
+ radial-gradient(at 98% 1%, rgba(var(--color-error-500) / 0.33) 0px, transparent 50%);
+ background-attachment: fixed;
+ background-position: center;
+ background-repeat: no-repeat;
+ background-size: cover;
+}
+@font-face {
+ font-family: 'Quicksand';
+ src: url('/fonts/Quicksand.ttf');
+ font-display: swap;
+}
+
+[data-popup] {
+ /* Display */
+ display: none;
+ /* Position */
+ position: absolute;
+ top: 0;
+ left: 0;
+ /* Transitions */
+ transition: none;
+}
+.tree-item-content {
+ @apply w-full;
+}
diff --git a/apps/web/src/error.html b/apps/web/src/error.html
new file mode 100644
index 0000000..1dc9344
--- /dev/null
+++ b/apps/web/src/error.html
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts
new file mode 100644
index 0000000..8cdc54b
--- /dev/null
+++ b/apps/web/src/hooks.server.ts
@@ -0,0 +1,59 @@
+import { isDevOrCi, PATH_GROUPS, PATHS } from '@cloudkit/ui-core';
+import { auth } from '@lib/server/auth/lucia';
+import { type Handle, redirect } from '@sveltejs/kit';
+
+export const handle: Handle = async ({ event, resolve }) => {
+ await auth.deleteExpiredSessions();
+ const sessionId = event.cookies.get(auth.sessionCookieName);
+ if (!sessionId) {
+ event.locals.user = null;
+ event.locals.session = null;
+ if (event.route.id?.startsWith(PATH_GROUPS.PROTECTED)) {
+ redirect(302, PATHS.ROOT);
+ // TODO Implement email verification
+ // if (!user.verified) redirect(302, '/auth/verify/email');
+ }
+ return resolve(event);
+ }
+
+ const { session, user } = await auth.validateSession(sessionId);
+
+ if (session && session.fresh) {
+ const sessionCookie = auth.createSessionCookie(session.id);
+ event.cookies.set(sessionCookie.name, sessionCookie.value, {
+ path: PATHS.ROOT,
+ ...sessionCookie.attributes
+ });
+ }
+ if (!session) {
+ const sessionCookie = auth.createBlankSessionCookie();
+ event.cookies.set(sessionCookie.name, sessionCookie.value, {
+ path: PATHS.ROOT,
+ ...sessionCookie.attributes
+ });
+ }
+ event.locals.user = user;
+ event.locals.session = session;
+
+ if (user && event.route.id === PATH_GROUPS.PUBLIC) {
+ redirect(302, PATHS.PROFILE);
+ }
+ return await resolve(event);
+};
+
+export function handleError({ event, error, message }) {
+ console.error(error);
+ console.error(message);
+ if (!isDevOrCi) {
+ const { cookies } = event;
+ const token = cookies.get('auth_session');
+ if (token) {
+ cookies.delete('auth_session', { path: PATHS.ROOT });
+ }
+ }
+
+ const errorId = crypto.randomUUID();
+ return {
+ message: `${errorId}: We're having some trouble logging you in. Please try again later`
+ };
+}
diff --git a/apps/web/src/lib/api/auth-service-api.ts b/apps/web/src/lib/api/auth-service-api.ts
new file mode 100644
index 0000000..6ba4635
--- /dev/null
+++ b/apps/web/src/lib/api/auth-service-api.ts
@@ -0,0 +1,32 @@
+import type { UserWithRelations } from '@cloudkit/ui-core';
+import type { AuthenticateUserSchema, RegisterUserSchema } from '@lib/client/auth/schemas';
+import type { AxiosResponse } from 'axios';
+import axios from 'axios';
+import type { Infer, SuperValidated } from 'sveltekit-superforms/client';
+import { ApiServiceBase } from './base-api-service';
+
+class AuthApiService extends ApiServiceBase {
+ constructor() {
+ super('/api/v1/auth');
+ }
+
+ createNewSession(data: SuperValidated>['data']) {
+ return this.http.put('', data);
+ }
+ createNewUser(data: SuperValidated>['data']) {
+ // We have to use a separate axios config here because the default config would convert the avatar to an base64 encoded string
+ return axios.postForm<
+ SuperValidated>['data'],
+ AxiosResponse
+ >('/api/v1/auth', data);
+ }
+ deleteSession() {
+ return this.http.delete('');
+ }
+ deleteSessionById(id: string) {
+ return this.http.delete(`/${id}`);
+ }
+}
+
+const api = Object.freeze(new AuthApiService());
+export { api as AuthApiService };
diff --git a/apps/web/src/lib/api/base-api-service.ts b/apps/web/src/lib/api/base-api-service.ts
new file mode 100644
index 0000000..861326f
--- /dev/null
+++ b/apps/web/src/lib/api/base-api-service.ts
@@ -0,0 +1,57 @@
+import type { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios';
+import axios from 'axios';
+
+export abstract class ApiServiceBase {
+ public context: string;
+
+ protected readonly http: AxiosInstance;
+
+ constructor(
+ context: string,
+ commonRequestConfig: AxiosRequestConfig = {
+ transformRequest: [
+ (data) => {
+ return JSON.stringify(data);
+ }
+ ]
+ }
+ ) {
+ this.context = context;
+
+ let { headers = {} } = commonRequestConfig;
+ if (headers['Content-Type'] == null) {
+ headers = {
+ ...headers,
+ 'Content-Type': 'application/json'
+ };
+ }
+
+ this.http = axios.create({
+ ...commonRequestConfig,
+ headers,
+ baseURL: context,
+ paramsSerializer: { indexes: null }
+ });
+ // /**
+ // * Use this to log out all requests during a test run.
+ // */
+ // if (process.env.NODE_ENV === 'test') {
+ // this.http.interceptors.request.use(
+ // (config) => {
+ // console.log(`${config.method.toUpperCase()} ${config.baseURL}${config.url}`);
+ //
+ // return config;
+ // }
+ // );
+ // }
+
+ this.http.interceptors.response.use(null, (error: AxiosError) => {
+ if (error.response && typeof error.response.data === 'string') {
+ // verifySessionTimeout(error.response);
+ // verifyMaintenanceMode(error.response as AxiosResponse);
+ }
+
+ return Promise.reject(error);
+ });
+ }
+}
diff --git a/apps/web/src/lib/api/user-service-api.ts b/apps/web/src/lib/api/user-service-api.ts
new file mode 100644
index 0000000..b5313ba
--- /dev/null
+++ b/apps/web/src/lib/api/user-service-api.ts
@@ -0,0 +1,27 @@
+import { RegisterUserSchema } from '@lib/client/auth/schemas';
+import type { Infer, SuperValidated } from 'sveltekit-superforms/client';
+import { ApiServiceBase } from './base-api-service';
+
+class UserApiService extends ApiServiceBase {
+ constructor() {
+ super('/api/v1/user');
+ }
+
+ getCurrentUser() {
+ return this.http.get('/');
+ }
+
+ deleteCurrentUser() {
+ return this.http.delete('/');
+ }
+
+ updateUser(data: SuperValidated>['data']) {
+ return this.http.patch('/', data);
+ }
+ getUserById(id: string) {
+ return this.http.get(`/${id}`);
+ }
+}
+
+const api = Object.freeze(new UserApiService());
+export { api as UserApiService };
diff --git a/apps/web/src/lib/client/auth/schemas.ts b/apps/web/src/lib/client/auth/schemas.ts
new file mode 100644
index 0000000..ba21859
--- /dev/null
+++ b/apps/web/src/lib/client/auth/schemas.ts
@@ -0,0 +1,31 @@
+import { ALLOWED_STRINGS, ERROR_MESSAGE } from '@cloudkit/ui-core';
+import { z } from 'zod';
+
+export const MAX_FILE_SIZE = 500000;
+export const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
+export const ImageSchema = z.preprocess(
+ (value) => (Array.isArray(value) ? value : [value]),
+
+ z
+ .any()
+ .refine((files) => files?.length == 1, 'Image is required.')
+ .refine((files) => files?.[0]?.size <= MAX_FILE_SIZE, `Max file size is 5MB.`)
+ .refine(
+ (files) => ACCEPTED_IMAGE_TYPES.includes(files?.[0]?.type),
+ '.jpg, .jpeg, .png and .webp files are accepted.'
+ )
+);
+
+export const RegisterUserSchema = z.object({
+ firstName: z.string().min(1).max(32).regex(ALLOWED_STRINGS, ERROR_MESSAGE),
+ lastName: z.string().min(1).max(32).regex(ALLOWED_STRINGS, ERROR_MESSAGE),
+ password: z.string().min(8),
+ confirmPassword: z.string().min(8),
+ email: z.string().email().min(3),
+ avatar: z.instanceof(File).optional() // only optional because we validate the form before we add a missing avatar
+});
+
+export const AuthenticateUserSchema = z.object({
+ email: z.string().email().min(3),
+ password: z.string().min(8)
+});
diff --git a/apps/web/src/lib/components/molecues/user/user-card-list.svelte b/apps/web/src/lib/components/molecues/user/user-card-list.svelte
new file mode 100644
index 0000000..913b866
--- /dev/null
+++ b/apps/web/src/lib/components/molecues/user/user-card-list.svelte
@@ -0,0 +1,27 @@
+
+
+List of Example Users
+
+ {#each users as { id, avatar, firstName }}
+ {#if avatar && avatar.updatedAt}
+
+
+
+ {/if}
+ {/each}
+
diff --git a/apps/web/src/lib/components/molecues/user/user-card-list.test.ts b/apps/web/src/lib/components/molecues/user/user-card-list.test.ts
new file mode 100644
index 0000000..5d16a18
--- /dev/null
+++ b/apps/web/src/lib/components/molecues/user/user-card-list.test.ts
@@ -0,0 +1,26 @@
+import { render, screen } from '@testing-library/svelte';
+
+import { expect, test } from 'vitest';
+
+import UserCardList from './user-card-list.svelte';
+test('button with event', async () => {
+ render(UserCardList, {
+ users: [
+ {
+ createdAt: new Date(),
+ firstName: 'test',
+ id: '123',
+ lastName: 'test',
+ avatar: {
+ url: 'test'
+ },
+ email: 'test@test.com',
+ firstTime: true,
+ verified: true,
+ updatedAt: new Date()
+ }
+ ]
+ });
+ const text = screen.queryByText(/List of Example Users/);
+ expect(text).toBeInTheDocument();
+});
diff --git a/apps/web/src/lib/components/molecues/user/user-details.svelte b/apps/web/src/lib/components/molecues/user/user-details.svelte
new file mode 100644
index 0000000..63fe710
--- /dev/null
+++ b/apps/web/src/lib/components/molecues/user/user-details.svelte
@@ -0,0 +1,247 @@
+
+
+
+
+ Delete Account
+
diff --git a/apps/web/src/lib/components/organisms/modals/authenticate-modal.svelte b/apps/web/src/lib/components/organisms/modals/authenticate-modal.svelte
new file mode 100644
index 0000000..5dc18bf
--- /dev/null
+++ b/apps/web/src/lib/components/organisms/modals/authenticate-modal.svelte
@@ -0,0 +1,125 @@
+
+
+
diff --git a/src/lib/components/organisms/modals/errorModal.svelte b/apps/web/src/lib/components/organisms/modals/error-modal.svelte
similarity index 81%
rename from src/lib/components/organisms/modals/errorModal.svelte
rename to apps/web/src/lib/components/organisms/modals/error-modal.svelte
index 6e3b5d6..6939d0c 100644
--- a/src/lib/components/organisms/modals/errorModal.svelte
+++ b/apps/web/src/lib/components/organisms/modals/error-modal.svelte
@@ -1,7 +1,7 @@
+
+
diff --git a/apps/web/src/lib/components/organisms/navbar/protected-navbar.svelte b/apps/web/src/lib/components/organisms/navbar/protected-navbar.svelte
new file mode 100644
index 0000000..60e26df
--- /dev/null
+++ b/apps/web/src/lib/components/organisms/navbar/protected-navbar.svelte
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Profile
+
+
+ $deleteSessionMutation.mutate()}
+ >
+ {#if $deleteSessionMutation.isPending}
+
+ {:else}
+ Sign Out
+ {/if}
+
+
+
+
+
+
+
diff --git a/apps/web/src/lib/components/organisms/navbar/public-navbar.svelte b/apps/web/src/lib/components/organisms/navbar/public-navbar.svelte
new file mode 100644
index 0000000..3dffbf9
--- /dev/null
+++ b/apps/web/src/lib/components/organisms/navbar/public-navbar.svelte
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+ Sign In
+
+
+
+ Register
+
+
+
+
+
diff --git a/apps/web/src/lib/components/root-conainer.svelte b/apps/web/src/lib/components/root-conainer.svelte
new file mode 100644
index 0000000..8803005
--- /dev/null
+++ b/apps/web/src/lib/components/root-conainer.svelte
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/lib/components/sections/aboutThisProject.svelte b/apps/web/src/lib/components/sections/about-this-project.svelte
similarity index 100%
rename from src/lib/components/sections/aboutThisProject.svelte
rename to apps/web/src/lib/components/sections/about-this-project.svelte
diff --git a/src/lib/components/sections/authFlow.svelte b/apps/web/src/lib/components/sections/auth-flow.svelte
similarity index 100%
rename from src/lib/components/sections/authFlow.svelte
rename to apps/web/src/lib/components/sections/auth-flow.svelte
diff --git a/src/lib/components/sections/dataProxy.svelte b/apps/web/src/lib/components/sections/data-proxy.svelte
similarity index 71%
rename from src/lib/components/sections/dataProxy.svelte
rename to apps/web/src/lib/components/sections/data-proxy.svelte
index 93eec45..a1d94fb 100644
--- a/src/lib/components/sections/dataProxy.svelte
+++ b/apps/web/src/lib/components/sections/data-proxy.svelte
@@ -4,9 +4,12 @@
As previously mentioned, prisma doesn't a direct connection to Databases form edge functions.
You need to create a Data Proxy , which does some prisma magic and then you can use Prisma in Edge Functions. This is why there
- are two prisma schemas. The "normal" way of defining your data source is this
+ class="anchor"
+ >
+ Data Proxy
+
+ , which does some prisma magic and then you can use Prisma in Edge Functions. This is why there are
+ two prisma schemas. The "normal" way of defining your data source is this
@@ -24,9 +27,11 @@
{'}'}
- The DATABASE_URL
refers to the standard Database URL and DATA_PROXY
+ The DATABASE_URL
+ refers to the standard Database URL and
+ DATA_PROXY
refers to the connection string you generate when creating a
- Cloud Prisma Project
+ Cloud Prisma Project
- Its free for mostly everything.
diff --git a/apps/web/src/lib/components/sections/github-actions.svelte b/apps/web/src/lib/components/sections/github-actions.svelte
new file mode 100644
index 0000000..88fbc4e
--- /dev/null
+++ b/apps/web/src/lib/components/sections/github-actions.svelte
@@ -0,0 +1,17 @@
+
+ Github Actions Environemnts
+
+ There are two workflow files. PR.yml
+ and
+ MERGE_MASTER.yml
+ . The
+ PR.yml
+ is used for all pull requests and the
+ MERGE_MASTER.yml
+ is used for merging to the master branch.
+
+
+ Both workflows are set up to deploy all three apps to their respective cloudflare projects,
+ including feature branch deployments.
+
+
diff --git a/src/lib/components/sections/pr.svelte b/apps/web/src/lib/components/sections/pr.svelte
similarity index 78%
rename from src/lib/components/sections/pr.svelte
rename to apps/web/src/lib/components/sections/pr.svelte
index 4dfb223..73dab78 100644
--- a/src/lib/components/sections/pr.svelte
+++ b/apps/web/src/lib/components/sections/pr.svelte
@@ -10,7 +10,8 @@
INSTALL
- Installs depdendencies from pnpm-lock.yaml
and caches them
+ Installs depdendencies from pnpm-lock.yaml
+ and caches them
@@ -27,15 +28,20 @@
3.
UNIT_TEST
-
Runs unit tests using vitest
+
+ Runs unit tests using vitest
+
4.
-
E2E_TEST
-
Launches docker containers, seeds the database and thumbor and runs all e2e tests
+
PUBLISH
+
+ There are three publish jobs. One for the web-app, one for the swagger-ui and one for the
+ storybook.
+
diff --git a/apps/web/src/lib/components/sections/prerequisites.svelte b/apps/web/src/lib/components/sections/prerequisites.svelte
new file mode 100644
index 0000000..f28fa5a
--- /dev/null
+++ b/apps/web/src/lib/components/sections/prerequisites.svelte
@@ -0,0 +1,12 @@
+
+ Prerequisites
+
+ Becase PR.yaml
+ action uses docker containers, you'll have to configure the following Github Action Secrets in a
+ CI
+ environment in order for the Workflow to run through without errors. Example Values of these can
+ be cound in the
+ .env.ci
+ file.
+
+
diff --git a/apps/web/src/lib/components/sections/prisma-code-org.svelte b/apps/web/src/lib/components/sections/prisma-code-org.svelte
new file mode 100644
index 0000000..8f7a582
--- /dev/null
+++ b/apps/web/src/lib/components/sections/prisma-code-org.svelte
@@ -0,0 +1,16 @@
+
+ Prisma Code Organization
+
+ I couldn't find a good way to keep all my prisma relevant code together so I came up with the
+ following solution. There are two Repository
+ classes. One for managing Users and one for managing Images. I've split the Prisma code up like this
+ in order to keep the primsa imports to a bare minimum. All DB Actions I do via these classes and
+ they are called only from the server.
+
+
+ Note:
+ The
+ ImageRepository
+ can interchangebly be used with the local thumbor container or with Cloudflare's Image Service.
+
+
diff --git a/apps/web/src/lib/components/sections/road-map.svelte b/apps/web/src/lib/components/sections/road-map.svelte
new file mode 100644
index 0000000..298a9db
--- /dev/null
+++ b/apps/web/src/lib/components/sections/road-map.svelte
@@ -0,0 +1,48 @@
+
+ Roadmap
+
+ There are a few to-do items here, but nothing that wills top you from using this as a starting
+ point. Feel free to open issues/PRs if you have ideas and want to address issues.
+
+
+
+
1.
+
+
Remove Lucia Auth
+
+ Unforunately Lucia Auth has been depcricated and will no longer receiver updates beyond
+ V3. Therefore I'm going to migrate this template away from lucia. I don't know to what
+ yet, so I'm open for suggestions.
+
+
+
+
+
+
2.
+
+
Redis Sessions
+
+ Lucia V3 dropped support for redis session management. I'd like to get this back as I feel
+ that keeping sessions in redis and not hitting the DB for every request is good the
+ performance and would keep DB costs lower.
+
+
+
+
+
+
+
diff --git a/apps/web/src/lib/components/sections/run-local.svelte b/apps/web/src/lib/components/sections/run-local.svelte
new file mode 100644
index 0000000..42d1663
--- /dev/null
+++ b/apps/web/src/lib/components/sections/run-local.svelte
@@ -0,0 +1,85 @@
+
+ Running this locally
+
+
+
1.
+
+
+ Create a .env.development
+
+
+
+ It's used to configure access to the local thumbor instance (for storing and retrieving
+ images) and the local database
+
+
+
+
+
+
2.
+
+
+ Run pnpm dev
+
+
+
+
+ The turbo.json
+ configuration is set up with all the appropriate dependencies and will take care of the following.
+
+
+
+ 1.
+
+ Pull and start two docker containers (thumbor for image storage and postgresql for
+ user/data storage)
+
+
+
+ 2.
+
+ Generate the prisma client based on the schema from the db-schema
+ module
+
+
+
+ 3.
+
+ Push the gererated prisma client to the local psql container.
+ Note: This might fail if the containers are not up and running yet. If it does,
+ just run pnpm dev
+ a couple of times until it takes.
+
+
+
+
+ 4.
+
+ Generate zod schemas, which are consumed in the service-contract
+ module.
+
+
+
+ 5.
+
+ Launch the swagger-ui sveltekit app. This allows you to test out your API endpoints
+ locally.
+
+
+
+ 6.
+
+ Launch storybook so you can keep an eye on all your current components and their
+ states.
+
+
+
+ 7.
+ Launch the web-app.
+
+
+
+
+
+
+
diff --git a/src/lib/components/sections/runProd.svelte b/apps/web/src/lib/components/sections/run-prod.svelte
similarity index 58%
rename from src/lib/components/sections/runProd.svelte
rename to apps/web/src/lib/components/sections/run-prod.svelte
index bf18e7c..a481b76 100644
--- a/src/lib/components/sections/runProd.svelte
+++ b/apps/web/src/lib/components/sections/run-prod.svelte
@@ -11,14 +11,8 @@
Cloudflare Pages
There is a free tier but even the paid tier isn't very expensive.
- You'll also have to create the project in Cloudflare and set it up. For the sake of
- convenience I've included the cloudflare:init
script. You'll have to set up
- the .env.production
file.
-
-
- Note: If you look at
- createCloudflareProject.js
you'll see that the build & publishing configuration
- is disabled. This is because the Github Action will take care of that.
+ You'll also have to create the projects in Cloudflare and set them up. The project is set
+ up to run three cloudflare projects. The web-app, the swagger-ui and storybook.
@@ -34,8 +28,11 @@
variations (the variations do not count to the 100'000 image limit). Delivering images is
1 USD for every 100'000 images. There's also a enterprise version. Check out the details here .
+ href="https://developers.cloudflare.com/images/pricing/"
+ >
+ here
+
+ .
@@ -49,35 +46,20 @@
free tier. They don't charge you for a month if the bill is under 50 Cent. It's very
affordable for what you're getting. Seriously, check them out .
+ href="https://neon.tech/pricing"
+ >
+ check them out
+
+ .
+
4.
-
-
Upstash
-
- They have a generous free tier. Personally, I use the "Pay As You Go" option and set a low
- budget limit. Check out their pricing
here .
-
-
-
-
-
5.
Prisma Cloud
-
- You will not be able to hack your way around this one because prisma's edge client doesn't
- allow connecting to a database directly. They have a generous free tier and that's the
- only thing I use them for. Create a project [here](https://cloud.prisma.io/), create the
- Connection String and use this. Their DataExplorer will then be linked to the production
- database, which can be quite handy as well.
-
+
You'll have to create a project there and connect it to the database.
diff --git a/src/lib/components/sections/scripts.svelte b/apps/web/src/lib/components/sections/scripts.svelte
similarity index 76%
rename from src/lib/components/sections/scripts.svelte
rename to apps/web/src/lib/components/sections/scripts.svelte
index c9cc6cc..3889255 100644
--- a/src/lib/components/sections/scripts.svelte
+++ b/apps/web/src/lib/components/sections/scripts.svelte
@@ -1,12 +1,15 @@
- Scripts in package.json
+
+ Scripts in package.json
+
- I've included a lot of scripts in the
package.json
file. The mostly depend on each
- other. The naming follows this pattern in general
+ I've included a lot of scripts in the
package.json
+ file. The mostly depend on each other. The naming follows this pattern in general
[COMMAND]:[ACTION]:[ENV]
For example, If I want to generate my prisma schema for local development I would run
-
pnpm prisma:gen:dev
.
+
pnpm prisma:gen:dev
+ .
Here are some useful scripts
@@ -16,10 +19,10 @@
pnpm psql:dump
- Dumps the content of the current database into a cloudkit-db-dump.sql
file.
- You can
- ./cloudkit-db-dump.sql:/docker-entrypoint-initdb.d/init.sql
which will initialize
- the psql container with those contents on start up every time.
+ Dumps the content of the current database into a cloudkit-db-dump.sql
+ file. You can
+ ./cloudkit-db-dump.sql:/docker-entrypoint-initdb.d/init.sql
+ which will initialize the psql container with those contents on start up every time.
@@ -29,7 +32,8 @@
pnpm psql:restore
- Restores from the latest cloudkit-db-dump.sql
file, if it exists.
+ Restores from the latest cloudkit-db-dump.sql
+ file, if it exists.
@@ -48,8 +52,12 @@
pnpm clean
- Deletes the ./playwright-report
, ./.wrangler
and
- ./.svelte-kit
folders for a clean slate
+ Deletes the ./playwright-report
+ ,
+ ./.wrangler
+ and
+ ./.svelte-kit
+ folders for a clean slate
diff --git a/src/lib/components/sections/tech.svelte b/apps/web/src/lib/components/sections/tech.svelte
similarity index 56%
rename from src/lib/components/sections/tech.svelte
rename to apps/web/src/lib/components/sections/tech.svelte
index 33e3958..e2f935f 100644
--- a/src/lib/components/sections/tech.svelte
+++ b/apps/web/src/lib/components/sections/tech.svelte
@@ -1,11 +1,11 @@
- This is a complete Sveltekit Template designed to help you release your SvelteKit App on
- Cloudflare Pages using the following services:
+ This is a complete Enterprise-Grade Sveltekit Template designed to help you release your
+ SvelteKit App, SwaggerUI and Storybook on Cloudflare Pages.
Cloudflare Pages
+ Cloudflare Pages
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ OpenAPI Initiative
+
+
+
+
+
+
+ Prisma
+
+
+
+
GitHub Actions
+
GitHub Actions
+
+
Playwright
+ Playwright
+
+
Vite
-
-
-
-
@@ -162,10 +254,12 @@
fill="#4169E1"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
- >PostgreSQL
+ PostgreSQL
+
+
Cloudflare
+ Cloudflare
+
+
@@ -193,11 +289,12 @@
height="40px"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 40 40"
- >
+
+ >
+
diff --git a/src/lib/components/sections/welcome.svelte b/apps/web/src/lib/components/sections/welcome.svelte
similarity index 92%
rename from src/lib/components/sections/welcome.svelte
rename to apps/web/src/lib/components/sections/welcome.svelte
index af59ed2..5d1d64e 100644
--- a/src/lib/components/sections/welcome.svelte
+++ b/apps/web/src/lib/components/sections/welcome.svelte
@@ -1,3 +1,6 @@
+
+
diff --git a/apps/web/src/lib/queries/fetch-random-avatar-query-config.ts b/apps/web/src/lib/queries/fetch-random-avatar-query-config.ts
new file mode 100644
index 0000000..e659a53
--- /dev/null
+++ b/apps/web/src/lib/queries/fetch-random-avatar-query-config.ts
@@ -0,0 +1,13 @@
+export const fetchRandomAvatarQueryConfig = {
+ queryKey: ['Avatar'],
+ queryFn: async () => {
+ const resp = await fetch('https://picsum.photos/736');
+
+ const image = (await resp.blob()) as unknown as File;
+ return image;
+ //
+ // const converted = await convertFileToBase64(image);
+ // return converted;
+ },
+ enabled: false
+};
diff --git a/apps/web/src/lib/server/auth/lucia.ts b/apps/web/src/lib/server/auth/lucia.ts
new file mode 100644
index 0000000..aae5bbb
--- /dev/null
+++ b/apps/web/src/lib/server/auth/lucia.ts
@@ -0,0 +1,52 @@
+import { db } from '@lib/server/repository/prisma-client';
+import { PrismaAdapter } from '@lucia-auth/adapter-prisma';
+import { Lucia } from 'lucia';
+
+/**
+ * Returns a Lucia instance with the appropriate adapter and middleware based on the environment.
+ */
+async function getConfiguration() {
+ const adapter = new PrismaAdapter(db.session, db.user);
+ const lucia = new Lucia(adapter, {
+ sessionCookie: {
+ attributes: {
+ // set to `true` when using HTTPS
+ secure: process.env.NODE_ENV === 'production'
+ }
+ },
+
+ getUserAttributes: (attributes) => {
+ return {
+ firstName: attributes.firstName,
+ lastName: attributes.lastName,
+ email: attributes.email,
+ createdAt: attributes.createdAt,
+ updatedAt: attributes.updatedAt,
+ verified: attributes.verified,
+ firstTime: attributes.firstTime
+ };
+ }
+ });
+ return lucia;
+}
+
+/**
+ * The instance of Lucia authentication middleware.
+ */
+export const auth = await getConfiguration();
+declare module 'lucia' {
+ interface Register {
+ Lucia: typeof auth;
+ DatabaseUserAttributes: DatabaseUserAttributes;
+ }
+}
+
+type DatabaseUserAttributes = {
+ firstName: string;
+ lastName: string;
+ email: string;
+ verified: boolean;
+ createdAt: Date;
+ updatedAt: Date;
+ firstTime: boolean;
+};
diff --git a/apps/web/src/lib/server/errors/index.ts b/apps/web/src/lib/server/errors/index.ts
new file mode 100644
index 0000000..3bb8113
--- /dev/null
+++ b/apps/web/src/lib/server/errors/index.ts
@@ -0,0 +1,23 @@
+export class AccessDeniedError extends Error {
+ constructor(resource: string) {
+ super(`Access denied to ${resource}`);
+ }
+}
+
+export class InvalidSessionError extends Error {
+ constructor() {
+ super('Invalid Session');
+ }
+}
+
+export class ResourceNotFoundError extends Error {
+ constructor(resource: string) {
+ super(`Resources not found: ${resource}`);
+ }
+}
+
+export class CollectionAlreadyExistsError extends Error {
+ constructor(collectionName: string) {
+ super(`Collection with this name already exists: ${collectionName}`);
+ }
+}
diff --git a/apps/web/src/lib/server/middleware/rate-limiter.ts b/apps/web/src/lib/server/middleware/rate-limiter.ts
new file mode 100644
index 0000000..5ba468f
--- /dev/null
+++ b/apps/web/src/lib/server/middleware/rate-limiter.ts
@@ -0,0 +1,14 @@
+import { RateLimiter } from 'sveltekit-rate-limiter/server';
+
+export const limiter = new RateLimiter({
+ // A rate is defined as [number, unit]
+ IP: [10, 'h'], // IP address limiter
+ IPUA: [5, 'm'], // IP + User Agent limiter
+ cookie: {
+ // Cookie limiter
+ name: 'limiterid', // Unique cookie name for this limiter
+ secret: 'SECRETKEY-SERVER-ONLY', // Use $env/static/private
+ rate: [2, 'm'],
+ preflight: true // Require preflight call (see load function)
+ }
+});
diff --git a/apps/web/src/lib/server/middleware/validate.ts b/apps/web/src/lib/server/middleware/validate.ts
new file mode 100644
index 0000000..538fe14
--- /dev/null
+++ b/apps/web/src/lib/server/middleware/validate.ts
@@ -0,0 +1,156 @@
+import { AuthenticateUserSchema, RegisterUserSchema } from '@lib/client/auth/schemas';
+import { type Cookies } from '@sveltejs/kit';
+import { z } from 'zod';
+import { auth } from '../auth/lucia';
+import {
+ AccessDeniedError,
+ CollectionAlreadyExistsError,
+ InvalidSessionError,
+ ResourceNotFoundError
+} from '../errors';
+
+function getSchema(request: Request): z.AnyZodObject | z.ZodOptional {
+ const METHODS = {
+ GET: 'GET',
+ POST: 'POST',
+ PUT: 'PUT',
+ PATCH: 'PATCH',
+ DELETE: 'DELETE'
+ } as const;
+ switch (request.url) {
+ case '/api/v1/auth':
+ switch (request.method) {
+ case METHODS.PUT: {
+ return AuthenticateUserSchema;
+ }
+ case METHODS.PATCH: {
+ return RegisterUserSchema;
+ }
+ case METHODS.DELETE: {
+ return AuthenticateUserSchema;
+ }
+ default:
+ return z.object({});
+ }
+
+ case 'api/v1/auth/session-id-regex-here': {
+ switch (request.method) {
+ case METHODS.DELETE: {
+ return AuthenticateUserSchema;
+ }
+ default:
+ return z.object({});
+ }
+ }
+ case '/api/v1/user':
+ switch (request.method) {
+ case METHODS.GET: {
+ return RegisterUserSchema;
+ }
+ case METHODS.PATCH: {
+ return RegisterUserSchema;
+ }
+ default:
+ return z.object({});
+ }
+ default:
+ return z.object({});
+ }
+}
+class RequestValidator {
+ async validateBody>(
+ request: Request
+ ): Promise {
+ try {
+ const schema = getSchema(request);
+ const data = await request.json();
+ return (await schema.parseAsync(data)) as T;
+ } catch (parseError) {
+ let err = parseError;
+ if (err instanceof z.ZodError) {
+ err = err.issues.map((e) => ({ path: e.path[0], message: e.message }));
+ }
+ throw err;
+ }
+ }
+
+ async validateParams(request: Request, params: Record) {
+ try {
+ const schema = getSchema(request);
+
+ await schema.parseAsync(params);
+ } catch (parseError) {
+ let err = parseError;
+ if (err instanceof z.ZodError) {
+ err = err.issues.map((e) => ({ path: e.path[0], message: e.message }));
+ }
+ throw err;
+ }
+ }
+
+ /**
+ *
+ * @param cookies
+ * @returns {Promise>}
+ * @throws {Error} If the session is invalid
+ * //TODO Remove with implementation of zen stack
+ */
+ async validateSession(cookies: Cookies): Promise> {
+ const { user, session } = await auth.validateSession(cookies.get(auth.sessionCookieName) ?? '');
+ if (!user) {
+ throw new InvalidSessionError();
+ }
+ return { user, session };
+ }
+
+ async validateRequest>({
+ cookies,
+
+ request
+ }: {
+ cookies?: Cookies;
+ params?: Partial>;
+ request?: Request;
+ }) {
+ let seessionValudation;
+ let bodyValidation;
+ if (cookies) {
+ seessionValudation = await this.validateSession(cookies);
+ }
+ if (request) {
+ bodyValidation = await this.validateBody(request);
+ }
+ return {
+ session: seessionValudation,
+ body: bodyValidation
+ };
+ }
+
+ handleError(
+ e: AccessDeniedError | InvalidSessionError | ResourceNotFoundError | z.ZodError | unknown
+ ) {
+ if (e instanceof InvalidSessionError) {
+ return new Response('Invalid Session', { status: 401 });
+ }
+ if (e instanceof ResourceNotFoundError) {
+ return new Response('Resource not found', { status: 404 });
+ }
+ if (e instanceof AccessDeniedError) {
+ return new Response('Access denied', { status: 403 });
+ }
+ if (e instanceof z.ZodError) {
+ return new Response(JSON.stringify(e), { status: 400 });
+ }
+ if (e instanceof CollectionAlreadyExistsError) {
+ return new Response(e.message, { status: 409 });
+ }
+
+ return new Response(
+ "Something went wrong on our end. We've been notivied and will look into it",
+ { status: 500 }
+ );
+ }
+}
+
+const frozen = Object.freeze(new RequestValidator());
+export { frozen as RequestValidator };
diff --git a/apps/web/src/lib/server/repository/image-repository.ts b/apps/web/src/lib/server/repository/image-repository.ts
new file mode 100644
index 0000000..e6b68f3
--- /dev/null
+++ b/apps/web/src/lib/server/repository/image-repository.ts
@@ -0,0 +1,243 @@
+import type { Image } from '@cloudkit/ui-core';
+import { generateId } from 'lucia';
+
+import { IMAGE_API_TOKEN } from '$env/static/private';
+import { PUBLIC_IMAGE_API_URL } from '$env/static/public';
+import type { CloudflareImageDeleteResponse, UserWithRelations } from '@cloudkit/ui-core';
+import { isDevOrCi } from '@cloudkit/ui-core';
+import { db } from './prisma-client';
+
+/**
+ * Image paths consist of the following items in the following order
+ * 1. userId
+ * 2. roomId
+ * 3. boxId
+ * 4. imteId
+ *
+ */
+export type ImageTypes = `${string}/${string}/${string}/${string}` | `${string}/avatar`;
+
+/**
+ * @interface ImageRepositoryCreate
+ * @description Interface for the create method of the ImageRepository class.
+ * @property {Prisma.ImageCreateInput} data - The data to create the image with.
+ * @property {File} image - The image file to upload.
+ * @property {ImageTypes} type - The type of the image.
+ */
+
+/**
+ * @class ImageRepository
+ * @description Class representing the image repository.
+ */
+class ImageRepository {
+ static _instanceCache: ImageRepository;
+
+ static instance(): ImageRepository {
+ if (!this._instanceCache) {
+ this._instanceCache = new this();
+ }
+
+ return this._instanceCache;
+ }
+
+ /**
+ * Finds an image by its ID.
+ * @param id - The ID of the image to find.
+ * @returns A Promise that resolves to the found image, or null if no image was found.
+ */
+ findImageById(id: string): Promise {
+ return db.image.findUniqueOrThrow({
+ where: {
+ id: id
+ }
+ });
+ }
+
+ /**
+ * @method create
+ * @description Creates a new image.
+ * @param {File} The file to upload
+ * @returns {Promise} A Promise that resolves to the created image URL.
+ */
+ async create(image: File): Promise {
+ await this.postToImageService(image);
+ const id = generateId(31);
+
+ return db.image.create({ data: { id: id, url: `${PUBLIC_IMAGE_API_URL}/${id}` } });
+ }
+
+ async deleteFromCloudflare(url: string): Promise {
+ return (await fetch(url, {
+ method: 'DELETE',
+ headers: {
+ Authorization: `Bearer ${IMAGE_API_TOKEN}`
+ }
+ })) as unknown as CloudflareImageDeleteResponse;
+ }
+
+ /**
+ * @method deleteById
+ * @description Deletes an image by its ID.
+ * @param {string} url - The ID of the image to delete.
+ * @returns {Promise} A Promise that resolves to the deleted image.
+ */
+ async deleteByUrl(url: string): Promise {
+ const response = await fetch(url, {
+ method: 'DELETE',
+ headers: {
+ Authorization: `Bearer ${IMAGE_API_TOKEN}`
+ }
+ });
+ if (response.status === 200) {
+ return await db.image.delete({ where: { url: url } });
+ }
+ return await db.image.findFirstOrThrow({ where: { url: url } });
+ }
+
+ /**
+ * @method findAll
+ * @description Finds all images.
+ * @returns {Promise} A Promise that resolves to an array of all images.
+ */
+ findAll(): Promise {
+ return db.image.findMany();
+ }
+
+ /**
+ * @method findById
+ * @description Finds an image by its ID.
+ * @param {string} id - The ID of the image to find.
+ * @returns {Promise} A Promise that resolves to the found image.
+ */
+ findById(id: string): Promise {
+ return db.image.findUniqueOrThrow({ where: { id } });
+ }
+
+ /**
+ * @method update
+ * @description Updates an image.
+ * @param {NonNullable} data - The data to update the image with.
+ * @returns {Promise} A Promise that resolves to the updated image.
+ */
+ async update({ data, image }: { data: UserWithRelations; image: File }): Promise {
+ await this.patchToImageService(image, data.id);
+
+ if (!data.avatar) {
+ throw new Error('No avatar found in user');
+ }
+ return await db.image.update({
+ where: { id: data.id },
+ data: { updatedAt: new Date(), url: data.avatar.url }
+ });
+ }
+
+ async postToImageService(image: string | File): Promise<{ url: string; id: string }> {
+ const id = generateId(31);
+
+ if (isDevOrCi) {
+ try {
+ await fetch(`${PUBLIC_IMAGE_API_URL}/${id}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-prefix': 'image/webp',
+ Slug: `${`${PUBLIC_IMAGE_API_URL}/${id}`}.webp`
+ },
+ body: image
+ });
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.info('error posting to image service');
+ }
+
+ return { url: `${PUBLIC_IMAGE_API_URL}/${id}`, id: id };
+ } else {
+ const form = new FormData();
+
+ form.append('file', new Blob([image]));
+ form.append('id', id);
+
+ const workerResponse = await fetch(PUBLIC_IMAGE_API_URL, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${IMAGE_API_TOKEN}`
+ },
+ body: form
+ });
+ const response = await workerResponse.json();
+
+ return { url: `${PUBLIC_IMAGE_API_URL}/${response.result.id}`, id: response.result.id };
+ }
+ }
+
+ async patchToImageService(image: File, id: string): Promise {
+ if (isDevOrCi) {
+ await fetch(`${PUBLIC_IMAGE_API_URL}/${id}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-prefix': 'image/webp',
+ Slug: `${PUBLIC_IMAGE_API_URL}/${id}.webp`
+ },
+ body: image
+ });
+ await db.image.update({
+ where: {
+ id
+ },
+ data: {
+ updatedAt: new Date()
+ }
+ });
+ return id as string;
+ } else {
+ // Delete the old image first
+ const oldImage = await db.image.findFirstOrThrow({ where: { id } });
+ await this.deleteFromCloudflare(oldImage.url);
+ // Upload the new image to the same URL
+ const form = new FormData();
+
+ form.append('file', new Blob([image]));
+ form.append('id', id);
+ const workerResponse = await fetch(PUBLIC_IMAGE_API_URL, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${IMAGE_API_TOKEN}`
+ },
+ body: form
+ });
+ const resppnse = await workerResponse.json();
+ await db.image.update({
+ where: {
+ id
+ },
+ data: {
+ updatedAt: new Date()
+ }
+ });
+ return resppnse.result.id;
+ }
+ }
+
+ /**
+ * Deletes an image by its ID.
+ * @param id - The ID of the image to delete.
+ * @returns A promise that resolves when the image is deleted.
+ */
+ async deleteById(id: string) {
+ const image = await this.findById(id);
+ await this.deleteByUrl(image.url);
+ return await db.image.delete({ where: { id } });
+ }
+
+ async findByUserId(userId: string): Promise {
+ return db.image.findFirst({
+ where: {
+ user: {
+ id: userId
+ }
+ }
+ });
+ }
+}
+
+const repo = Object.freeze(new ImageRepository());
+export { repo as ImageRepository };
diff --git a/src/lib/server/repository/prismaClient.ts b/apps/web/src/lib/server/repository/prisma-client.ts
similarity index 79%
rename from src/lib/server/repository/prismaClient.ts
rename to apps/web/src/lib/server/repository/prisma-client.ts
index a0edccd..00b7cfd 100644
--- a/src/lib/server/repository/prismaClient.ts
+++ b/apps/web/src/lib/server/repository/prisma-client.ts
@@ -1,10 +1,10 @@
-import { dev } from '$app/environment';
-import { DATABASE_URL } from '$env/static/private';
-import { isDev } from '@lib/utils/general';
import { PrismaClient as PrismaClientNode, Prisma as PrismaNode } from '@prisma/client';
import { PrismaClient as PrismaClientEdge, Prisma as PrismaEdge } from '@prisma/client/edge';
import { withAccelerate } from '@prisma/extension-accelerate';
+import { DATABASE_URL } from '$env/static/private';
+import { isDevOrCi } from '@cloudkit/ui-core';
+
const prismaConfiguration = {
datasources: {
db: {
@@ -13,10 +13,10 @@ const prismaConfiguration = {
}
};
-export const db = isDev
+export const db = isDevOrCi
? new PrismaClientNode(prismaConfiguration).$extends(withAccelerate())
: new PrismaClientEdge(prismaConfiguration).$extends(withAccelerate());
-export const PrismaClientKnownRequestError = dev
+export const PrismaClientKnownRequestError = isDevOrCi
? PrismaNode.PrismaClientKnownRequestError
: PrismaEdge.PrismaClientKnownRequestError;
diff --git a/apps/web/src/lib/server/repository/user-repository.ts b/apps/web/src/lib/server/repository/user-repository.ts
new file mode 100644
index 0000000..a81f96d
--- /dev/null
+++ b/apps/web/src/lib/server/repository/user-repository.ts
@@ -0,0 +1,190 @@
+import { isDevOrCi } from '@cloudkit/ui-core';
+import { generateId, Scrypt } from 'lucia';
+
+import type { Image, User, UserWithRelations } from '@cloudkit/ui-core';
+import type { RegisterUserSchema } from '@lib/client/auth/schemas';
+import { Prisma } from '@prisma/client';
+import type { Infer, SuperValidated } from 'sveltekit-superforms/server';
+import { ImageRepository } from './image-repository';
+import { db } from './prisma-client';
+
+const findUerByIdSchema = Prisma.validator()({
+ include: {
+ avatar: {
+ select: {
+ url: true,
+ createdAt: true,
+ updatedAt: true
+ }
+ }
+ }
+});
+
+type FindUserByIdSchema = Prisma.UserGetPayload;
+
+class UserRepository {
+ /**
+ * Singleton instance of UserRepository.
+ */
+ static _instanceCache: UserRepository;
+
+ /**
+ * Returns the singleton instance of UserRepository.
+ */
+ static instance(): UserRepository {
+ if (!this._instanceCache) {
+ this._instanceCache = new this();
+ }
+
+ return this._instanceCache;
+ }
+
+ /**
+ * Finds a user by their ID.
+ * @param userId - The ID of the user to find.
+ * @returns The user with the specified ID.
+ * @throws An error if the user is not found.
+ */
+ findById(userId: string): Promise {
+ return db.user.findUniqueOrThrow({
+ where: {
+ id: userId
+ },
+ include: {
+ avatar: {
+ select: {
+ url: true,
+ createdAt: true,
+ updatedAt: true
+ }
+ }
+ }
+ });
+ }
+
+ /**
+ * Deletes a user by their ID.
+ * @param userId - The ID of the user to delete.
+ * @returns A promise that resolves when the user is deleted.
+ */
+ async deleteById(userId: string) {
+ const deletedUser = await db.user.delete({
+ where: {
+ id: userId
+ },
+ include: {
+ avatar: true
+ }
+ });
+
+ if (deletedUser?.avatar && !isDevOrCi) {
+ await ImageRepository.deleteFromCloudflare(deletedUser.avatar.url);
+ }
+ return true;
+ }
+
+ async create(
+ data: SuperValidated>['data']
+ ): Promise {
+ const id = generateId(31);
+ if (!data.avatar) {
+ throw new Error('No avatar found in user');
+ }
+ const { id: imageId, url } = await ImageRepository.postToImageService(data.avatar);
+ const hashedPassword = await new Scrypt().hash(data.password);
+
+ return db.user.create({
+ data: {
+ id: id,
+ hashedPassword: hashedPassword,
+ email: data.email,
+ firstName: data.firstName,
+ lastName: data.lastName,
+ verified: false,
+ firstTime: true,
+ avatar: {
+ create: { id: imageId, url: url }
+ }
+ },
+ include: {
+ avatar: true
+ }
+ });
+ }
+
+ findByEmail(email: string) {
+ return db.user.findUniqueOrThrow({
+ where: {
+ email
+ },
+ select: {
+ id: true,
+ firstName: true,
+ lastName: true,
+ email: true,
+ avatar: true,
+ createdAt: true,
+ updatedAt: true,
+ hashedPassword: true
+ }
+ });
+ }
+
+ updateFromSession(input: User): Promise {
+ return db.user.update({ where: { id: input.id }, data: { ...input } });
+ }
+
+ updateAvatar(user: User, image: Image): Promise {
+ return db.user.update({
+ where: {
+ id: user.id
+ },
+ data: {
+ avatar: {
+ connect: {
+ id: image.id
+ }
+ }
+ }
+ });
+ }
+
+ updateData(input: User): Promise {
+ return db.user.update({
+ where: {
+ id: input.id
+ },
+ data: {
+ email: input.email,
+ firstName: input.firstName,
+ lastName: input.lastName,
+ verified: input.verified,
+ firstTime: input.firstTime,
+ updatedAt: new Date()
+ }
+ });
+ }
+
+ exists(email: string): Promise {
+ return db.user
+ .findFirst({
+ where: {
+ email
+ }
+ })
+ .then(Boolean);
+ }
+ findByIdWithRelations(userId: string): Promise {
+ return db.user.findUniqueOrThrow({
+ where: {
+ id: userId
+ },
+ include: {
+ avatar: true
+ }
+ });
+ }
+}
+
+const repo = Object.freeze(new UserRepository());
+export { repo as UserRepository };
diff --git a/apps/web/src/lib/server/services/user-service.ts b/apps/web/src/lib/server/services/user-service.ts
new file mode 100644
index 0000000..c33223a
--- /dev/null
+++ b/apps/web/src/lib/server/services/user-service.ts
@@ -0,0 +1,34 @@
+import type { User, UserWithRelations } from '@cloudkit/ui-core';
+import type { RegisterUserSchema } from '@lib/client/auth/schemas';
+import type { Infer, SuperValidated } from 'sveltekit-superforms/server';
+import { UserRepository } from '../repository/user-repository';
+
+// TODO implement ZenStack to manage access
+class UserService {
+ static _instanceCache: UserService;
+
+ static instance(): UserService {
+ if (!this._instanceCache) {
+ this._instanceCache = new this();
+ }
+
+ return this._instanceCache;
+ }
+
+ async createUser(
+ data: SuperValidated>['data']
+ ): Promise {
+ return UserRepository.create(data);
+ }
+
+ async updateUser(data: User): Promise {
+ return UserRepository.updateFromSession({ ...data, firstTime: false });
+ }
+
+ async deleteUser(data: UserWithRelations): Promise {
+ return UserRepository.deleteById(data.id);
+ }
+}
+
+const repo = Object.freeze(new UserService());
+export { repo as UserService };
diff --git a/apps/web/src/lib/stores/constants.ts b/apps/web/src/lib/stores/constants.ts
new file mode 100644
index 0000000..7a673b7
--- /dev/null
+++ b/apps/web/src/lib/stores/constants.ts
@@ -0,0 +1,3 @@
+export const STORE_CONTEXTS = {
+ User: 'user'
+} as const;
diff --git a/apps/web/src/lib/stores/index.ts b/apps/web/src/lib/stores/index.ts
new file mode 100644
index 0000000..80b1636
--- /dev/null
+++ b/apps/web/src/lib/stores/index.ts
@@ -0,0 +1 @@
+export * from './user-store';
diff --git a/apps/web/src/lib/stores/user-store.ts b/apps/web/src/lib/stores/user-store.ts
new file mode 100644
index 0000000..10232ac
--- /dev/null
+++ b/apps/web/src/lib/stores/user-store.ts
@@ -0,0 +1,23 @@
+import { getContext, setContext } from 'svelte';
+import { writable, type Writable } from 'svelte/store';
+import { STORE_CONTEXTS } from './constants';
+import type { UserWithRelations } from '@cloudkit/ui-core';
+
+export function initUserStore(user: T | null) {
+ const store = writable(user);
+ setContext(STORE_CONTEXTS.User, store);
+ return store;
+}
+
+export function getUserStore<
+ T extends Record = NonNullable
+>(storeExtension?: T) {
+ const userStore: Writable = getContext(STORE_CONTEXTS.User);
+ if (storeExtension) {
+ userStore.update((user) => ({
+ ...user,
+ ...storeExtension
+ }));
+ }
+ return userStore;
+}
diff --git a/apps/web/src/routes/(protected)/+layout.server.ts b/apps/web/src/routes/(protected)/+layout.server.ts
new file mode 100644
index 0000000..5004383
--- /dev/null
+++ b/apps/web/src/routes/(protected)/+layout.server.ts
@@ -0,0 +1,21 @@
+import { PATHS } from '@cloudkit/ui-core';
+import { auth } from '@lib/server/auth/lucia';
+import { UserRepository } from '@lib/server/repository/user-repository';
+import { redirect } from '@sveltejs/kit';
+
+import type { LayoutServerLoad } from './$types';
+
+export const load = (async ({ url, cookies }) => {
+ const { pathname } = url;
+
+ const sessionId = cookies.get(auth.sessionCookieName);
+ if (!sessionId) {
+ redirect(302, PATHS.ROOT);
+ }
+
+ const { user } = await auth.validateSession(sessionId);
+ return {
+ user: await UserRepository.findById(user?.id ?? ''),
+ pathname
+ };
+}) satisfies LayoutServerLoad;
diff --git a/apps/web/src/routes/(protected)/+layout.svelte b/apps/web/src/routes/(protected)/+layout.svelte
new file mode 100644
index 0000000..672ab5f
--- /dev/null
+++ b/apps/web/src/routes/(protected)/+layout.svelte
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(protected)/api/v1/user/+server.ts b/apps/web/src/routes/(protected)/api/v1/user/+server.ts
new file mode 100644
index 0000000..bb22144
--- /dev/null
+++ b/apps/web/src/routes/(protected)/api/v1/user/+server.ts
@@ -0,0 +1,29 @@
+import { auth } from '@lib/server/auth/lucia';
+import { InvalidSessionError } from '@lib/server/errors';
+import { RequestValidator } from '@lib/server/middleware/validate';
+import { UserService } from '@lib/server/services/user-service';
+import type { RequestHandler } from '@sveltejs/kit';
+
+export const GET: RequestHandler = async () => {
+ return new Response(null, { status: 500 });
+};
+
+export const PATCH: RequestHandler = async () => {
+ return new Response(null, { status: 500 });
+};
+
+export const DELETE: RequestHandler = async ({ cookies }) => {
+ try {
+ const { user, session } = await RequestValidator.validateSession(cookies);
+ const success = await UserService.deleteUser(user!);
+ if (success) {
+ await auth.invalidateSession(session!.id);
+ return new Response(null, { status: 200 });
+ }
+ } catch (e) {
+ if (e instanceof InvalidSessionError) {
+ return new Response(null, { status: 401 });
+ }
+ }
+ return new Response(null, { status: 500 });
+};
diff --git a/apps/web/src/routes/(protected)/api/v1/user/[userId]/+server.ts b/apps/web/src/routes/(protected)/api/v1/user/[userId]/+server.ts
new file mode 100644
index 0000000..57a567a
--- /dev/null
+++ b/apps/web/src/routes/(protected)/api/v1/user/[userId]/+server.ts
@@ -0,0 +1,5 @@
+import type { RequestHandler } from '@sveltejs/kit';
+
+export const GET: RequestHandler = async () => {
+ return new Response(null, { status: 500 });
+};
diff --git a/apps/web/src/routes/(protected)/profile/+page.server.ts b/apps/web/src/routes/(protected)/profile/+page.server.ts
new file mode 100644
index 0000000..76e447d
--- /dev/null
+++ b/apps/web/src/routes/(protected)/profile/+page.server.ts
@@ -0,0 +1,68 @@
+import { SERVER_FORM_ACTIONS } from '@cloudkit/ui-core';
+
+import { EditUserSchema, PATHS } from '@cloudkit/ui-core';
+
+import { ImageRepository } from '@lib/server/repository/image-repository';
+import { UserRepository } from '@lib/server/repository/user-repository';
+import { fail, redirect } from '@sveltejs/kit';
+import { zod } from 'sveltekit-superforms/adapters';
+import { superValidate } from 'sveltekit-superforms/server';
+
+import type { Actions, PageServerLoad } from './$types';
+
+export const load = (async (event) => {
+ const user = event.locals.user;
+
+ if (!user) {
+ redirect(302, PATHS.ROOT);
+ }
+
+ const editUserForm = await superValidate(
+ {
+ firstName: user.firstName,
+ lastName: user.lastName,
+ email: user.email
+ },
+ zod(EditUserSchema)
+ );
+ return {
+ editUserForm
+ };
+}) satisfies PageServerLoad;
+
+export const actions: Actions = {
+ [SERVER_FORM_ACTIONS.UPDATE_USER]: async ({ request, locals }) => {
+ const user = locals.user;
+ if (!user) {
+ redirect(302, PATHS.ROOT);
+ }
+ const formData = await request.formData();
+ const editUserForm = await superValidate(formData, zod(EditUserSchema));
+ if (!editUserForm.valid) {
+ return fail(400, { form: editUserForm });
+ }
+
+ const userWithRelations = await UserRepository.findByIdWithRelations(user.id);
+
+ const avatar = formData.get('avatar');
+ if (avatar instanceof File && userWithRelations !== null) {
+ const image = await ImageRepository.update({
+ data: userWithRelations,
+ image: avatar
+ });
+
+ editUserForm.data.avatar = image;
+ // await UserRepository.updateAvatar(locals.user!, avatarEntry);
+ }
+ // await UserRepository.updateData({
+ // ...locals.user,
+ // firstName: editUserForm.data.firstName,
+ // lastName: editUserForm.data.lastName,
+ // email: editUserForm.data.email
+ // });
+
+ return {
+ editUserForm
+ };
+ }
+};
diff --git a/src/routes/home/+page.svelte b/apps/web/src/routes/(protected)/profile/+page.svelte
similarity index 50%
rename from src/routes/home/+page.svelte
rename to apps/web/src/routes/(protected)/profile/+page.svelte
index 86a0ae6..fccc792 100644
--- a/src/routes/home/+page.svelte
+++ b/apps/web/src/routes/(protected)/profile/+page.svelte
@@ -1,14 +1,11 @@
-{#if data.user}
-
-{/if}
+
diff --git a/apps/web/src/routes/(public)/+layout.server.ts b/apps/web/src/routes/(public)/+layout.server.ts
new file mode 100644
index 0000000..f91e1c5
--- /dev/null
+++ b/apps/web/src/routes/(public)/+layout.server.ts
@@ -0,0 +1,26 @@
+import type { Infer, SuperValidated } from 'sveltekit-superforms';
+import { zod } from 'sveltekit-superforms/adapters';
+import { superValidate } from 'sveltekit-superforms/server';
+
+import { AuthenticateUserSchema, RegisterUserSchema } from '@lib/client/auth/schemas';
+import type { LayoutServerLoad } from './$types';
+
+let authenticate: SuperValidated> | null = null;
+let register: SuperValidated> | null = null;
+export const load = (async ({ url }) => {
+ if (authenticate === null) {
+ authenticate = await superValidate(zod(AuthenticateUserSchema));
+ }
+
+ if (register === null) {
+ register = await superValidate(zod(RegisterUserSchema));
+ }
+
+ const { pathname } = url;
+
+ return {
+ register,
+ authenticate,
+ pathname
+ };
+}) satisfies LayoutServerLoad;
diff --git a/apps/web/src/routes/(public)/+layout.svelte b/apps/web/src/routes/(public)/+layout.svelte
new file mode 100644
index 0000000..a474e03
--- /dev/null
+++ b/apps/web/src/routes/(public)/+layout.svelte
@@ -0,0 +1,41 @@
+
+
+
+
+
diff --git a/apps/web/src/routes/(public)/+page.server.ts b/apps/web/src/routes/(public)/+page.server.ts
new file mode 100644
index 0000000..86cce14
--- /dev/null
+++ b/apps/web/src/routes/(public)/+page.server.ts
@@ -0,0 +1,36 @@
+import { PATHS, SERVER_FORM_ACTIONS } from '@cloudkit/ui-core';
+
+import { auth } from '@lib/server/auth/lucia';
+import { UserRepository } from '@lib/server/repository/user-repository';
+import { fail } from '@sveltejs/kit';
+import { Scrypt } from 'lucia';
+import { zod } from 'sveltekit-superforms/adapters';
+import { message, superValidate } from 'sveltekit-superforms/server';
+
+import { AuthenticateUserSchema } from '@lib/client/auth/schemas';
+import type { Actions } from './$types';
+
+export const actions: Actions = {
+ [SERVER_FORM_ACTIONS.AUTHENTICATE]: async ({ request, cookies }) => {
+ const signIn = await superValidate(request, zod(AuthenticateUserSchema));
+ if (!signIn.valid) {
+ return fail(400, { form: signIn });
+ }
+
+ const userExists = await UserRepository.exists(signIn.data.email);
+ if (!userExists) {
+ return message(signIn, 'E-Mail or password incorrect', { status: 400 });
+ }
+ const user = await UserRepository.findByEmail(signIn.data.email);
+ const validPassword = await new Scrypt().verify(user.hashedPassword, signIn.data.password);
+ if (!validPassword) {
+ return message(signIn, 'E-Mail or password incorrect', { status: 400 });
+ }
+ const session = await auth.createSession(user.id, {});
+ const sessionCookie = auth.createSessionCookie(session.id);
+ cookies.set(sessionCookie.name, sessionCookie.value, {
+ path: PATHS.ROOT,
+ ...sessionCookie.attributes
+ });
+ }
+};
diff --git a/apps/web/src/routes/(public)/+page.svelte b/apps/web/src/routes/(public)/+page.svelte
new file mode 100644
index 0000000..00a42f3
--- /dev/null
+++ b/apps/web/src/routes/(public)/+page.svelte
@@ -0,0 +1,5 @@
+
+
+
diff --git a/apps/web/src/routes/(public)/api/v1/auth/+server.ts b/apps/web/src/routes/(public)/api/v1/auth/+server.ts
new file mode 100644
index 0000000..d4c1f80
--- /dev/null
+++ b/apps/web/src/routes/(public)/api/v1/auth/+server.ts
@@ -0,0 +1,81 @@
+import { PATHS } from '@cloudkit/ui-core';
+import { AuthenticateUserSchema, RegisterUserSchema } from '@lib/client/auth/schemas';
+import { auth } from '@lib/server/auth/lucia';
+import { UserRepository } from '@lib/server/repository/user-repository';
+import { UserService } from '@lib/server/services/user-service';
+import type { RequestHandler } from '@sveltejs/kit';
+import { json } from '@sveltejs/kit';
+import { Scrypt } from 'lucia';
+
+export const POST: RequestHandler = async ({ request, cookies }) => {
+ await auth.validateSession(cookies.get(auth.sessionCookieName) ?? '');
+
+ const data = await request.formData();
+ const {
+ success,
+ error,
+ data: postData
+ } = RegisterUserSchema.safeParse(Object.fromEntries(data.entries()));
+ if (success && postData) {
+ const userExists = await UserRepository.exists(postData.email);
+
+ if (!userExists) {
+ const created = await UserService.createUser(postData);
+ const session = await auth.createSession(created.id, {});
+ const sessionCookie = auth.createSessionCookie(session.id);
+ cookies.set(sessionCookie.name, sessionCookie.value, {
+ path: PATHS.ROOT,
+ ...sessionCookie.attributes
+ });
+ return json(created);
+ } else {
+ return new Response(null, { status: 409 });
+ }
+ } else {
+ console.error('errors:', error);
+ }
+ return new Response(null, { status: 500 });
+};
+export const PUT: RequestHandler = async ({ request, cookies }) => {
+ const formData = await request.json();
+
+ const { success, data, error } = AuthenticateUserSchema.safeParse(formData);
+ if (!success && error) {
+ return json(error, {
+ status: 400
+ });
+ }
+
+ const userExists = await UserRepository.exists(data.email);
+
+ if (!userExists) {
+ return json({ message: 'computer sais no' }, { status: 400 });
+ }
+ const user = await UserRepository.findByEmail(data.email);
+ const validPassword = await new Scrypt().verify(user.hashedPassword, data.password);
+
+ if (!validPassword) {
+ return new Response(null, { status: 500 });
+ }
+ const session = await auth.createSession(user.id, {});
+ const sessionCookie = auth.createSessionCookie(session.id);
+ cookies.set(sessionCookie.name, sessionCookie.value, {
+ path: PATHS.ROOT,
+ ...sessionCookie.attributes
+ });
+
+ return json(user);
+};
+export const DELETE = async ({ locals, cookies }) => {
+ if (!locals.session) {
+ return new Response(null, { status: 401 });
+ }
+ await auth.invalidateSession(locals.session.id);
+ const sessionCookie = auth.createBlankSessionCookie();
+ cookies.set(sessionCookie.name, sessionCookie.value, {
+ path: PATHS.ROOT,
+ ...sessionCookie.attributes
+ });
+
+ return new Response(null, { status: 200 });
+};
diff --git a/apps/web/src/routes/+error.svelte b/apps/web/src/routes/+error.svelte
new file mode 100644
index 0000000..cbbe6d6
--- /dev/null
+++ b/apps/web/src/routes/+error.svelte
@@ -0,0 +1,6 @@
+
+
+Whoopsie. Looks like something went wrong
+{$page.error?.message}
diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte
new file mode 100644
index 0000000..6ff4c42
--- /dev/null
+++ b/apps/web/src/routes/+layout.svelte
@@ -0,0 +1,53 @@
+
+
+
+
+ {@html ''}
+
+
+ cloudkit.FYI
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/+layout.ts b/apps/web/src/routes/+layout.ts
new file mode 100644
index 0000000..68f5e18
--- /dev/null
+++ b/apps/web/src/routes/+layout.ts
@@ -0,0 +1,16 @@
+import { browser } from '$app/environment';
+import { QueryClient } from '@tanstack/svelte-query';
+import type { LayoutLoad } from './$types';
+
+export const load: LayoutLoad = async () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ enabled: browser,
+ staleTime: 60 * 1000
+ }
+ }
+ });
+
+ return { queryClient };
+};
diff --git a/apps/web/static/android-chrome-192x192.png b/apps/web/static/android-chrome-192x192.png
new file mode 100644
index 0000000..1e96ce0
Binary files /dev/null and b/apps/web/static/android-chrome-192x192.png differ
diff --git a/apps/web/static/android-chrome-512x512.png b/apps/web/static/android-chrome-512x512.png
new file mode 100644
index 0000000..bc12fb8
Binary files /dev/null and b/apps/web/static/android-chrome-512x512.png differ
diff --git a/apps/web/static/apple-touch-icon.png b/apps/web/static/apple-touch-icon.png
new file mode 100644
index 0000000..5ed02d9
Binary files /dev/null and b/apps/web/static/apple-touch-icon.png differ
diff --git a/apps/web/static/favicon-16x16.png b/apps/web/static/favicon-16x16.png
new file mode 100644
index 0000000..7aa98b8
Binary files /dev/null and b/apps/web/static/favicon-16x16.png differ
diff --git a/apps/web/static/favicon-32x32.png b/apps/web/static/favicon-32x32.png
new file mode 100644
index 0000000..5cdc723
Binary files /dev/null and b/apps/web/static/favicon-32x32.png differ
diff --git a/apps/web/static/favicon.ico b/apps/web/static/favicon.ico
new file mode 100644
index 0000000..72ead40
Binary files /dev/null and b/apps/web/static/favicon.ico differ
diff --git a/static/fonts/Quicksand.ttf b/apps/web/static/fonts/Quicksand.ttf
similarity index 100%
rename from static/fonts/Quicksand.ttf
rename to apps/web/static/fonts/Quicksand.ttf
diff --git a/apps/web/static/icons/LICENSE b/apps/web/static/icons/LICENSE
new file mode 100644
index 0000000..729f464
--- /dev/null
+++ b/apps/web/static/icons/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Skeleton Labs, LLC
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/apps/web/static/icons/add.svg b/apps/web/static/icons/add.svg
new file mode 100644
index 0000000..4291ff0
--- /dev/null
+++ b/apps/web/static/icons/add.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/icons/admin.svg b/apps/web/static/icons/admin.svg
similarity index 100%
rename from static/icons/admin.svg
rename to apps/web/static/icons/admin.svg
diff --git a/static/icons/alert.svg b/apps/web/static/icons/alert.svg
similarity index 100%
rename from static/icons/alert.svg
rename to apps/web/static/icons/alert.svg
diff --git a/apps/web/static/icons/briefcase.svg b/apps/web/static/icons/briefcase.svg
new file mode 100644
index 0000000..e3af050
--- /dev/null
+++ b/apps/web/static/icons/briefcase.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/static/icons/browsers.svg b/apps/web/static/icons/browsers.svg
new file mode 100644
index 0000000..b2cef40
--- /dev/null
+++ b/apps/web/static/icons/browsers.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/icons/cal.svg b/apps/web/static/icons/cal.svg
similarity index 100%
rename from static/icons/cal.svg
rename to apps/web/static/icons/cal.svg
diff --git a/static/icons/checkFalse.svg b/apps/web/static/icons/checkFalse.svg
similarity index 100%
rename from static/icons/checkFalse.svg
rename to apps/web/static/icons/checkFalse.svg
diff --git a/static/icons/checkTrue.svg b/apps/web/static/icons/checkTrue.svg
similarity index 100%
rename from static/icons/checkTrue.svg
rename to apps/web/static/icons/checkTrue.svg
diff --git a/apps/web/static/icons/close.svg b/apps/web/static/icons/close.svg
new file mode 100644
index 0000000..7d5875c
--- /dev/null
+++ b/apps/web/static/icons/close.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/icons/communityPlaceholder.svg b/apps/web/static/icons/communityPlaceholder.svg
similarity index 100%
rename from static/icons/communityPlaceholder.svg
rename to apps/web/static/icons/communityPlaceholder.svg
diff --git a/static/icons/edit.svg b/apps/web/static/icons/edit.svg
similarity index 100%
rename from static/icons/edit.svg
rename to apps/web/static/icons/edit.svg
diff --git a/apps/web/static/icons/eye.svg b/apps/web/static/icons/eye.svg
new file mode 100644
index 0000000..9cde243
--- /dev/null
+++ b/apps/web/static/icons/eye.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/apps/web/static/icons/kebab-menu.svg b/apps/web/static/icons/kebab-menu.svg
new file mode 100644
index 0000000..add63fe
--- /dev/null
+++ b/apps/web/static/icons/kebab-menu.svg
@@ -0,0 +1,19 @@
+
+
+
+ Kebab-Menu
+
+
\ No newline at end of file
diff --git a/static/icons/loading.svg b/apps/web/static/icons/loading.svg
similarity index 100%
rename from static/icons/loading.svg
rename to apps/web/static/icons/loading.svg
diff --git a/apps/web/static/icons/logo.png b/apps/web/static/icons/logo.png
new file mode 100644
index 0000000..bc12fb8
Binary files /dev/null and b/apps/web/static/icons/logo.png differ
diff --git a/static/icons/mapPin.svg b/apps/web/static/icons/mapPin.svg
similarity index 100%
rename from static/icons/mapPin.svg
rename to apps/web/static/icons/mapPin.svg
diff --git a/static/icons/member.svg b/apps/web/static/icons/member.svg
similarity index 100%
rename from static/icons/member.svg
rename to apps/web/static/icons/member.svg
diff --git a/static/icons/menu.svg b/apps/web/static/icons/menu.svg
similarity index 100%
rename from static/icons/menu.svg
rename to apps/web/static/icons/menu.svg
diff --git a/apps/web/static/icons/package.svg b/apps/web/static/icons/package.svg
new file mode 100644
index 0000000..f1e09ee
--- /dev/null
+++ b/apps/web/static/icons/package.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/icons/placeholder.svg b/apps/web/static/icons/placeholder.svg
similarity index 100%
rename from static/icons/placeholder.svg
rename to apps/web/static/icons/placeholder.svg
diff --git a/apps/web/static/icons/printer.svg b/apps/web/static/icons/printer.svg
new file mode 100644
index 0000000..8a9a7ac
--- /dev/null
+++ b/apps/web/static/icons/printer.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/icons/save.svg b/apps/web/static/icons/save.svg
similarity index 100%
rename from static/icons/save.svg
rename to apps/web/static/icons/save.svg
diff --git a/static/icons/upload.svg b/apps/web/static/icons/upload.svg
similarity index 100%
rename from static/icons/upload.svg
rename to apps/web/static/icons/upload.svg
diff --git a/static/icons/users.svg b/apps/web/static/icons/users.svg
similarity index 100%
rename from static/icons/users.svg
rename to apps/web/static/icons/users.svg
diff --git a/static/images/placeholder-community-logo.webp b/apps/web/static/images/placeholder-community-logo.webp
similarity index 100%
rename from static/images/placeholder-community-logo.webp
rename to apps/web/static/images/placeholder-community-logo.webp
diff --git a/static/images/placeholder.png b/apps/web/static/images/placeholder.png
similarity index 100%
rename from static/images/placeholder.png
rename to apps/web/static/images/placeholder.png
diff --git a/apps/web/static/robots.txt b/apps/web/static/robots.txt
new file mode 100644
index 0000000..77470cb
--- /dev/null
+++ b/apps/web/static/robots.txt
@@ -0,0 +1,2 @@
+User-agent: *
+Disallow: /
\ No newline at end of file
diff --git a/apps/web/static/site.webmanifest b/apps/web/static/site.webmanifest
new file mode 100644
index 0000000..3a9b679
--- /dev/null
+++ b/apps/web/static/site.webmanifest
@@ -0,0 +1,19 @@
+{
+ "name": "Whats In",
+ "short_name": "WI",
+ "icons": [
+ {
+ "src": "/android-chrome-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/android-chrome-512x512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ }
+ ],
+ "theme_color": "#ffffff",
+ "background_color": "#ffffff",
+ "display": "standalone"
+}
diff --git a/apps/web/svelte.config.js b/apps/web/svelte.config.js
new file mode 100644
index 0000000..fca8f13
--- /dev/null
+++ b/apps/web/svelte.config.js
@@ -0,0 +1,49 @@
+import cloudflareAdapter from '@sveltejs/adapter-cloudflare';
+import nodeAdapter from '@sveltejs/adapter-node';
+import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
+
+const adapter =
+ process.env.ENVIRONMENT === 'ci'
+ ? nodeAdapter()
+ : cloudflareAdapter({
+ // See below for an explanation of these options
+
+ routes: {
+ include: ['/*'],
+ exclude: ['']
+ },
+ platformProxy: {
+ configPath: 'wrangler.toml',
+ environment: process.env.ENVIRONMENT,
+ experimentalJsonConfig: false,
+ persist: false
+ }
+ });
+/** @type {import('@sveltejs/kit').Config} */
+const config = {
+ preprocess: [vitePreprocess()],
+
+ kit: {
+ adapter,
+ csrf: {
+ checkOrigin: true
+ },
+ env: {
+ dir: './../../'
+ },
+ alias: {
+ '@mocks/*': './src/__mocks__/*',
+ '@hooks/*': './src/hooks/*',
+ '@components/*': './src/lib/components/*',
+ '@lib/*': './src/lib/*',
+ '@pages/*': './src/pages/*',
+ '@services/*': './src/lib/services/*',
+ '@styles/*': './src/styles/*',
+ '@utils/*': './src/lib/utils/*',
+ '@assets/*': './src/assets/*',
+ '@middleware/*': './src/lib/server/middleware/*'
+ }
+ }
+};
+
+export default config;
diff --git a/tailwind.config.ts b/apps/web/tailwind.config.ts
similarity index 72%
rename from tailwind.config.ts
rename to apps/web/tailwind.config.ts
index c3dc608..0117d9d 100644
--- a/tailwind.config.ts
+++ b/apps/web/tailwind.config.ts
@@ -11,8 +11,13 @@ const config = {
darkMode: 'class',
content: [
'./src/**/*.{html,js,svelte,ts}',
+ '../../packages/ui-core/src/**/*.{html,js,svelte,ts}',
// 3. Append the path to the Skeleton package
- join(require.resolve('@skeletonlabs/skeleton'), '../**/*.{html,js,svelte,ts}')
+ join(require.resolve('@skeletonlabs/skeleton'), '../**/*.{html,js,svelte,ts}'),
+ join(
+ require.resolve('@skeletonlabs/skeleton'),
+ '../../packages/ui-core/src/**/*.{html,js,svelte,ts}'
+ )
],
theme: {
extend: {}
@@ -21,10 +26,11 @@ const config = {
forms,
typography,
// 4. Append the Skeleton plugin (after other plugins)
+ require('@tailwindcss/container-queries'),
skeleton({
themes: {
// Register each theme within this array:
- preset: [{ name: 'gold-nouveau', enhancements: true }]
+ preset: [{ name: 'wintry', enhancements: true }]
}
})
]
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
new file mode 100644
index 0000000..3600c5a
--- /dev/null
+++ b/apps/web/tsconfig.json
@@ -0,0 +1,20 @@
+{
+ "extends": "./.svelte-kit/tsconfig.json",
+ "compilerOptions": {
+ "allowJs": true,
+ "checkJs": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "moduleResolution": "bundler",
+ "types": ["@testing-library/jest-dom/vitest"]
+ }
+ // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias
+ // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files
+ //
+ // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
+ // from the referenced tsconfig.json - TypeScript does not merge them in
+}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
new file mode 100644
index 0000000..d7f3e2d
--- /dev/null
+++ b/apps/web/vite.config.ts
@@ -0,0 +1,46 @@
+import { sveltekit } from '@sveltejs/kit/vite';
+import { fileURLToPath } from 'node:url';
+import tsconfigPaths from 'vite-tsconfig-paths';
+import { defineConfig } from 'vitest/config';
+
+import { svelteTesting } from '@testing-library/svelte/vite';
+
+export default defineConfig({
+ plugins: [tsconfigPaths(), sveltekit(), svelteTesting()],
+ server: {
+ port: 4000
+ },
+ preview: {
+ port: 4000
+ },
+ optimizeDeps: {
+ esbuildOptions: {
+ target: 'esnext'
+ }
+ },
+ build: {
+ target: 'es2020'
+ },
+ test: {
+ environment: 'jsdom',
+ globals: true,
+ setupFiles: ['./vitest-setup.ts']
+ },
+ css: {
+ preprocessorOptions: {
+ scss: {
+ api: 'modern'
+ }
+ }
+ },
+ define: {
+ SUPERFORMS_LEGACY: true
+ },
+ resolve: {
+ alias: {
+ '@cloudkit/db-schema': fileURLToPath(
+ new URL('./../../packages/db-schema/src/generated/index.ts', import.meta.url)
+ )
+ }
+ }
+});
diff --git a/apps/web/vitest-setup.ts b/apps/web/vitest-setup.ts
new file mode 100644
index 0000000..d08348a
--- /dev/null
+++ b/apps/web/vitest-setup.ts
@@ -0,0 +1,17 @@
+import '@testing-library/jest-dom/vitest';
+
+import { vi } from 'vitest';
+
+Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(), // deprecated
+ removeListener: vi.fn(), // deprecated
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn()
+ }))
+});
diff --git a/apps/web/wrangler.toml b/apps/web/wrangler.toml
new file mode 100644
index 0000000..520a3d5
--- /dev/null
+++ b/apps/web/wrangler.toml
@@ -0,0 +1,85 @@
+#:schema node_modules/wrangler/config-schema.json
+name = "cloudkit"
+compatibility_date = "2024-07-07"
+pages_build_output_dir = ".svelte-kit/cloudflare"
+
+# Automatically place your workloads in an optimal location to minimize latency.
+# If you are running back-end logic in a Pages Function, running it closer to your back-end infrastructure
+# rather than the end user may result in better performance.
+# Docs: https://developers.cloudflare.com/pages/functions/smart-placement/#smart-placement
+# [placement]
+# mode = "smart"
+
+# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables)
+# Docs:
+# - https://developers.cloudflare.com/pages/functions/bindings/#environment-variables
+# Note: Use secrets to store sensitive data.
+# - https://developers.cloudflare.com/pages/functions/bindings/#secrets
+# [vars]
+# MY_VARIABLE = "production_value"
+
+# Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network
+# Docs: https://developers.cloudflare.com/pages/functions/bindings/#workers-ai
+# [ai]
+# binding = "AI"
+
+# Bind a D1 database. D1 is Cloudflare’s native serverless SQL database.
+# Docs: https://developers.cloudflare.com/pages/functions/bindings/#d1-databases
+# [[d1_databases]]
+# binding = "MY_DB"
+# database_name = "my-database"
+# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+
+# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model.
+# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps.
+# Docs: https://developers.cloudflare.com/workers/runtime-apis/durable-objects
+# [[durable_objects.bindings]]
+# name = "MY_DURABLE_OBJECT"
+# class_name = "MyDurableObject"
+# script_name = 'my-durable-object'
+
+# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs.
+# Docs: https://developers.cloudflare.com/pages/functions/bindings/#kv-namespaces
+# [[kv_namespaces]]
+# binding = "MY_KV_NAMESPACE"
+# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
+
+# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer.
+# Docs: https://developers.cloudflare.com/pages/functions/bindings/#queue-producers
+# [[queues.producers]]
+# binding = "MY_QUEUE"
+# queue = "my-queue"
+
+# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files.
+# Docs: https://developers.cloudflare.com/pages/functions/bindings/#r2-buckets
+# [[r2_buckets]]
+# binding = "MY_BUCKET"
+# bucket_name = "my-bucket"
+
+# Bind another Worker service. Use this binding to call another Worker without network overhead.
+# Docs: https://developers.cloudflare.com/pages/functions/bindings/#service-bindings
+# [[services]]
+# binding = "MY_SERVICE"
+# service = "my-service"
+
+# To use different bindings for preview and production environments, follow the examples below.
+# When using environment-specific overrides for bindings, ALL bindings must be specified on a per-environment basis.
+# Docs: https://developers.cloudflare.com/pages/functions/wrangler-configuration#environment-specific-overrides
+
+######## PREVIEW environment config ########
+
+# [env.preview.vars]
+# API_KEY = "xyz789"
+
+# [[env.preview.kv_namespaces]]
+# binding = "MY_KV_NAMESPACE"
+# id = ""
+
+######## PRODUCTION environment config ########
+
+# [env.production.vars]
+# API_KEY = "abc123"
+
+# [[env.production.kv_namespaces]]
+# binding = "MY_KV_NAMESPACE"
+# id = ""
\ No newline at end of file
diff --git a/package.json b/package.json
index 0c9a88a..f5dfd0b 100644
--- a/package.json
+++ b/package.json
@@ -1,107 +1,51 @@
{
- "name": "sveltekit-template",
- "version": "0.0.1",
- "private": true,
- "type": "module",
+ "name": "cloudkit.dle.dev",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
"scripts": {
- "prep": "pnpm prisma:gen:dev && pnpm prisma:push:dev && pnpm psql:seed",
- "dev": "pnpm prisma:gen:dev && vite dev",
- "dev:prod": "pnpm prisma:gen:prod && vite dev",
- "clean": "rimraf ./playwright-report && rimraf ./.wrangler && rimraf ./.svelte-kit",
- "build:prod": "pnpm clean && pnpm prisma:gen:prod && vite build --mode production",
- "build:ci": "pnpm clean && pnpm prisma:gen:ci && vite build --mode ci",
- "preview": "pnpm build:ci && vite preview",
- "preview:cf": "pnpm build:prod && wrangler pages dev ./.svelte-kit/cloudflare",
- "test:integration:debug": "playwright test --debug",
- "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
- "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
- "lint": "prettier --plugin-search-dir . --check ./src && eslint ./src",
- "format": "prettier --plugin-search-dir . --write ./src",
- "test:unit": "vitest --run",
- "test:e2e": "playwright test",
- "test:e2e:report": "playwright test && npx playwright show-report",
- "test:e2e:dev": "playwright test --ui --project=chromium --config ./playwright.config.dev.ts",
- "test:e2e:ci": "playwright test --project=chromium --config ./playwright.config.ci.ts",
- "prisma:gen:dev": "dotenv -e .env.development -- prisma generate --schema ./prisma/dev.schema.prisma",
- "prisma:gen:ci": "dotenv -e .env.ci -- prisma generate --schema ./prisma/dev.schema.prisma",
- "prisma:gen:prod": "dotenv -e .env.production -- prisma generate --no-engine --schema ./prisma/prod.schema.prisma",
- "prisma:push:dev": "dotenv -e .env.development -- prisma db push --schema ./prisma/dev.schema.prisma",
- "prisma:push:prod": "dotenv -e .env.production -- prisma db push --schema ./prisma/prod.schema.prisma",
- "prisma:studio": "dotenv -e .env.development -- prisma studio --schema ./prisma/dev.schema.prisma",
- "prisma:studio:prod": "dotenv -e .env.production -- prisma studio --schema ./prisma/prod.schema.prisma",
- "psql:dump": "docker exec -i cloudkit-db /bin/bash -c \"PGPASSWORD=pass123 pg_dump --username admin cloudkit-db\" > ./cloudkit-db-dump.sql",
- "psql:restore": "docker exec -i cloudkit-db /bin/bash -c \"PGPASSWORD=pass123 psql --username admin cloudkit-db\" < /cloudkit-db-dump.sql",
- "psql:seed": "pnpm prisma:gen:dev && dotenv -e .env.development -- node prisma/seed.js",
- "psql:seed:prod": "pnpm prisma:gen:dev && dotenv -e .env.production -- node prisma/seed.js",
- "cloudflare:init": "dotenv -e .env.production -- node createCloudflareProject.js"
+ "build:api": "turbo run @cloudkit/service-contract#build",
+ "build:prod:api": "turbo run @cloudkit/swagger-ui#build",
+ "dev:storybook": "turbo run @cloudkit/styleguide#storybook",
+ "dev": "dotenv -e .env.development -- turbo run dev",
+ "dev:api": "turbo run @cloudkit/swagger-ui#dev",
+ "prepare": "husky",
+ "clean": "rimraf --glob .svelte-kit build dist .turbo",
+ "lint": "turbo run lint -- --fix",
+ "check": "turbo run check",
+ "test": "turbo run test",
+ "sync": "turbo run sync",
+ "build:local": "dotenv -e .env.development -- turbo run build",
+ "build": "turbo run build",
+ "studio":"pnpm --filter=@cloudkit/db-schema studio"
},
- "prisma": {
- "seed": "node prisma/seed.js"
+ "devDependencies": {
+ "@types/node": "22.7.6",
+ "@types/uuid": "^10.0.0",
+ "dotenv": "^16.4.5",
+ "dotenv-cli": "^7.4.2",
+ "husky": "^9.1.5",
+ "lint-staged": "15.2.10",
+ "rimraf": "^6.0.1",
+ "tsx": "^4.19.0",
+ "typescript": "^5.5.4",
+ "vite": "5.4.9",
+ "vite-plugin-tailwind-purgecss": "0.3.3",
+ "vite-tsconfig-paths": "^5.0.1",
+ "vitest": "2.1.3",
+ "wrangler": "3.84.0"
},
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
"dependencies": {
- "@floating-ui/dom": "^1.5.3",
- "@lucia-auth/adapter-prisma": "^3.0.2",
- "@lucia-auth/adapter-session-redis": "^2.1.1",
- "@lucia-auth/adapter-session-unstorage": "^2.1.0",
- "@lucia-auth/adapter-sqlite": "^2.0.1",
- "@prisma/client": "5.8.0",
- "@prisma/extension-accelerate": "^0.6.2",
- "@upstash/redis": "1.23.4",
- "classnames": "^2.3.2",
- "svelte-markdown": "^0.4.0",
- "sveltekit-superforms": "^1.8.0",
- "unstorage": "^1.9.0",
- "uuid": "^9.0.1",
- "zod": "^3.22.4"
+ "@prisma/client": "5.21.0",
+ "eslint-plugin-yml": "^1.14.0",
+ "turbo": "^2.2.1"
},
- "devDependencies": {
- "@faker-js/faker": "^8.2.0",
- "@playwright/test": "^1.39.0",
- "@skeletonlabs/skeleton": "^2.4.0",
- "@skeletonlabs/tw-plugin": "^0.2.3",
- "@sveltejs/adapter-cloudflare": "^2.3.3",
- "@sveltejs/adapter-node": "^1.3.1",
- "@sveltejs/kit": "^1.27.1",
- "@tailwindcss/forms": "^0.5.6",
- "@tailwindcss/typography": "^0.5.10",
- "@types/node": "20.8.9",
- "@types/uuid": "^9.0.6",
- "@typescript-eslint/eslint-plugin": "^6.9.0",
- "@typescript-eslint/parser": "^6.9.0",
- "autoprefixer": "^10.4.16",
- "dotenv": "^16.3.1",
- "dotenv-cli": "^7.3.0",
- "eslint": "^8.52.0",
- "eslint-config-prettier": "^9.0.0",
- "eslint-plugin-svelte": "^2.34.0",
- "highlight.js": "^11.9.0",
- "husky": "^8.0.3",
- "jest": "^29.7.0",
- "jimp": "^0.22.10",
- "just-debounce-it": "^3.2.0",
- "lint-staged": "15.0.2",
- "lucia": "^2.7.2",
- "node-fetch": "^3.3.2",
- "postcss": "^8.4.31",
- "prettier": "3.0.3",
- "prettier-plugin-svelte": "^3.0.3",
- "prisma": "5.8.0",
- "redis": "^4.6.10",
- "rimraf": "^5.0.5",
- "sass": "^1.69.5",
- "set-cookie-parser": "^2.6.0",
- "sharp": "^0.32.6",
- "svelte": "^4.2.2",
- "svelte-check": "^3.5.2",
- "svelte-preprocess": "^5.0.4",
- "tailwindcss": "^3.3.5",
- "tsx": "^3.14.0",
- "type-fest": "^4.6.0",
- "typescript": "^5.2.2",
- "vite": "4.5.1",
- "vite-plugin-tailwind-purgecss": "^0.1.3",
- "vite-tsconfig-paths": "^4.2.1",
- "vitest": "0.34.6",
- "wrangler": "^3.22.4"
+ "packageManager": "pnpm@999.999.999",
+ "engines": {
+ "node": ">=v20",
+ "pnpm": ">=9"
}
}
diff --git a/packages/api-domain/.eslintrc.cjs b/packages/api-domain/.eslintrc.cjs
new file mode 100644
index 0000000..f1c66a7
--- /dev/null
+++ b/packages/api-domain/.eslintrc.cjs
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: ['@cloudkit/eslint-config']
+};
diff --git a/packages/api-domain/.lintstagedrc b/packages/api-domain/.lintstagedrc
new file mode 100644
index 0000000..5e06bd8
--- /dev/null
+++ b/packages/api-domain/.lintstagedrc
@@ -0,0 +1,6 @@
+{
+ "*.{js,ts,svelte,css,scss,postcss,md,json,yml}": [
+ "prettier --config ../../.prettierrc --write --cache ./src"
+ ],
+ "*.{ts,svelte,css,scss,yml}": ["eslint --cache --fix"]
+}
diff --git a/packages/api-domain/package.json b/packages/api-domain/package.json
new file mode 100644
index 0000000..78e6446
--- /dev/null
+++ b/packages/api-domain/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "@cloudkit/api-domain",
+ "version": "1.0.0",
+ "description": "Types for cloudkit's API",
+ "main": "index.js",
+ "scripts": {
+ "gen": "tsx ./src/index.ts"
+ },
+ "keywords": [
+ "cloudkit",
+ "api",
+ "types"
+ ],
+ "author": "Daniel Einars",
+ "license": "ISC",
+ "dependencies": {
+ "@asteasolutions/zod-to-openapi": "^7.1.1",
+ "@cloudkit/db-schema": "workspace:^",
+ "@cloudkit/eslint-config": "workspace:^",
+ "tsx": "^4.19.0",
+ "yaml": "^2.5.1",
+ "zod": "^3.23.8"
+ }
+}
diff --git a/packages/api-domain/src/index.ts b/packages/api-domain/src/index.ts
new file mode 100644
index 0000000..b8b3022
--- /dev/null
+++ b/packages/api-domain/src/index.ts
@@ -0,0 +1 @@
+throw new Error('Not yet implemented');
diff --git a/packages/api-domain/tsconfig.json b/packages/api-domain/tsconfig.json
new file mode 100644
index 0000000..1646576
--- /dev/null
+++ b/packages/api-domain/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "../../tsconfig-base.json"
+}
diff --git a/docker-compose.yaml b/packages/api-mocks/docker-compose.yaml
similarity index 80%
rename from docker-compose.yaml
rename to packages/api-mocks/docker-compose.yaml
index 05d2b68..d1f70c4 100644
--- a/docker-compose.yaml
+++ b/packages/api-mocks/docker-compose.yaml
@@ -1,11 +1,11 @@
-version: '3'
+name: cloudkit
networks:
cloudkit-net:
- driver: bridge
name: cloudkit-net
+ driver: bridge
services:
- cloudkit-img:
+ img:
image: ghcr.io/minimalcompact/thumbor
container_name: cloudkit-img
environment:
@@ -33,18 +33,18 @@ services:
networks:
- cloudkit-net
ports:
- - "6501:6501"
-
- cloudkit-auth-cache:
- image: redis:6.2
- container_name: cloudkit-auth-cache
- networks:
- - cloudkit-net
- ports:
- - '6379:6379'
- command: redis-server --loglevel warning
- cloudkit-db:
- image: postgres:15
+ - '7501:6501'
+
+ # redis:
+ # image: redis:6.2
+ # container_name: cloudkit-auth-cache
+ # networks:
+ # - cloudkit-net
+ # ports:
+ # - '6379:6379'
+ # command: redis-server --loglevel warning
+ db:
+ image: postgres:latest
container_name: cloudkit-db
networks:
- cloudkit-net
@@ -53,4 +53,6 @@ services:
- POSTGRES_PASSWORD=pass123
- POSTGRES_DB=cloudkit-db
ports:
- - 6500:5432
+ - 7500:5432
+ # volumes:
+ # - ./sk-db-dump.sql:/docker-entrypoint-initdb.d/sk-db-dump.sql
diff --git a/packages/api-mocks/package.json b/packages/api-mocks/package.json
new file mode 100644
index 0000000..beeafa6
--- /dev/null
+++ b/packages/api-mocks/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@cloudkit/api-mocks",
+ "version": "1.0.0",
+ "description": "",
+ "scripts": {
+ "db:dump": "docker exec -i cloudkit-db /bin/bash -c \"PGPASSWORD=pass123 pg_dump --username admin cloudkit-db\" > ./cloudkit-db-dump.sql",
+ "db:restore": "docker exec -i cloudkit-db /bin/bash -c \"PGPASSWORD=pass123 psql --username admin cloudkit-db\" < ./cloudkit-db-dump.sql",
+ "db:seed": "pnpm prisma:gen:dev && pnpm prisma:push:dev && dotenv -e .env.development -- node prisma/seed.js",
+ "start": "docker compose up -d",
+ "stop": "docker compose stop"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC"
+}
diff --git a/sk-db-dump.sql b/packages/api-mocks/sk-db-dump.sql
similarity index 100%
rename from sk-db-dump.sql
rename to packages/api-mocks/sk-db-dump.sql
diff --git a/packages/db-schema/.eslintrc.cjs b/packages/db-schema/.eslintrc.cjs
new file mode 100644
index 0000000..f1c66a7
--- /dev/null
+++ b/packages/db-schema/.eslintrc.cjs
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: ['@cloudkit/eslint-config']
+};
diff --git a/packages/db-schema/.lintstagedrc b/packages/db-schema/.lintstagedrc
new file mode 100644
index 0000000..5e06bd8
--- /dev/null
+++ b/packages/db-schema/.lintstagedrc
@@ -0,0 +1,6 @@
+{
+ "*.{js,ts,svelte,css,scss,postcss,md,json,yml}": [
+ "prettier --config ../../.prettierrc --write --cache ./src"
+ ],
+ "*.{ts,svelte,css,scss,yml}": ["eslint --cache --fix"]
+}
diff --git a/packages/db-schema/package.json b/packages/db-schema/package.json
new file mode 100644
index 0000000..10b1f08
--- /dev/null
+++ b/packages/db-schema/package.json
@@ -0,0 +1,27 @@
+{
+ "name": "@cloudkit/db-schema",
+ "version": "1.0.0",
+ "description": "",
+ "type": "module",
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "scripts": {
+ "generate": "prisma generate --no-engine --schema ./prisma/schema.prisma && pnpm format",
+ "push": "prisma db push --schema ./prisma/schema.prisma && pnpm format",
+ "push:prod": "dotenv -e ../../.env.production -- prisma db push --schema ./prisma/schema.prisma",
+ "push:manual": "dotenv -e ../../.env.development -- prisma db push --schema ./prisma/schema.prisma && pnpm format",
+ "studio": "dotenv -e ../../.env.development -- prisma studio --schema ./prisma/schema.prisma && pnpm format",
+ "studio:prod": "dotenv -e ../../.env.production -- prisma studio --schema ./prisma/schema.prisma && pnpm format",
+ "format": "prettier --config ../../.prettierrc --write ."
+ },
+ "dependencies": {
+ "@prisma/client": "5.21.0",
+ "@cloudkit/eslint-config": "workspace:^",
+ "prisma": "^5.19.1",
+ "zod": "^3.23.8",
+ "zod-prisma-types": "^3.1.8"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC"
+}
diff --git a/packages/db-schema/prisma/schema.prisma b/packages/db-schema/prisma/schema.prisma
new file mode 100644
index 0000000..184dd89
--- /dev/null
+++ b/packages/db-schema/prisma/schema.prisma
@@ -0,0 +1,61 @@
+generator client {
+ provider = "prisma-client-js"
+ previewFeatures = ["fullTextSearch"]
+}
+
+generator zod {
+ provider = "zod-prisma-types"
+ createOptionalDefaultValuesTypes = true
+ createRelationValuesTypes = true
+ writeNullishInModelTypes = true
+ output = "./../src/generated"
+}
+
+datasource db {
+ provider = "postgresql"
+ url = env("DATABASE_URL")
+}
+
+model Session {
+ id String @id
+ userId String
+ expiresAt DateTime
+ user User @relation(references: [id], fields: [userId], onDelete: Cascade)
+}
+
+/// @zod.import(["import { ALLOWED_STRINGS, ERROR_MESSAGE } from '../utils';"])
+model User {
+ id String @id @unique @default(uuid())
+ /// @zod.string.email().min(3)
+ email String @unique
+ hashedPassword String
+ /// @zod.string.min(1).max(32).regex(ALLOWED_STRINGS, ERROR_MESSAGE)
+ firstName String
+ /// @zod.string.min(1).max(32).regex(ALLOWED_STRINGS, ERROR_MESSAGE)
+ lastName String
+ verified Boolean @default(false)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @default(now())
+ avatar Image?
+
+ sessions Session[]
+ firstTime Boolean @default(true)
+
+ @@index(fields: [id], type: Hash)
+}
+
+
+model Image {
+ id String @id @default(uuid())
+ createdAt DateTime @default(now())
+ updatedAt DateTime @default(now())
+ url String @unique
+ user User? @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
+ userId String? @unique
+
+
+
+ @@index(fields: [id], type: Hash)
+
+ @@index(fields: [userId], type: Hash)
+}
diff --git a/packages/db-schema/prisma/seed.js b/packages/db-schema/prisma/seed.js
new file mode 100644
index 0000000..8ab2594
--- /dev/null
+++ b/packages/db-schema/prisma/seed.js
@@ -0,0 +1,125 @@
+import { faker } from '@faker-js/faker';
+import { prisma } from '@lucia-auth/adapter-prisma';
+import { PrismaClient } from '@prisma/client';
+import { Lucia } from 'lucia';
+import { createImage, userAttributes } from './seedUtils.js';
+import { PrismaAdapter } from '@lucia-auth/adapter-prisma';
+
+const db = new PrismaClient({
+ datasources: {
+ db: {
+ url: process.env.DATABASE_URL
+ }
+ }
+});
+
+const adapter = new PrismaAdapter(db.session, db.user);
+const lucia = new Lucia(adapter, {
+ sessionCookie: {
+ attributes: {
+ // set to `true` when using HTTPS
+ secure: process.env.NODE_ENV === 'production'
+ }
+ },
+
+ getUserAttributes: (attributes) => {
+ return {
+ firstName: attributes.firstName,
+ lastName: attributes.lastName,
+ email: attributes.email,
+ createdAt: attributes.createdAt,
+ updatedAt: attributes.updatedAt,
+ verified: attributes.verified
+ };
+ }
+});
+const prefix = 'cloudkit';
+
+async function freshInit() {
+ await db.user.deleteMany({});
+ await db.group.deleteMany({});
+ await db.item.deleteMany({});
+ await db.image.deleteMany({});
+ // CREATE ADMIN USER
+
+ const user = await auth.createUser({
+ key: {
+ providerId: 'username',
+ providerUserId: 'admin@dle.dev',
+ password: 'adminadmin'
+ },
+ attributes: {
+ email: 'admin@dle.dev'
+ }
+ });
+ const url = `${process.env.IMAGE_API}/${prefix}/${user.userId}/avatar`;
+ await db.user.update({
+ where: {
+ id: user.userId,
+ email: user.email
+ },
+ data: {
+ firstName: 'Daniel',
+ lastName: 'Kit',
+ verified: false,
+ avatar: {
+ create: {
+ url,
+ id: user.userId
+ }
+ }
+ }
+ });
+ await createImage(url);
+
+ // CREATE RANDOM USERS
+ for (let i = 0; i < 10; i++) {
+ const firstName = faker.person.firstName();
+ const lastName = faker.person.lastName();
+ const email = `${firstName.toLowerCase()}.${lastName.toLowerCase()}@dle.dev`;
+ const user = await auth.createUser({
+ key: {
+ providerId: 'username',
+ providerUserId: email,
+ password: 'adminadmin'
+ },
+ attributes: {
+ email
+ }
+ });
+ const url = `${process.env.IMAGE_API}/${prefix}/${user.userId}/avatar`;
+ await db.user.update({
+ where: {
+ id: user.userId,
+ email: user.email
+ },
+ data: {
+ email,
+ firstName,
+ lastName,
+ verified: false,
+ avatar: {
+ create: {
+ url,
+ id: user.userId
+ }
+ }
+ }
+ });
+
+ await createImage(url);
+ }
+}
+
+async function main() {
+ await freshInit();
+}
+main()
+ .then(async () => {
+ await db.$disconnect();
+ })
+ .catch(async (e) => {
+ console.error(e);
+ await db.$disconnect();
+ process.exit(1);
+ });
diff --git a/packages/db-schema/prisma/seedUtils.js b/packages/db-schema/prisma/seedUtils.js
new file mode 100644
index 0000000..2c794ed
--- /dev/null
+++ b/packages/db-schema/prisma/seedUtils.js
@@ -0,0 +1,49 @@
+import fetch from 'node-fetch';
+import sharp from 'sharp';
+const dev = process.env.SEED_DEV === 'true';
+import fs from 'fs';
+export async function createImage(url) {
+ const image = await fetch('https://picsum.photos/700')
+ .then((response) => response.arrayBuffer())
+ .then((buffer) => sharp(buffer).webp({ quality: 80 }).toBuffer());
+
+ return dev
+ ? await postToLocalThumborInstance(image, url)
+ : await postToCloudflareImageService(image, url);
+}
+async function postToCloudflareImageService(image, url) {
+ const form = new FormData();
+ form.append('file', new Blob([image]), url);
+ form.append('id', url);
+ const response = await fetch(process.env.IMAGE_API, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${process.env.IMAGE_API_TOKEN}`
+ },
+ body: form
+ });
+
+ console.info('response: ', response);
+}
+
+async function postToLocalThumborInstance(image, url) {
+ await fetch(url, {
+ method: 'PUT',
+ headers: {
+ 'Content-prefix': 'image/webp',
+ Slug: `${url}.webp`
+ },
+ body: image
+ });
+}
+
+export const userAttributes = (data) => ({
+ firstName: data.firstName,
+ lastName: data.lastName,
+ email: data.email,
+ avatar: data.avatar,
+ createdAt: data.createdAt,
+ updatedAt: data.updatedAt,
+ verified: data.verified,
+ collections: data.collections
+});
diff --git a/packages/db-schema/src/index.ts b/packages/db-schema/src/index.ts
new file mode 100644
index 0000000..a569a11
--- /dev/null
+++ b/packages/db-schema/src/index.ts
@@ -0,0 +1,2 @@
+export * from './generated';
+export * from './utils';
diff --git a/packages/db-schema/src/utils/index.ts b/packages/db-schema/src/utils/index.ts
new file mode 100644
index 0000000..af9fc0a
--- /dev/null
+++ b/packages/db-schema/src/utils/index.ts
@@ -0,0 +1,6 @@
+export const ALLOWED_STRINGS =
+ /^[\s!?_"+.,():;@%ÀÁÂÃÄÅǍĀĄÆÇČĆÈÉÊËĚĒĘǦÌÍÎÏĪÐĐĎĹĽŁÑŇŃÒÓÔÕÖŌ×ØŘŔŠŚŤÙÚÛÜŮŪÝΫŽŻŹÞßàáâãäåǎāąæçčćďđèéêëěēęǧìíîïīðĺľłñňńòóôõöō÷øřŕšśťùúûüůūýþÿžżźa-zA-Z0-9-\u0027\u2019ŐőŰűĞğİıŞş¡¿ªº£¢€§]*$/;
+export const ERROR_MESSAGE = 'This field may only contain letters or numbers';
+
+export const MAX_FILE_SIZE = 500000;
+export const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp'];
diff --git a/packages/db-schema/tsconfig.json b/packages/db-schema/tsconfig.json
new file mode 100644
index 0000000..1646576
--- /dev/null
+++ b/packages/db-schema/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "../../tsconfig-base.json"
+}
diff --git a/packages/eslint-config/.lintstagedrc b/packages/eslint-config/.lintstagedrc
new file mode 100644
index 0000000..095cd0a
--- /dev/null
+++ b/packages/eslint-config/.lintstagedrc
@@ -0,0 +1,4 @@
+{
+ "*.{cjs}": [
+ "prettier --config ../../.prettierrc --write --cache ./index.cjs"
+ ]}
diff --git a/.eslintrc.cjs b/packages/eslint-config/index.cjs
similarity index 50%
rename from .eslintrc.cjs
rename to packages/eslint-config/index.cjs
index ebc1958..ca2919b 100644
--- a/.eslintrc.cjs
+++ b/packages/eslint-config/index.cjs
@@ -1,13 +1,16 @@
+/** @type { import("eslint").Linter.Config } */
module.exports = {
- root: true,
+ root: false,
+ plugins: ['@typescript-eslint', 'unused-imports'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:svelte/recommended',
+ 'plugin:@tanstack/eslint-plugin-query/recommended',
+ 'plugin:yml/standard',
'prettier'
],
parser: '@typescript-eslint/parser',
- plugins: ['@typescript-eslint'],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
@@ -25,6 +28,15 @@ module.exports = {
parserOptions: {
parser: '@typescript-eslint/parser'
}
+ },
+ {
+ files: ['*.yml'],
+ parser: 'yaml-eslint-parser'
}
- ]
+ ],
+ rules: {
+ 'no-console': ['error', { allow: ['warn', 'error'] }],
+ 'lines-around-directive': ['error', { before: 'always', after: 'always' }]
+ },
+ ignorePatterns: ['*.js', '*.json', '*.md', '!turbo.json', '*.css']
};
diff --git a/packages/eslint-config/package.json b/packages/eslint-config/package.json
new file mode 100644
index 0000000..713325c
--- /dev/null
+++ b/packages/eslint-config/package.json
@@ -0,0 +1,37 @@
+{
+ "name": "@cloudkit/eslint-config",
+ "version": "0.0.1",
+ "main": "./index.cjs",
+ "module": "./index.cjs",
+ "author": "Daniel Einars",
+ "type": "module",
+ "description": "Eslint config for WhatsIn",
+ "exports": {
+ ".": {
+ "import": "./index.cjs",
+ "default": "./index.cjs"
+ }
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/polaroidkidd/whatsin.fyi.git"
+ },
+ "keywords": [
+ "whatsin"
+ ],
+ "license": "ISC",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "^7.17.0",
+ "@typescript-eslint/parser": "^7.17.0",
+ "eslint": "^8.57.0",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-config-turbo": "^2.1.2",
+ "eslint-plugin-simple-import-sort": "^12.1.1",
+ "eslint-plugin-svelte": "^2.43.0",
+ "eslint-plugin-unused-imports": "^3.2.0",
+ "eslint-plugin-yml": "^1.14.0",
+ "prettier": "3.3.3",
+ "prettier-plugin-svelte": "^3.2.6",
+ "yaml-eslint-parser": "^1.2.3"
+ }
+}
diff --git a/packages/eslint-config/vite.config.ts b/packages/eslint-config/vite.config.ts
new file mode 100644
index 0000000..136860d
--- /dev/null
+++ b/packages/eslint-config/vite.config.ts
@@ -0,0 +1,15 @@
+// vite.config.ts
+import { resolve } from 'path';
+import { defineConfig } from 'vite';
+
+// https://vitejs.dev/guide/build.html#library-mode
+export default defineConfig({
+ build: {
+ lib: {
+ entry: resolve(__dirname, 'index.cjs'),
+ name: '@cloudkit/eslint-config',
+ fileName: 'index'
+ }
+ },
+ plugins: []
+});
diff --git a/packages/service-contract/.eslintrc.cjs b/packages/service-contract/.eslintrc.cjs
new file mode 100644
index 0000000..f1c66a7
--- /dev/null
+++ b/packages/service-contract/.eslintrc.cjs
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: ['@cloudkit/eslint-config']
+};
diff --git a/packages/service-contract/.lintstagedrc b/packages/service-contract/.lintstagedrc
new file mode 100644
index 0000000..5e06bd8
--- /dev/null
+++ b/packages/service-contract/.lintstagedrc
@@ -0,0 +1,6 @@
+{
+ "*.{js,ts,svelte,css,scss,postcss,md,json,yml}": [
+ "prettier --config ../../.prettierrc --write --cache ./src"
+ ],
+ "*.{ts,svelte,css,scss,yml}": ["eslint --cache --fix"]
+}
diff --git a/packages/service-contract/package.json b/packages/service-contract/package.json
new file mode 100644
index 0000000..d4487b9
--- /dev/null
+++ b/packages/service-contract/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@cloudkit/service-contract",
+ "version": "0.0.1",
+ "main": "./dist/cloudkit-openapi-docs.json",
+ "author": "Daniel Einars",
+ "description": "OpenAPI Spec for cloudkit",
+ "exports": {
+ ".": {
+ "import": "./dist/cloudkit-openapi-docs.json",
+ "require": "./dist/cloudkit-openapi-docs.json"
+ }
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/polaroidkidd/cloudkit.dle.dev.git"
+ },
+ "scripts": {
+ "build": "pnpm dist && pnpm format",
+ "dist": "tsx ./src/index.ts",
+ "format": "prettier --config ../../.prettierrc --write ."
+ },
+ "keywords": [
+ "cloudkit",
+ "openapi"
+ ],
+ "license": "ISC",
+ "dependencies": {
+ "@asteasolutions/zod-to-openapi": "^7.1.1",
+ "@cloudkit/db-schema": "workspace:^",
+ "@cloudkit/eslint-config": "workspace:^",
+ "tsx": "^4.19.0",
+ "yaml": "^2.5.1",
+ "zod": "^3.23.8"
+ }
+}
diff --git a/packages/service-contract/src/index.ts b/packages/service-contract/src/index.ts
new file mode 100644
index 0000000..a1dd4b8
--- /dev/null
+++ b/packages/service-contract/src/index.ts
@@ -0,0 +1,355 @@
+import {
+ extendZodWithOpenApi,
+ OpenApiGeneratorV3,
+ OpenAPIRegistry
+} from '@asteasolutions/zod-to-openapi';
+import * as fs from 'fs';
+import path from 'path';
+import { z } from 'zod';
+import { UserDTO, ZodErrorSchemaDTO } from './models';
+
+extendZodWithOpenApi(z);
+const registry = new OpenAPIRegistry();
+
+const userIdSchema = registry.registerParameter(
+ 'userId',
+ z.string().openapi({
+ param: {
+ name: 'userId',
+ in: 'path'
+ },
+ example: '1212121'
+ })
+);
+
+const Components = {
+ User: 'User'
+} as const;
+
+const Tags = {
+ User: 'User',
+ Auth: 'Auth'
+};
+const sessionCookie = registry.registerComponent('securitySchemes', 'cookieAuth', {
+ type: 'apiKey',
+ in: 'cookie',
+ name: 'auth_session '
+});
+
+registry.registerPath({
+ method: 'get',
+ path: '/api/v1/user',
+ description: 'Retrieves the currently logged in user',
+ tags: [Tags.User],
+ security: [{ [sessionCookie.name]: [] }],
+
+ responses: {
+ 200: {
+ description: 'Retrieves the currently logged in user',
+ content: {
+ 'application/json': {
+ schema: UserDTO.openapi(Components.User)
+ }
+ }
+ },
+
+ 401: {
+ description: 'Invalid Session.'
+ },
+ 403: {
+ description: 'Access denied.'
+ },
+ 404: {
+ description: 'Resource not found.'
+ }
+ }
+});
+
+registry.registerPath({
+ method: 'delete',
+ path: '/api/v1/user',
+ description: 'Retrieves the currently logged in user',
+ tags: [Tags.User],
+ security: [{ [sessionCookie.name]: [] }],
+
+ responses: {
+ 200: {
+ description: 'Retrieves the currently logged in user',
+ content: {
+ 'application/json': {
+ schema: UserDTO.openapi(Components.User)
+ }
+ }
+ },
+
+ 401: {
+ description: 'Invalid Session.'
+ },
+ 403: {
+ description: 'Access denied.'
+ },
+ 404: {
+ description: 'Resource not found.'
+ }
+ }
+});
+
+registry.registerPath({
+ method: 'get',
+ path: '/api/v1/user/{userId}',
+ description: 'Retrieves a specific User',
+ tags: [Tags.User],
+ security: [{ [sessionCookie.name]: [] }],
+ request: {
+ params: z.object({ userId: userIdSchema })
+ },
+ responses: {
+ 200: {
+ description: 'The specific User.',
+ content: {
+ 'application/json': {
+ schema: UserDTO.openapi(Components.User)
+ }
+ }
+ },
+
+ 401: {
+ description: 'Invalid Session.'
+ },
+ 403: {
+ description: 'Access denied.'
+ },
+ 404: {
+ description: 'Resource not found.'
+ }
+ }
+});
+
+registry.registerPath({
+ method: 'put',
+ path: '/api/v1/auth',
+ description: 'Creates a new session for a user (authenticate)',
+ tags: [Tags.Auth],
+ security: [{ [sessionCookie.name]: [] }],
+
+ request: {
+ body: {
+ content: {
+ 'application/json': {
+ schema: UserDTO.openapi(Components.User)
+ }
+ }
+ }
+ },
+ responses: {
+ 200: {
+ description: 'The newly created user.',
+ content: {
+ 'application/json': {
+ schema: UserDTO.openapi(Components.User)
+ }
+ }
+ },
+ 403: {
+ description: 'Username or Password is invalid.'
+ },
+ 400: {
+ description: 'Invalid input.',
+ content: {
+ 'application/text': {
+ schema: ZodErrorSchemaDTO
+ }
+ }
+ }
+ }
+});
+registry.registerPath({
+ method: 'post',
+ path: '/api/v1/auth',
+ description: 'Creates a new user (register)',
+ tags: [Tags.Auth],
+ security: [{ [sessionCookie.name]: [] }],
+
+ request: {
+ body: {
+ content: {
+ 'application/json': {
+ schema: UserDTO.openapi(Components.User)
+ }
+ }
+ }
+ },
+ responses: {
+ 200: {
+ description: 'The newly created user.',
+ content: {
+ 'application/json': {
+ schema: UserDTO.openapi(Components.User)
+ }
+ }
+ },
+
+ 401: {
+ description: 'Invalid Session.'
+ },
+ 403: {
+ description: 'Access denied.'
+ },
+ 404: {
+ description: 'Resource not found.'
+ }
+ }
+});
+registry.registerPath({
+ method: 'delete',
+ path: '/api/v1/auth',
+ description: 'Close all sessions (log me out everywhere)',
+ tags: [Tags.Auth],
+ security: [{ [sessionCookie.name]: [] }],
+
+ responses: {
+ 200: {
+ description: 'Session Deleted.'
+ },
+
+ 401: {
+ description: 'Invalid Session.'
+ },
+ 403: {
+ description: 'Access denied.'
+ },
+ 404: {
+ description: 'Resource not found.'
+ }
+ }
+});
+
+registry.registerPath({
+ method: 'delete',
+ path: '/api/v1/auth/{session}',
+ description: 'Close a specific sessions (log me out here)',
+ tags: [Tags.Auth],
+ security: [{ [sessionCookie.name]: [] }],
+
+ request: {
+ body: {
+ content: {
+ 'application/json': {
+ schema: UserDTO.openapi(Components.User)
+ }
+ }
+ }
+ },
+ responses: {
+ 200: {
+ description: 'Session Deleted'
+ },
+
+ 401: {
+ description: 'Invalid Session.'
+ },
+ 403: {
+ description: 'Access denied.'
+ },
+ 404: {
+ description: 'Resource not found.'
+ }
+ }
+});
+
+registry.registerPath({
+ method: 'patch',
+ path: '/api/v1/user',
+ description: 'Updates an existing user',
+ tags: [Tags.User],
+ security: [{ [sessionCookie.name]: [] }],
+
+ request: {
+ body: {
+ content: {
+ 'application/json': {
+ schema: UserDTO.openapi(Components.User)
+ }
+ }
+ }
+ },
+ responses: {
+ 200: {
+ description: 'The updated user.',
+ content: {
+ 'application/json': {
+ schema: UserDTO.openapi(Components.User)
+ }
+ }
+ },
+ 400: {
+ description: 'Invalid input.',
+ content: {
+ 'application/text': {
+ schema: ZodErrorSchemaDTO
+ }
+ }
+ },
+
+ 401: {
+ description: 'Invalid Session.'
+ },
+ 403: {
+ description: 'Access denied.'
+ },
+ 404: {
+ description: 'Resource not found.'
+ }
+ }
+});
+
+function getOpenApiDocumentation() {
+ const generator = new OpenApiGeneratorV3(registry.definitions);
+
+ return generator.generateDocument({
+ openapi: '3.1.0',
+ info: {
+ version: '0.0.1',
+ title: 'CloudKit API',
+ description:
+ 'This is the API for CloudKit. All endpoints are only available to authenticated users and require a valid session. Additionally they are all rate limited.',
+
+ contact: {
+ name: 'Daniel Einars',
+ email: 'daniel@cloudkit.dle.dev'
+ }
+ },
+
+ servers: [
+ {
+ url: 'https://cloudkit.dle.dev/api/v1',
+ description: 'Production'
+ }
+ ]
+ });
+}
+
+function writeDocumentation() {
+ // OpenAPI JSON
+ const docs = getOpenApiDocumentation();
+
+ // YAML equivalent
+
+ const outputPath = path.join(__dirname, '../dist');
+ //create the directory if it doesn't exist using path
+ if (fs.existsSync(outputPath)) {
+ fs.rmSync(outputPath, {
+ recursive: true,
+ force: true
+ });
+ }
+ fs.mkdirSync(outputPath, {
+ recursive: true
+ });
+
+ fs.writeFileSync(`${outputPath}/cloudkit-openapi-docs.json`, JSON.stringify(docs, null, 2), {
+ encoding: 'utf-8'
+ });
+}
+
+writeDocumentation();
diff --git a/packages/service-contract/src/models.ts b/packages/service-contract/src/models.ts
new file mode 100644
index 0000000..7a75bd9
--- /dev/null
+++ b/packages/service-contract/src/models.ts
@@ -0,0 +1,41 @@
+import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
+import {
+ ACCEPTED_IMAGE_TYPES,
+ ImageOptionalDefaultsSchema,
+ MAX_FILE_SIZE,
+ UserOptionalDefaultsSchema
+} from '@cloudkit/db-schema';
+
+import { z } from 'zod';
+extendZodWithOpenApi(z);
+
+export const UserDTO = UserOptionalDefaultsSchema;
+export const FileDTO = z
+ .any()
+ .refine((files) => files?.length == 1, 'Image is required.')
+ .refine((files) => files?.[0]?.size <= MAX_FILE_SIZE, `Max file size is 5MB.`)
+ .refine(
+ (files) => ACCEPTED_IMAGE_TYPES.includes(files?.[0]?.type),
+ '.jpg, .jpeg, .png and .webp files are accepted.'
+ )
+ .optional()
+ .or(z.string().optional());
+
+export const ImageDTO = ImageOptionalDefaultsSchema.merge(
+ z.object({
+ file: FileDTO.optional()
+ })
+);
+
+export const ZodErrorSchemaDTO = z.object({
+ name: z.string(),
+ issues: z.array(
+ z.object({
+ code: z.string(),
+ expected: z.string(),
+ received: z.string(),
+ path: z.array(z.string()),
+ message: z.string()
+ })
+ )
+});
diff --git a/packages/service-contract/tsconfig.json b/packages/service-contract/tsconfig.json
new file mode 100644
index 0000000..5c924eb
--- /dev/null
+++ b/packages/service-contract/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../tsconfig-base.json",
+ "compilerOptions": {
+ "resolveJsonModule": true
+ },
+ "include": ["src"]
+}
diff --git a/packages/service-contract/vite.config.ts b/packages/service-contract/vite.config.ts
new file mode 100644
index 0000000..9445993
--- /dev/null
+++ b/packages/service-contract/vite.config.ts
@@ -0,0 +1,15 @@
+// vite.config.ts
+import { resolve } from 'path';
+import { defineConfig } from 'vite';
+
+// https://vitejs.dev/guide/build.html#library-mode
+export default defineConfig({
+ build: {
+ lib: {
+ entry: resolve(__dirname, 'index.cjs'),
+ name: '@cloudkit/service-contract',
+ fileName: 'index'
+ }
+ },
+ plugins: []
+});
diff --git a/packages/tests/.eslintrc.cjs b/packages/tests/.eslintrc.cjs
new file mode 100644
index 0000000..f1c66a7
--- /dev/null
+++ b/packages/tests/.eslintrc.cjs
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: ['@cloudkit/eslint-config']
+};
diff --git a/packages/tests/.lintstagedrc b/packages/tests/.lintstagedrc
new file mode 100644
index 0000000..e3f1306
--- /dev/null
+++ b/packages/tests/.lintstagedrc
@@ -0,0 +1,6 @@
+{
+ "*.{js,ts,svelte,css,scss,postcss,md,json,yml}": [
+ "prettier --config ../../.prettierrc --write --cache ./e2e"
+ ],
+ "*.{ts,svelte,css,scss,yml}": ["eslint --cache --fix"]
+}
diff --git a/packages/tests/e2e/managing-groups.spec.ts b/packages/tests/e2e/managing-groups.spec.ts
new file mode 100644
index 0000000..bb6f402
--- /dev/null
+++ b/packages/tests/e2e/managing-groups.spec.ts
@@ -0,0 +1,238 @@
+import { PATHS } from '@lib/routing/paths';
+import { expect, test } from '@playwright/test';
+import { PrismaClient } from '@prisma/client';
+const USER = {
+ EMAIL: 'playwright-group-test@test.com',
+ FRST_NAME: 'Play',
+ LAST_NAME: 'Wirght',
+ PASSWORD: 'password123',
+ CONFIRM_PASSWORD: 'password123'
+};
+
+const db = new PrismaClient({
+ datasources: {
+ db: {
+ url: process.env.DATABASE_URL
+ }
+ }
+});
+
+test.afterAll(async () => {
+ await db.user.deleteMany({
+ where: {
+ email: USER.EMAIL
+ }
+ });
+});
+
+test('Create a User', async ({ baseURL, page }) => {
+ await test.step('fill in user registration form', async () => {
+ await page.goto(baseURL as string);
+
+ await page.getByRole('button', { name: 'Register' }).click();
+ await expect(
+ page.getByText(
+ 'Email First Name Last Name Password Confirm Password Upload your avatar Allowed '
+ )
+ ).toBeVisible();
+ await page.locator('input[name="email"]').fill(USER.EMAIL);
+ await page.locator('input[name="firstName"]').fill(USER.FRST_NAME);
+ await page.locator('input[name="lastName"]').fill(USER.LAST_NAME);
+ await page.locator('input[name="password"]').fill(USER.PASSWORD);
+ await page.locator('input[name="confirmPassword"]').fill(USER.CONFIRM_PASSWORD);
+ await page.getByLabel('Confirm Signing Up').click();
+ await page.waitForURL(`${baseURL as string}${PATHS.COLLECTIONS}`);
+ const button = page.getByRole('button', { name: "Don't show this again" });
+ await button.waitFor({ state: 'attached' });
+ await button.click({ force: true });
+ await page.waitForTimeout(500);
+ const isVisible = await page.getByTestId('onboarding-modal').isVisible();
+ expect(isVisible).toBe(false);
+ });
+});
+
+test('Create collections', async ({ baseURL, page }) => {
+ await test.step('sign in', async () => {
+ await page.goto(baseURL as string);
+
+ await page.getByRole('button', { name: 'Sign In' }).click();
+
+ await page.locator('input[name="email"]').fill(USER.EMAIL);
+ await page.locator('input[name="password"]').fill(USER.PASSWORD);
+ await page.getByLabel('Confirm Sign In').click();
+ await page.waitForURL((baseURL as string) + PATHS.COLLECTIONS);
+ });
+ await test.step('create a group', async () => {
+ await page.getByRole('button', { name: 'Create a Collection' }).click();
+ const modal = page.getByTestId('create-collection-modal');
+ const isVisible = await modal.isVisible();
+ expect(isVisible).toBe(true);
+ const textBox = page.getByTestId('create-collection-modal').getByRole('textbox');
+ await textBox.fill('Test Group');
+ const button = page.getByLabel('Create Collection');
+ await button.waitFor({ state: 'attached' });
+ await button.click({ force: true });
+ await page.waitForResponse((response) => response.url().includes('createCollection'));
+ await page.waitForURL((baseURL as string) + PATHS.COLLECTIONS);
+ const collection = await db.collection.findFirst({
+ where: {
+ name: 'Test Group'
+ }
+ });
+
+ expect(collection).toBeTruthy();
+ const isNewCollectionVisible = await page.getByText('Test Group').isVisible();
+ expect(isNewCollectionVisible).toBe(true);
+ });
+ await test.step('navigate to group', async () => {
+ await page.getByRole('link', { name: 'View Items' }).click();
+ const collection = await db.collection.findFirst({
+ where: {
+ name: 'Test Group'
+ }
+ });
+ await page.waitForURL(`${baseURL as string}${PATHS.COLLECTIONS}/${collection?.id}`);
+ expect(page.url()).toBe(`${baseURL as string}${PATHS.COLLECTIONS}/${collection?.id}`);
+ });
+
+ await test.step('create a nested collection', async () => {
+ await page.getByRole('button', { name: 'Add a Sub Group' }).click({ force: true });
+ const modal = page.getByTestId('create-collection-modal');
+ const isVisible = await modal.isVisible();
+ expect(isVisible).toBe(true);
+ const textBox = page.getByTestId('create-collection-modal').getByRole('textbox');
+ await textBox.fill('Test Child Group');
+ const button = page.getByLabel('Create Collection');
+ await button.waitFor({ state: 'attached' });
+ await button.click({ force: true });
+ await page.waitForResponse((response) => response.url().includes('createCollection'));
+ const parentCollection = await db.collection.findFirst({
+ where: {
+ name: 'Test Group',
+ isRoot: true
+ }
+ });
+ await page.waitForURL(`${baseURL as string}${PATHS.COLLECTIONS}/${parentCollection?.id}`);
+ const childCollection = await db.collection.findFirst({
+ where: {
+ name: 'Test Child Group',
+ isRoot: false,
+ parentId: parentCollection!.id
+ }
+ });
+
+ expect(childCollection).toBeTruthy();
+ const isNewCollectionVisible = await page.getByText('Test Child Group').isVisible();
+ expect(isNewCollectionVisible).toBe(true);
+ });
+ await test.step('navigate back to parent group', async () => {
+ await page.goBack();
+ await page.waitForURL((baseURL as string) + PATHS.COLLECTIONS);
+ expect(page.url()).toBe((baseURL as string) + PATHS.COLLECTIONS);
+ });
+
+ await test.step('Attempt to create duplicate collections', async () => {});
+});
+
+test('Delete Collections', async ({ baseURL, page }) => {
+ async function signIn() {
+ await page.goto(baseURL as string);
+
+ await page.getByRole('button', { name: 'Sign In' }).click();
+
+ await page.locator('input[name="email"]').fill(USER.EMAIL);
+ await page.locator('input[name="password"]').fill(USER.PASSWORD);
+ await page.getByLabel('Confirm Sign In').click();
+ await page.waitForURL((baseURL as string) + PATHS.COLLECTIONS);
+ }
+
+ async function createGroup(name: string) {
+ await page.getByRole('button', { name: 'Create a Collection' }).click();
+ const modal = page.getByTestId('create-collection-modal');
+ const isVisible = await modal.isVisible();
+ expect(isVisible).toBe(true);
+ const textBox = page.getByTestId('create-collection-modal').getByRole('textbox');
+ await textBox.fill(name);
+ const button = page.getByLabel('Create Collection');
+ await button.waitFor({ state: 'attached' });
+ await button.click({ force: true });
+ await page.waitForResponse((response) => response.url().includes('createCollection'));
+ await page.waitForURL((baseURL as string) + PATHS.COLLECTIONS);
+ }
+
+ await test.step('delete parent collection without children', async () => {
+ await db.collection.deleteMany({
+ where: {
+ User: {
+ email: USER.EMAIL
+ }
+ }
+ });
+ await signIn();
+ await createGroup('Test Group WIthout Child');
+ await page.getByRole('button', { name: 'Delete Collection' }).click();
+ await page.getByTestId('confirm-deleting-collection').click();
+
+ await page.waitForResponse((response) => response.url().includes('deleteCollection'));
+ const collections = await db.collection.findMany({
+ where: {
+ name: 'Test Group WIthout Child'
+ }
+ });
+ expect(collections).toStrictEqual([]);
+ });
+
+ await test.step('Attempt to delete a parent collection with children', async () => {
+ await createGroup('Test Group Parent');
+
+ await page.getByRole('link', { name: 'View Items' }).click();
+ const collection = await db.collection.findFirst({
+ where: {
+ name: 'Test Group Parent'
+ }
+ });
+ await page.waitForURL(`${baseURL as string}${PATHS.COLLECTIONS}/${collection?.id}`);
+
+ await page.getByRole('button', { name: 'Add a Sub Group' }).click({ force: true });
+
+ const textBox = page.getByTestId('create-collection-modal').getByRole('textbox');
+ await textBox.fill('Test Child Group');
+ const button = page.getByLabel('Create Collection');
+ await button.waitFor({ state: 'attached' });
+ await button.click({ force: true });
+ await page.waitForResponse((response) => response.url().includes('createCollection'));
+ await page.goBack();
+
+ await page.getByRole('button', { name: 'Delete Collection' }).click();
+ await page.getByTestId('confirm-deleting-collection').click();
+ await page.waitForResponse((response) => response.url().includes('deleteCollection'));
+ const errorModal = page.getByText("Collection has children. Can'");
+ expect(errorModal).toBeVisible();
+ });
+
+ await test.step('Delete Chilld first, then parent', async () => {
+ await page.getByRole('link', { name: 'View Items' }).click();
+ const collection = await db.collection.findFirst({
+ where: {
+ name: 'Test Group Parent'
+ }
+ });
+ await page.waitForURL(`${baseURL as string}${PATHS.COLLECTIONS}/${collection?.id}`);
+
+ await page.getByRole('button', { name: 'Delete Collection' }).click();
+ await page.getByTestId('confirm-deleting-collection').click();
+ await page.waitForResponse((response) => response.url().includes('deleteCollection'));
+ await page.goBack();
+ await page.getByRole('button', { name: 'Delete Collection' }).click();
+ await page.getByTestId('confirm-deleting-collection').click();
+ await page.waitForResponse((response) => response.url().includes('deleteCollection'));
+ const collections = await db.collection.findMany({
+ where: {
+ User: {
+ email: USER.EMAIL
+ }
+ }
+ });
+ expect(collections).toStrictEqual([]);
+ });
+});
diff --git a/packages/tests/e2e/user-auth-flows.spec.ts b/packages/tests/e2e/user-auth-flows.spec.ts
new file mode 100644
index 0000000..69d9f4a
--- /dev/null
+++ b/packages/tests/e2e/user-auth-flows.spec.ts
@@ -0,0 +1,121 @@
+import { PATHS } from '@lib/routing/paths';
+import { expect, test } from '@playwright/test';
+import { PrismaClient } from '@prisma/client';
+const USER = {
+ EMAIL: 'playwright-user-auth-test@test.com',
+ FRST_NAME: 'Play',
+ LAST_NAME: 'Wirght',
+ PASSWORD: 'password123',
+ CONFIRM_PASSWORD: 'password123'
+};
+
+const db = new PrismaClient({
+ datasources: {
+ db: {
+ url: process.env.DATABASE_URL
+ }
+ }
+});
+test.afterAll(async () => {
+ await db.user.deleteMany({
+ where: {
+ email: USER.EMAIL
+ }
+ });
+});
+
+test('User Registration', async ({ baseURL, page }) => {
+ await test.step('fill in user registration form', async () => {
+ await page.goto(baseURL as string);
+
+ await page.getByRole('button', { name: 'Register' }).click();
+ await expect(
+ page.getByText(
+ 'Email First Name Last Name Password Confirm Password Upload your avatar Allowed '
+ )
+ ).toBeVisible();
+ await page.locator('input[name="email"]').fill(USER.EMAIL);
+ await page.locator('input[name="firstName"]').fill(USER.FRST_NAME);
+ await page.locator('input[name="lastName"]').fill(USER.LAST_NAME);
+ await page.locator('input[name="password"]').fill(USER.PASSWORD);
+ await page.locator('input[name="confirmPassword"]').fill(USER.CONFIRM_PASSWORD);
+
+ expect(await page.locator('input[name="email"]').inputValue()).toBe(USER.EMAIL);
+ expect(await page.locator('input[name="firstName"]').inputValue()).toBe(USER.FRST_NAME);
+ expect(await page.locator('input[name="lastName"]').inputValue()).toBe(USER.LAST_NAME);
+ expect(await page.locator('input[name="password"]').inputValue()).toBe(USER.PASSWORD);
+ expect(await page.locator('input[name="confirmPassword"]').inputValue()).toBe(
+ USER.CONFIRM_PASSWORD
+ );
+ });
+
+ await test.step('submit user registration form', async () => {
+ await page.getByLabel('Confirm Signing Up').click();
+ await page.waitForURL(`${baseURL as string}${PATHS.COLLECTIONS}`);
+
+ expect(page).toHaveURL(`${baseURL as string}${PATHS.COLLECTIONS}`);
+ });
+
+ await test.step('dismiss onboarding', async () => {
+ const button = page.getByRole('button', { name: "Don't show this again" });
+ await button.waitFor({ state: 'attached' });
+ await button.click({ force: true });
+ await page.waitForTimeout(1000);
+ const isVisible = await page.getByTestId('onboarding-modal').isVisible();
+ expect(isVisible).toBe(false);
+ });
+ await test.step('sign out', async () => {
+ await page.getByRole('button', { name: 'member icon' }).click();
+
+ await page.getByRole('button', { name: 'Sign Out' }).click();
+ await page.waitForURL(baseURL as string);
+
+ expect(page).toHaveURL(baseURL as string);
+ const user = await db.user.findFirst({
+ where: {
+ email: USER.EMAIL
+ }
+ });
+
+ expect(user).not.toBeNull();
+ });
+});
+test('User Deletion', async ({ baseURL, page }) => {
+ await test.step('sign in', async () => {
+ await page.goto(baseURL as string);
+
+ await page.getByRole('button', { name: 'Sign In' }).click();
+
+ await page.locator('input[name="email"]').fill(USER.EMAIL);
+ await page.locator('input[name="password"]').fill(USER.PASSWORD);
+ expect(await page.locator('input[name="email"]').inputValue()).toBe(USER.EMAIL);
+ expect(await page.locator('input[name="password"]').inputValue()).toBe(USER.PASSWORD);
+ });
+
+ await test.step('submit sign in form', async () => {
+ await page.getByLabel('Confirm Sign In').click();
+ await page.waitForURL((baseURL as string) + PATHS.COLLECTIONS);
+ expect(page).toHaveURL((baseURL as string) + PATHS.COLLECTIONS);
+ });
+
+ await test.step('navigate to profile page', async () => {
+ await page.getByRole('navigation').getByRole('link').click();
+ await page.waitForURL((baseURL as string) + PATHS.PROFILE);
+ expect(page).toHaveURL((baseURL as string) + PATHS.PROFILE);
+ });
+
+ await test.step('delete account', async () => {
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
+ await page.getByRole('button', { name: 'Delete Account' }).click();
+ await page.getByTestId('modal').getByRole('button', { name: 'Confirm' }).click();
+ await page.waitForURL(baseURL as string);
+ expect(page).toHaveURL(baseURL as string);
+ const user = await db.user.findFirst({
+ where: {
+ email: USER.EMAIL
+ }
+ });
+
+ expect(user).toBeNull();
+ });
+});
diff --git a/packages/tests/package.json b/packages/tests/package.json
new file mode 100644
index 0000000..897aa9f
--- /dev/null
+++ b/packages/tests/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@cloudkit/tests",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "lint": "eslint ./e2e"
+ },
+ "dependencies": {
+ "@playwright/test": "^1.47.0",
+ "@cloudkit/eslint-config": "workspace:^",
+ "jest": "^29.7.0",
+ "playwright": "^1.47.0"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC"
+}
diff --git a/packages/tests/playwright.config.ci.ts b/packages/tests/playwright.config.ci.ts
new file mode 100644
index 0000000..d548fc8
--- /dev/null
+++ b/packages/tests/playwright.config.ci.ts
@@ -0,0 +1,45 @@
+import { devices } from '@playwright/test';
+import { defineConfig } from '@playwright/test';
+import dotenv from 'dotenv';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file
+const __dirname = path.dirname(__filename); // get the name of the directory
+dotenv.config({ path: path.resolve(__dirname, '.env.development') });
+
+export default defineConfig({
+ webServer: {
+ command: 'pnpm run-ci',
+ port: 3000
+ },
+ timeout: 30_000,
+ expect: {
+ timeout: 30_000
+ },
+
+ retries: 2,
+
+ use: {
+ trace: 'retain-on-failure',
+ video: 'retain-on-failure',
+
+ actionTimeout: 10_000
+ },
+
+ reporter: 'github',
+ testDir: 'tests/e2e',
+ testMatch: /(.+\.)?(test|spec)\.[jt]s/,
+
+ projects: [
+ {
+ name: 'chromium',
+ use: {
+ ...devices['Desktop Chrome'],
+ channel: 'chrome',
+ viewport: { width: 1920, height: 1080 },
+ baseURL: 'http://localhost:4000'
+ }
+ }
+ ]
+});
diff --git a/packages/tests/playwright.config.dev.ts b/packages/tests/playwright.config.dev.ts
new file mode 100644
index 0000000..38b0887
--- /dev/null
+++ b/packages/tests/playwright.config.dev.ts
@@ -0,0 +1,42 @@
+import { devices } from '@playwright/test';
+import { defineConfig } from '@playwright/test';
+import dotenv from 'dotenv';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url); // get the resolved path to the file
+const __dirname = path.dirname(__filename); // get the name of the directory
+dotenv.config({ path: path.resolve(__dirname, '.env.development') });
+
+export default defineConfig({
+ webServer: {
+ command: 'pnpm run-ci',
+ port: 3000
+ },
+ retries: 0,
+ // test timeout set to 10 seconds
+ timeout: 30_000,
+ expect: {
+ // expect timeout set to 10 seconds
+ timeout: 30_000
+ },
+ // Reporter to use
+ use: {
+ actionTimeout: 10_000
+ },
+ // Reporter to use
+ reporter: 'html',
+ testDir: 'tests/e2e',
+ testMatch: /(.+\.)?(test|spec)\.[jt]s/,
+ projects: [
+ {
+ name: 'chromium',
+ use: {
+ ...devices['Desktop Chrome'],
+ channel: 'chrome',
+ viewport: { width: 1920, height: 1080 },
+ baseURL: 'http://localhost:4000'
+ }
+ }
+ ]
+});
diff --git a/packages/tests/tsconfig.json b/packages/tests/tsconfig.json
new file mode 100644
index 0000000..1646576
--- /dev/null
+++ b/packages/tests/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "../../tsconfig-base.json"
+}
diff --git a/tests/unit/index.spec.ts b/packages/tests/unit/index.spec.ts
similarity index 100%
rename from tests/unit/index.spec.ts
rename to packages/tests/unit/index.spec.ts
diff --git a/packages/ui-core/.eslintrc.cjs b/packages/ui-core/.eslintrc.cjs
new file mode 100644
index 0000000..f1c66a7
--- /dev/null
+++ b/packages/ui-core/.eslintrc.cjs
@@ -0,0 +1,3 @@
+module.exports = {
+ extends: ['@cloudkit/eslint-config']
+};
diff --git a/packages/ui-core/.lintstagedrc b/packages/ui-core/.lintstagedrc
new file mode 100644
index 0000000..5e06bd8
--- /dev/null
+++ b/packages/ui-core/.lintstagedrc
@@ -0,0 +1,6 @@
+{
+ "*.{js,ts,svelte,css,scss,postcss,md,json,yml}": [
+ "prettier --config ../../.prettierrc --write --cache ./src"
+ ],
+ "*.{ts,svelte,css,scss,yml}": ["eslint --cache --fix"]
+}
diff --git a/packages/ui-core/package.json b/packages/ui-core/package.json
new file mode 100644
index 0000000..e63f317
--- /dev/null
+++ b/packages/ui-core/package.json
@@ -0,0 +1,66 @@
+{
+ "name": "@cloudkit/ui-core",
+ "version": "1.0.0",
+ "description": "",
+ "type": "module",
+ "scripts": {
+ "dev": "svelte-kit sync && svelte-package -w",
+ "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
+ "lint": "eslint ./src",
+ "test": "vitest run",
+ "sync": "svelte-kit sync"
+ },
+ "exports": {
+ ".": {
+ "types": "./src/index.ts",
+ "default": "./src/index.ts"
+ }
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "dependencies": {
+ "@cloudkit/db-schema": "workspace:^",
+ "@cloudkit/eslint-config": "workspace:^",
+ "@floating-ui/dom": "^1.6.10",
+ "@skeletonlabs/skeleton": "2.10.2",
+ "@skeletonlabs/tw-plugin": "^0.4.0",
+ "@svelte-put/qr": "^1.2.1",
+ "@sveltejs/adapter-node": "5.2.2",
+ "@sveltejs/kit": "^2.5.26",
+ "@tailwindcss/container-queries": "^0.1.1",
+ "@tailwindcss/forms": "0.5.9",
+ "@tailwindcss/typography": "0.5.15",
+ "axios": "^1.7.7",
+ "browser-image-compression": "^2.0.2",
+ "classnames": "^2.5.1",
+ "sass": "^1.78.0",
+ "svelte": "^4.2.19",
+ "sveltekit-superforms": "^2.19.1",
+ "tailwind-merge": "^2.5.2",
+ "tailwindcss": "^3.4.10",
+ "uuid": "^10.0.0",
+ "zod": "^3.23.8"
+ },
+ "devDependencies": {
+ "@storybook/addon-svelte-csf": "^4.1.7",
+ "@storybook/svelte": "^8.3.5",
+ "@sveltejs/adapter-auto": "^3.0.0",
+ "@sveltejs/adapter-static": "^3.0.5",
+ "@sveltejs/package": "^2.3.5",
+ "@sveltejs/vite-plugin-svelte": "^3.1.2",
+ "@testing-library/jest-dom": "^6.6.2",
+ "@testing-library/svelte": "^5.2.4",
+ "@testing-library/user-event": "^14.5.2",
+ "@vitest/ui": "^2.1.3",
+ "autoprefixer": "^10.4.20",
+ "jsdom": "^25.0.1",
+ "postcss": "^8.4.45",
+ "postcss-load-config": "^6.0.1",
+ "svelte-check": "^4.0.1",
+ "svelte-preprocess": "^6.0.2",
+ "type-fest": "^4.26.1",
+ "vite-tsconfig-paths": "^5.0.1",
+ "vitest": "2.1.3"
+ }
+}
diff --git a/packages/ui-core/src/api/base-api-client.ts b/packages/ui-core/src/api/base-api-client.ts
new file mode 100644
index 0000000..9f6dfad
--- /dev/null
+++ b/packages/ui-core/src/api/base-api-client.ts
@@ -0,0 +1,57 @@
+import type { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios';
+import axios from 'axios';
+
+export abstract class ApiServiceBase {
+ public context: string;
+
+ protected readonly http: AxiosInstance;
+
+ constructor(
+ context: string,
+ commonRequestConfig: AxiosRequestConfig = {
+ transformRequest: [
+ (data) => {
+ return JSON.stringify(data);
+ }
+ ]
+ }
+ ) {
+ this.context = context;
+
+ let { headers = {} } = commonRequestConfig;
+ if (headers['Content-Type'] == null) {
+ headers = {
+ ...headers,
+ 'Content-Type': 'application/json'
+ };
+ }
+
+ this.http = axios.create({
+ ...commonRequestConfig,
+ headers,
+ baseURL: context,
+ paramsSerializer: { indexes: null }
+ });
+ // /**
+ // * Use this to log out all requests during a test run.
+ // */
+ // if (process.env.NODE_ENV === 'test') {
+ // this.http.interceptors.request.use(
+ // (config) => {
+ // console.log(`${config.method.toUpperCase()} ${config.baseURL}${config.url}`);
+ //
+ // return config;
+ // }
+ // );
+ // }
+
+ this.http.interceptors.response.use(null, (error: AxiosError) => {
+ if (error.response && typeof error.response.data === 'string') {
+ // Use the verify middleware here verifySessionTimeout(error.response);
+ // verifyMaintenanceMode(error.response as AxiosResponse);
+ }
+
+ return Promise.reject(error);
+ });
+ }
+}
diff --git a/packages/ui-core/src/app.html b/packages/ui-core/src/app.html
new file mode 100644
index 0000000..abf05de
--- /dev/null
+++ b/packages/ui-core/src/app.html
@@ -0,0 +1,18 @@
+
+
+
+ %sveltekit.head%
+
+
+
+
+
+ %sveltekit.body%
+
+
diff --git a/packages/ui-core/src/cache/index.ts b/packages/ui-core/src/cache/index.ts
new file mode 100644
index 0000000..b53c0b8
--- /dev/null
+++ b/packages/ui-core/src/cache/index.ts
@@ -0,0 +1,29 @@
+import { browser } from '$app/environment';
+import { error } from '@sveltejs/kit';
+
+export const cache = new Map();
+
+export const cacheFetch = async (key: string, fetchCallback: () => ReturnType) => {
+ if (browser && cache.has(key)) {
+ console.warn(`Cache hit for ${key}`);
+ return cache.get(key) as T;
+ }
+ const response = await fetchCallback();
+
+ if (!response.ok) {
+ const message = await response.json();
+ throw error(response.status, message);
+ }
+ const result = await response.json();
+ console.warn(`Caching ${key}`);
+ cache.set(key, result);
+
+ return result as T;
+};
+
+export const clearCache = (key: string) => {
+ if (browser && cache.has(key)) {
+ console.warn(`Clearing cache for ${key}`);
+ cache.delete(key);
+ }
+};
diff --git a/packages/ui-core/src/components/buttons/index.ts b/packages/ui-core/src/components/buttons/index.ts
new file mode 100644
index 0000000..77d0b97
--- /dev/null
+++ b/packages/ui-core/src/components/buttons/index.ts
@@ -0,0 +1 @@
+export { default as Button } from './simple-button.svelte';
diff --git a/packages/ui-core/src/components/buttons/navigation-button.stories.svelte b/packages/ui-core/src/components/buttons/navigation-button.stories.svelte
new file mode 100644
index 0000000..48ad394
--- /dev/null
+++ b/packages/ui-core/src/components/buttons/navigation-button.stories.svelte
@@ -0,0 +1,42 @@
+
+
+
+
+
+ You are navigating to {args.href}
+
+
+
+
diff --git a/packages/ui-core/src/components/buttons/navigation-button.svelte b/packages/ui-core/src/components/buttons/navigation-button.svelte
new file mode 100644
index 0000000..c8e752e
--- /dev/null
+++ b/packages/ui-core/src/components/buttons/navigation-button.svelte
@@ -0,0 +1,60 @@
+
+
+
+ {#if $page.url.pathname === href}
+
+ {/if}
+
+
diff --git a/packages/ui-core/src/components/buttons/simple-button.stories.svelte b/packages/ui-core/src/components/buttons/simple-button.stories.svelte
new file mode 100644
index 0000000..28f5f5f
--- /dev/null
+++ b/packages/ui-core/src/components/buttons/simple-button.stories.svelte
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+ You clicked: {count}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/ui-core/src/components/buttons/simple-button.svelte b/packages/ui-core/src/components/buttons/simple-button.svelte
new file mode 100644
index 0000000..04ef59b
--- /dev/null
+++ b/packages/ui-core/src/components/buttons/simple-button.svelte
@@ -0,0 +1,92 @@
+
+
+
+
+
+
+
diff --git a/packages/ui-core/src/components/buttons/simple-button.test.ts b/packages/ui-core/src/components/buttons/simple-button.test.ts
new file mode 100644
index 0000000..4bd67f4
--- /dev/null
+++ b/packages/ui-core/src/components/buttons/simple-button.test.ts
@@ -0,0 +1,18 @@
+import { render, screen } from '@testing-library/svelte';
+import userEvent from '@testing-library/user-event';
+import { expect, test, vi } from 'vitest';
+
+import SimpleButton from './simple-button.svelte';
+
+test('button with event', async () => {
+ const user = userEvent.setup();
+ const onClick = vi.fn();
+
+ const { component } = render(SimpleButton);
+ component.$on('click', onClick);
+
+ const button = screen.getByRole('button');
+ await user.click(button);
+
+ expect(onClick).toHaveBeenCalledOnce();
+});
diff --git a/packages/ui-core/src/components/card/card-container.svelte b/packages/ui-core/src/components/card/card-container.svelte
new file mode 100644
index 0000000..807f871
--- /dev/null
+++ b/packages/ui-core/src/components/card/card-container.svelte
@@ -0,0 +1,17 @@
+
+
+
+
+
diff --git a/packages/ui-core/src/components/card/card.svelte b/packages/ui-core/src/components/card/card.svelte
new file mode 100644
index 0000000..c5bf1f7
--- /dev/null
+++ b/packages/ui-core/src/components/card/card.svelte
@@ -0,0 +1,73 @@
+
+
+
+ {#if title.length > 0}
+
+ {title}
+
+ {/if}
+ {#if actions.length > 0}
+
+
+
+ {/if}
+
+
+
+ {#each actions as action}
+ {#if action.href}
+
{action.title}
+ {:else}
+
+ {action.title}
+
+ {/if}
+ {/each}
+
+
+
diff --git a/src/lib/components/atoms/forms/inputTypes.ts b/packages/ui-core/src/components/forms/input-types.ts
similarity index 100%
rename from src/lib/components/atoms/forms/inputTypes.ts
rename to packages/ui-core/src/components/forms/input-types.ts
diff --git a/src/lib/components/molecues/textEdit.svelte b/packages/ui-core/src/components/forms/text-edit.svelte
similarity index 84%
rename from src/lib/components/molecues/textEdit.svelte
rename to packages/ui-core/src/components/forms/text-edit.svelte
index 2e74884..20260f8 100644
--- a/src/lib/components/molecues/textEdit.svelte
+++ b/packages/ui-core/src/components/forms/text-edit.svelte
@@ -1,7 +1,6 @@
-