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} + +
+ +
{firstName}
+
+
+ {/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 @@ + + +
+
+ + +
+

+ Member since {monthNames[new Date($userStore.createdAt).getMonth()]} + {new Date($userStore.createdAt).getFullYear()} +

+
+

Verification Status

+
+ {#if $userStore.verified} + + {:else} + + {/if} +
+
+
+

+ +

+
+
+

+ +

+
+
+

+ +

+
+ +
+
+ +
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 @@ + +
+ + + + + + + + +
+ {#if fileList} + + {:else} + + {/if} +
+
+ + {#if fileList} + {fileList[0].name} + {:else} + Upload your avatar + {/if} + + +
+ Allowed Filetypes: *.jpg, *.webp, *.png +
+
+ If you don't select one, I'll grab a random one from + Picsum + +
+
+
+
+ + +
+ 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 @@ + + + + + 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 @@ + + + + + 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. +
+
+
+ +
+ 3. +
+ API Rate Limiting +
+ I'd like to add basic rate limiting functions to the API endpoints using + sveltekit-rate-limiter + +
+
+
+
+
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. + 1. + + Pull and start two docker containers (thumbor for image storage and postgresql for + user/data storage) + +
  2. +
  3. + 2. + + Generate the prisma client based on the schema from the db-schema + module + +
  4. +
  5. + 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. + + +
  6. +
  7. + 4. + + Generate zod schemas, which are consumed in the service-contract + module. + +
  8. +
  9. + 5. + + Launch the swagger-ui sveltekit app. This allows you to test out your API endpoints + locally. + +
  10. +
  11. + 6. + + Launch storybook so you can keep an eye on all your current components and their + states. + +
  12. +
  13. + 7. + Launch the web-app. +
  14. +
+
+
+
+
+
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 + + + + + +
+ + Mono-Repo + + +
+
+ +
+ + Turborepo + + +
+
+ +
+ + Storybook + + +
+
+ +
+ + Vitest + + +
+
+ +
+ + OpenAPI Initiative + + +
+
+ +
+ + Swagger + +
Prisma -
+ Prisma + + + +
GitHub Actions + GitHub Actions + +
Playwright + Playwright + + Vite - - - -
- Redis + 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 @@ + + + + + + + + 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 @@ + + + + + + + + + + + + + + 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} + + {/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 @@ -
-