├── .dockerignore ├── .env.example ├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ └── docker-publish.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── bookmarklet.js ├── docker-compose.selfhost.yaml ├── docker-compose.yaml ├── haproxy-web.cfg ├── package-lock.json ├── package.json ├── playwright.config.ts ├── postcss.config.js ├── src ├── app.css ├── app.d.ts ├── app.html ├── hooks.server.ts ├── index.test.ts ├── lib │ ├── actions │ │ ├── clickOutside.ts │ │ └── tooltip.ts │ ├── components │ │ ├── Alert.svelte │ │ ├── Modal.svelte │ │ ├── NavbarSearch.svelte │ │ ├── Pagination.svelte │ │ ├── RecipeCard.svelte │ │ ├── RecipeCardRow.svelte │ │ ├── RecipeCategoryTag.svelte │ │ ├── RecipeSearchCard.svelte │ │ ├── Tabs.svelte │ │ ├── Tag.svelte │ │ ├── Toggle.svelte │ │ ├── Tooltip.svelte │ │ └── icons │ │ │ └── GoCardlessIcon.svelte │ ├── constants.ts │ ├── errors.ts │ ├── index.ts │ ├── server │ │ ├── api │ │ │ └── client.ts │ │ └── infra │ │ │ └── health.ts │ ├── stores.ts │ ├── types.ts │ └── utils.ts ├── print.css └── routes │ ├── (app) │ ├── +layout.server.ts │ ├── +layout.svelte │ ├── admin │ │ ├── (nosidebar) │ │ │ └── recipes │ │ │ │ └── [slug] │ │ │ │ ├── +page.server.ts │ │ │ │ └── +page.svelte │ │ ├── (sidebar) │ │ │ ├── +layout.svelte │ │ │ ├── recipes │ │ │ │ ├── +page.server.ts │ │ │ │ └── +page.svelte │ │ │ ├── status │ │ │ │ ├── +page.server.ts │ │ │ │ └── +page.svelte │ │ │ └── users │ │ │ │ ├── +page.server.ts │ │ │ │ └── +page.svelte │ │ └── +layout.server.ts │ ├── categories │ │ ├── +layout.server.ts │ │ ├── +layout.svelte │ │ ├── +page.server.ts │ │ ├── +page.svelte │ │ ├── [slug] │ │ │ ├── +page.server.ts │ │ │ ├── +page.svelte │ │ │ └── edit │ │ │ │ ├── +page.server.ts │ │ │ │ └── +page.svelte │ │ ├── create │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ │ ├── recent │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ │ └── uncategorized │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ ├── charts │ │ ├── (nosidebar) │ │ │ └── recipes │ │ │ │ └── [slug] │ │ │ │ ├── +page.server.ts │ │ │ │ └── +page.svelte │ │ └── (sidebar) │ │ │ ├── +layout.svelte │ │ │ └── recipes │ │ │ └── popular │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ ├── help │ │ ├── +layout.svelte │ │ ├── bookmarklet │ │ │ └── +page.svelte │ │ ├── contact │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ │ ├── donate │ │ │ └── +page.svelte │ │ └── faq │ │ │ └── +page.svelte │ ├── meal-plans │ │ ├── +page.server.ts │ │ └── +page.svelte │ ├── recipes │ │ ├── [slug] │ │ │ ├── +page.server.ts │ │ │ ├── +page.svelte │ │ │ └── edit │ │ │ │ ├── +page.server.ts │ │ │ │ └── +page.svelte │ │ ├── create │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ │ ├── import │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ │ └── random │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ ├── search │ │ ├── +page.server.ts │ │ └── +page.svelte │ ├── settings │ │ ├── +layout.svelte │ │ ├── logout │ │ │ └── +page.svelte │ │ ├── subscription │ │ │ ├── +page.server.ts │ │ │ ├── +page.svelte │ │ │ ├── callback │ │ │ │ ├── +page.server.ts │ │ │ │ └── +page.svelte │ │ │ ├── cancel │ │ │ │ ├── +page.server.ts │ │ │ │ └── +page.svelte │ │ │ ├── mandate │ │ │ │ ├── +page.server.ts │ │ │ │ └── +page.svelte │ │ │ └── success │ │ │ │ ├── +page.server.ts │ │ │ │ └── +page.svelte │ │ └── user │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ ├── share │ │ └── recipes │ │ │ └── [slug] │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ ├── shopping-lists │ │ ├── +page.server.ts │ │ ├── +page.svelte │ │ ├── [slug] │ │ │ ├── +page.server.ts │ │ │ ├── +page.svelte │ │ │ └── edit │ │ │ │ ├── +page.server.ts │ │ │ │ └── +page.svelte │ │ └── create │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ └── tags │ │ ├── +layout.server.ts │ │ ├── +layout.svelte │ │ ├── +page.server.ts │ │ ├── +page.svelte │ │ ├── [slug] │ │ ├── +page.server.ts │ │ ├── +page.svelte │ │ └── edit │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ │ ├── create │ │ ├── +page.server.ts │ │ └── +page.svelte │ │ ├── recent │ │ ├── +page.server.ts │ │ └── +page.svelte │ │ └── untagged │ │ ├── +page.server.ts │ │ └── +page.svelte │ ├── (auth) │ ├── +layout.server.ts │ ├── +layout.svelte │ └── auth │ │ ├── email-verify │ │ ├── +page.server.ts │ │ └── +page.svelte │ │ ├── forgot-password │ │ ├── +page.server.ts │ │ └── +page.svelte │ │ ├── login │ │ ├── +page.server.ts │ │ └── +page.svelte │ │ ├── oauth │ │ └── callback │ │ │ ├── +page.server.ts │ │ │ └── +page.svelte │ │ ├── register │ │ ├── +page.server.ts │ │ └── +page.svelte │ │ └── reset-password │ │ ├── +page.server.ts │ │ └── +page.svelte │ ├── (marketing) │ ├── +layout.svelte │ ├── +page.svelte │ ├── privacy-policy │ │ └── +page.svelte │ └── terms-and-conditions │ │ └── +page.svelte │ ├── +error.svelte │ ├── +layout.server.ts │ ├── +layout.svelte │ ├── api │ ├── meal-plans │ │ └── latest │ │ │ └── +server.ts │ ├── recipes │ │ └── [slug] │ │ │ └── shares │ │ │ └── +server.ts │ └── search │ │ └── +server.ts │ ├── infra │ ├── health │ │ ├── +page.server.ts │ │ └── +page.svelte │ ├── shutdown │ │ ├── +page.server.ts │ │ └── +page.svelte │ └── startup │ │ ├── +page.server.ts │ │ └── +page.svelte │ └── logout │ ├── +page.server.ts │ └── +page.svelte ├── static ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── favicon.png ├── robots.txt └── site.webmanifest ├── svelte.config.js ├── tailwind.config.js ├── tests └── test.ts ├── tsconfig.json ├── vite.config.ts └── wait-for-it.sh /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | PUBLIC_MAIN_TITLE="ManageMeals" 2 | PASSWORD_MIN_LENGTH=6 3 | API_URL=http://localhost:3000/v1 4 | COOKIE_ACCESS_TOKEN=mmeals_access_token 5 | COOKIE_REFRESH_TOKEN=mmeals_refresh_token 6 | COOKIE_ACCESS_TOKEN_EXPIRE_SEC=300 7 | COOKIE_REFRESH_TOKEN_EXPIRE_SEC=7200 8 | PUBLIC_MOCK_INSTANCE=no 9 | INFRA_ENDPOINT_KEY=secret 10 | PUBLIC_PAYPAL_APP_CLIENT_ID=ppclientid 11 | PUBLIC_PAYPAL_PLAN_ID=ppplanid 12 | PUBLIC_PREMIUM_PRICE=£1.90 13 | PUBLIC_SHOW_SUBSCRIPTION_PAGE=false 14 | PUBLIC_EMAIL_VERIFY_ENABLED=false 15 | ORIGIN=http://localhost:8309 16 | BODY_SIZE_LIMIT=Infinity 17 | PUBLIC_UMAMI_ANALYTICS_ENABLED=false 18 | PUBLIC_SHOW_AI_PROMO=false 19 | PUBLIC_SHOW_MOBILE_APPS=false 20 | PUBLIC_ENABLE_GOOGLE_OAUTH=true 21 | PUBLIC_GOOGLE_OAUTH_URL=http://localhost:3000/v1/auth/oauth/google 22 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type { import("eslint").Linter.Config } */ 2 | module.exports = { 3 | root: true, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:svelte/recommended', 8 | 'prettier' 9 | ], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['@typescript-eslint'], 12 | parserOptions: { 13 | sourceType: 'module', 14 | ecmaVersion: 2020, 15 | extraFileExtensions: ['.svelte'] 16 | }, 17 | env: { 18 | browser: true, 19 | es2017: true, 20 | node: true 21 | }, 22 | overrides: [ 23 | { 24 | files: ['*.svelte'], 25 | parser: 'svelte-eslint-parser', 26 | parserOptions: { 27 | parser: '@typescript-eslint/parser' 28 | } 29 | } 30 | ] 31 | }; 32 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Push Docker Image 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | env: 8 | REGISTRY: ghcr.io 9 | IMAGE_NAME: ${{ github.repository }} 10 | 11 | jobs: 12 | build-and-push: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | packages: write 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up QEMU 23 | uses: docker/setup-qemu-action@v3 24 | 25 | - name: Set up Docker Buildx 26 | uses: docker/setup-buildx-action@v3 27 | 28 | - name: Log in to the Container registry 29 | uses: docker/login-action@v3 30 | with: 31 | registry: ${{ env.REGISTRY }} 32 | username: ${{ github.actor }} 33 | password: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Build and push Docker image 36 | uses: docker/build-push-action@v6 37 | with: 38 | context: . 39 | platforms: linux/amd64,linux/arm64 40 | push: true 41 | tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | !.env.*.example 10 | vite.config.js.timestamp-* 11 | vite.config.ts.timestamp-* 12 | docker-compose.override.yaml 13 | docker-compose.*.override.yaml 14 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore files for PNPM, NPM and YARN 2 | pnpm-lock.yaml 3 | package-lock.json 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 8 | } 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY ["package.json", "package-lock.json*", "./"] 6 | 7 | RUN npm ci 8 | 9 | COPY . . 10 | 11 | RUN npm run build 12 | 13 | CMD ["node", "build"] 14 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: default 2 | default: 3 | echo default 4 | 5 | # hosted 6 | .PHONY: build 7 | build: 8 | docker compose build 9 | 10 | .PHONY: up 11 | up: 12 | docker compose up 13 | 14 | .PHONY: upd 15 | upd: 16 | docker compose up -d 17 | 18 | .PHONY: pull 19 | pull: 20 | docker compose pull 21 | 22 | # self hosted 23 | .PHONY: build-selfhost 24 | build-selfhost: 25 | docker compose \ 26 | -f docker-compose.selfhost.yaml \ 27 | -f docker-compose.selfhost.override.yaml \ 28 | build 29 | 30 | .PHONY: up-selfhost 31 | up-selfhost: 32 | docker compose \ 33 | -f docker-compose.selfhost.yaml \ 34 | -f docker-compose.selfhost.override.yaml \ 35 | up 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ManageMeals Web 2 | 3 | This is the ManageMeals frontend, what you see at https://managemeals.com. It's a SvelteKit app. 4 | 5 | ## Development 6 | 7 | The [backend](https://github.com/managemeals/manage-meals-api) needs to be running. Then setup the environment variables: 8 | 9 | ```bash 10 | cp .env.example .env 11 | ``` 12 | 13 | After that, the app can be started: 14 | 15 | ```bash 16 | npm install 17 | 18 | npm run dev 19 | ``` 20 | 21 | ## Production 22 | 23 | To build the app: 24 | 25 | ```bash 26 | npm run build 27 | ``` 28 | 29 | Though for production that's done in a Dockerfile. 30 | 31 | Expose ports by creating a file called `docker-compose.override.yaml` with the contents: 32 | 33 | ```yaml 34 | services: 35 | manage-meals-web-01: 36 | ports: 37 | - 8301:3000 38 | 39 | manage-meals-web-02: 40 | ports: 41 | - 8302:3000 42 | 43 | manage-meals-web-lb: 44 | ports: 45 | - 8309:80 46 | ``` 47 | 48 | Then start it up using `docker-compose`: 49 | 50 | ```bash 51 | cp .env.docker.example .env.docker 52 | 53 | make build 54 | 55 | make upd 56 | ``` 57 | -------------------------------------------------------------------------------- /bookmarklet.js: -------------------------------------------------------------------------------- 1 | // Create final code here: https://chriszarate.github.io/bookmarkleter/ 2 | 3 | // Configuration 4 | const IMPORT_URL = 'https://managemeals.com/recipes/import'; 5 | 6 | // Get and encode the current page URL 7 | const currentUrl = encodeURIComponent(window.location.href); 8 | 9 | // Redirect to the import page with the URL as a parameter 10 | window.location.href = `${IMPORT_URL}?url=${currentUrl}`; 11 | -------------------------------------------------------------------------------- /docker-compose.selfhost.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | manage-meals-web: 3 | image: ghcr.io/managemeals/manage-meals-web:latest 4 | restart: unless-stopped 5 | environment: 6 | PUBLIC_MAIN_TITLE: ManageMeals 7 | PASSWORD_MIN_LENGTH: 6 8 | API_URL: http://manage-meals-api:3000/v1 9 | COOKIE_ACCESS_TOKEN: mmeals_access_token 10 | COOKIE_REFRESH_TOKEN: mmeals_refresh_token 11 | COOKIE_ACCESS_TOKEN_EXPIRE_SEC: 600 12 | COOKIE_REFRESH_TOKEN_EXPIRE_SEC: 2629746 13 | ORIGIN: http://localhost:3001 14 | BODY_SIZE_LIMIT: Infinity 15 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | manage-meals-web: 3 | image: ghcr.io/managemeals/manage-meals-web:latest 4 | restart: unless-stopped 5 | env_file: 6 | - ./.env 7 | networks: 8 | - mmeals_net 9 | 10 | networks: 11 | mmeals_net: 12 | external: true 13 | -------------------------------------------------------------------------------- /haproxy-web.cfg: -------------------------------------------------------------------------------- 1 | defaults 2 | mode http 3 | balance roundrobin 4 | timeout client 10s 5 | timeout connect 5s 6 | timeout server 10s 7 | 8 | frontend http 9 | bind *:80 10 | default_backend web 11 | 12 | backend web 13 | option httpchk GET /infra/health?key="$INFRA_ENDPOINT_KEY" 14 | server w1 manage-meals-web-01:3000 check 15 | server w2 manage-meals-web-02:3000 check 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "manage-meals-web", 3 | "version": "0.0.1", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite dev", 7 | "build": "vite build", 8 | "preview": "vite preview", 9 | "test": "npm run test:integration && npm run test:unit", 10 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 11 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 12 | "lint": "prettier --check . && eslint .", 13 | "format": "prettier --write .", 14 | "test:integration": "playwright test", 15 | "test:unit": "vitest" 16 | }, 17 | "devDependencies": { 18 | "@iconify/svelte": "^3.1.6", 19 | "@playwright/test": "^1.42.1", 20 | "@sveltejs/adapter-auto": "^3.1.1", 21 | "@sveltejs/adapter-node": "^5.0.1", 22 | "@sveltejs/kit": "^2.5.3", 23 | "@sveltejs/vite-plugin-svelte": "^3.0.2", 24 | "@types/eslint": "8.56.5", 25 | "@types/lodash-es": "^4.17.12", 26 | "@typescript-eslint/eslint-plugin": "^7.1.1", 27 | "@typescript-eslint/parser": "^7.1.1", 28 | "autoprefixer": "^10.4.18", 29 | "eslint": "^8.57.0", 30 | "eslint-config-prettier": "^9.1.0", 31 | "eslint-plugin-svelte": "^2.35.1", 32 | "postcss": "^8.4.35", 33 | "prettier": "^3.2.5", 34 | "prettier-plugin-svelte": "^3.2.2", 35 | "svelte": "^4.2.12", 36 | "svelte-check": "^3.6.6", 37 | "tailwindcss": "^3.4.1", 38 | "tslib": "^2.6.2", 39 | "typescript": "^5.4.2", 40 | "unplugin-icons": "^0.18.5", 41 | "vite": "^5.1.5", 42 | "vitest": "^1.3.1" 43 | }, 44 | "type": "module", 45 | "dependencies": { 46 | "@paypal/paypal-js": "^8.1.0", 47 | "axios": "^1.6.7", 48 | "date-fns": "^3.6.0", 49 | "lodash-es": "^4.17.21" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from '@playwright/test'; 2 | 3 | const config: PlaywrightTestConfig = { 4 | webServer: { 5 | command: 'npm run build && npm run preview', 6 | port: 4173 7 | }, 8 | testDir: 'tests', 9 | testMatch: /(.+\.)?(test|spec)\.[jt]s/ 10 | }; 11 | 12 | export default config; 13 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | 3 | import type { IUser } from '$lib/types'; 4 | 5 | // for information about these interfaces 6 | declare global { 7 | namespace App { 8 | // interface Error {} 9 | interface Locals { 10 | user: IUser; 11 | } 12 | // interface PageData {} 13 | // interface PageState {} 14 | // interface Platform {} 15 | } 16 | namespace svelteHTML { 17 | interface HTMLAttributes { 18 | 'on:clickoutside'?: (event: CustomEvent) => void; 19 | } 20 | } 21 | } 22 | 23 | export {}; 24 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | %sveltekit.head% 11 | 12 | 13 |
%sveltekit.body%
14 | 15 | 16 | -------------------------------------------------------------------------------- /src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { env } from '$env/dynamic/private'; 2 | import apiClient, { apiClientUnauthed } from '$lib/server/api/client'; 3 | import { initLbShutdown } from '$lib/server/infra/health'; 4 | import type { Handle } from '@sveltejs/kit'; 5 | 6 | initLbShutdown(); 7 | 8 | export const handle: Handle = async ({ event, resolve }) => { 9 | if (event.url.pathname === '/' || event.url.pathname === '/logout') { 10 | return await resolve(event); 11 | } 12 | 13 | let accessToken = event.cookies.get(env.COOKIE_ACCESS_TOKEN) || ''; 14 | let refreshToken = event.cookies.get(env.COOKIE_REFRESH_TOKEN) || ''; 15 | 16 | if (!accessToken && !refreshToken) { 17 | if ( 18 | event.url.pathname !== '/'! && 19 | !event.url.pathname.startsWith('/auth') && 20 | !event.url.pathname.startsWith('/infra') && 21 | !event.url.pathname.startsWith('/share') 22 | ) { 23 | return new Response('Redirect', { 24 | status: 307, 25 | headers: { Location: `/auth/login?goto=${event.url.pathname}` } 26 | }); 27 | } else { 28 | return await resolve(event); 29 | } 30 | } 31 | 32 | // If the access token has expired, but not the refresh token, then refresh 33 | // the tokens 34 | if (!accessToken && refreshToken) { 35 | try { 36 | const res = await apiClientUnauthed.post('/auth/refresh-token', { 37 | token: refreshToken 38 | }); 39 | accessToken = res.data.accessToken; 40 | refreshToken = res.data.refreshToken; 41 | event.cookies.set(env.COOKIE_ACCESS_TOKEN, accessToken, { 42 | path: '/', 43 | maxAge: parseInt(env.COOKIE_ACCESS_TOKEN_EXPIRE_SEC, 10) 44 | }); 45 | event.cookies.set(env.COOKIE_REFRESH_TOKEN, refreshToken, { 46 | path: '/', 47 | maxAge: parseInt(env.COOKIE_REFRESH_TOKEN_EXPIRE_SEC, 10) 48 | }); 49 | } catch (e) { 50 | console.log(e); 51 | return new Response('Redirect', { status: 307, headers: { Location: '/logout' } }); 52 | } 53 | } 54 | 55 | try { 56 | const res = await apiClient([ 57 | { name: env.COOKIE_ACCESS_TOKEN, value: accessToken }, 58 | { name: env.COOKIE_REFRESH_TOKEN, value: refreshToken } 59 | ]).get('/settings/user'); 60 | event.locals.user = res.data; 61 | } catch (e) { 62 | console.log(e); 63 | } 64 | 65 | const response = await resolve(event); 66 | return response; 67 | }; 68 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from 'vitest'; 2 | 3 | describe('sum test', () => { 4 | it('adds 1 + 2 to equal 3', () => { 5 | expect(1 + 2).toBe(3); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/lib/actions/clickOutside.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Dispatch an event when a user clicks outside node 3 | * or any of the optional extraNodes. 4 | */ 5 | export const clickOutside = (node: any, extraNodes: any) => { 6 | const handleClick = (event: any) => { 7 | if (!node || node.contains(event.target) || event.defaultPrevented) { 8 | return; 9 | } 10 | for (const extraNode of extraNodes) { 11 | if (extraNode.contains(event.target)) { 12 | return; 13 | } 14 | } 15 | node.dispatchEvent(new CustomEvent('clickoutside', node)); 16 | }; 17 | 18 | document.addEventListener('click', handleClick, true); 19 | 20 | return { 21 | destroy() { 22 | document.removeEventListener('click', handleClick, true); 23 | } 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /src/lib/actions/tooltip.ts: -------------------------------------------------------------------------------- 1 | import Tooltip from '$lib/components/Tooltip.svelte'; 2 | 3 | export const tooltip = (node: any) => { 4 | let title: string; 5 | let tooltipComponent: any; 6 | 7 | const mouseOver = (event: any) => { 8 | title = node.getAttribute('title'); 9 | node.removeAttribute('title'); 10 | 11 | tooltipComponent = new Tooltip({ 12 | props: { 13 | title: title, 14 | x: event.pageX, 15 | y: event.pageY 16 | }, 17 | target: document.body 18 | }); 19 | }; 20 | 21 | const mouseMove = (event: any) => { 22 | tooltipComponent.$set({ 23 | x: event.pageX, 24 | y: event.pageY 25 | }); 26 | }; 27 | const mouseLeave = () => { 28 | tooltipComponent.$destroy(); 29 | node.setAttribute('title', title); 30 | }; 31 | 32 | node.addEventListener('mouseover', mouseOver); 33 | node.addEventListener('mouseleave', mouseLeave); 34 | node.addEventListener('mousemove', mouseMove); 35 | 36 | return { 37 | destroy() { 38 | node.removeEventListener('mouseover', mouseOver); 39 | node.removeEventListener('mouseleave', mouseLeave); 40 | node.removeEventListener('mousemove', mouseMove); 41 | } 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /src/lib/components/Alert.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | {#if variant === 'success'} 8 |
9 | 10 |
11 | {:else if variant === 'warning'} 12 |
13 | 14 |
15 | {:else} 16 |
17 | 18 |
19 | {/if} 20 | -------------------------------------------------------------------------------- /src/lib/components/Modal.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 | { 24 | show = false; 25 | }} 26 | on:click|self={() => { 27 | if (!disableOutsideClickClose) { 28 | dialog.close(); 29 | } 30 | }} 31 | class="rounded shadow max-w-3xl w-full" 32 | > 33 | 34 |
35 |
36 | 46 | 47 |
48 |
49 |
50 | -------------------------------------------------------------------------------- /src/lib/components/NavbarSearch.svelte: -------------------------------------------------------------------------------- 1 | 64 | 65 |
72 | 119 |
120 | -------------------------------------------------------------------------------- /src/lib/components/Pagination.svelte: -------------------------------------------------------------------------------- 1 | 28 | 29 |
30 | {#if pages <= 6} 31 |
32 |
36 | 37 |
38 | {#each { length: pages } as _, i} 39 | {i + 1} 44 | {/each} 45 |
46 | 47 |
48 |
49 | {:else} 50 |
51 | {#if page < 2} 52 |
56 | 57 |
58 | {:else} 59 | 64 | 65 | 66 | {/if} 67 | 1 72 |
...
73 | {#each middleItems as middleItem} 74 | {middleItem} 79 | {/each} 80 |
...
81 | = pages ? 'bg-orange-500 hover:bg-orange-500 text-white border-orange-500' : 'hover:bg-gray-100'}`} 84 | >{pages} 86 | {#if page >= pages} 87 |
88 | 89 |
90 | {:else} 91 | 96 | 97 | 98 | {/if} 99 |
100 | {/if} 101 |
102 | -------------------------------------------------------------------------------- /src/lib/components/RecipeCard.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 |
16 |
17 |

{recipe.data.title}

18 | {#if !hideCategoriesTags} 19 |
20 | 21 |
22 | {/if} 23 |
24 |
25 | -------------------------------------------------------------------------------- /src/lib/components/RecipeCardRow.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | {#if hasTitleLink} 11 |
12 |
21 |
22 |

23 | {recipe.data.title} 24 |

25 | 26 |
27 |
28 | {:else} 29 | 30 |
39 |
40 |

{recipe.data.title}

41 | 42 |
43 |
44 | {/if} 45 | -------------------------------------------------------------------------------- /src/lib/components/RecipeCategoryTag.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
10 |
11 | 12 |
13 |
14 | {#if recipe.categories && recipe.categories.length} 15 | {#each recipe.categories as category} 16 | {#if nonClickable} 17 | 18 | {category.name} 19 | 20 | {:else} 21 | {category.name} 25 | {/if} 26 | {/each} 27 | {:else} 28 |
Uncategorized
29 | {/if} 30 |
31 |
32 |
33 |
34 | 35 |
36 |
37 | {#if recipe.tags && recipe.tags.length} 38 | {#each recipe.tags as tag} 39 | {#if nonClickable} 40 | {tag.name} 41 | {:else} 42 | {tag.name} 46 | {/if} 47 | {/each} 48 | {:else} 49 |
Untagged
50 | {/if} 51 |
52 |
53 | -------------------------------------------------------------------------------- /src/lib/components/RecipeSearchCard.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 14 |
18 |
19 |

{@html recipe.highlight.title.snippet}

20 | {#if recipe.document.description} 21 |

22 | {recipe.document.description.length > descMaxLength 23 | ? `${recipe.document.description.substring(0, descMaxLength)}...` 24 | : recipe.document.description} 25 |

26 | {/if} 27 |
28 |
29 | 30 |
31 |
32 | {#if recipe.document.categories && recipe.document.categories.length} 33 | {#each recipe.document.categories as category} 34 |
{category}
35 | {/each} 36 | {:else} 37 |
Uncategorized
38 | {/if} 39 |
40 |
41 |
42 |
43 | 44 |
45 |
46 | {#if recipe.document.tags && recipe.document.tags.length} 47 | {#each recipe.document.tags as tag} 48 |
{tag}
49 | {/each} 50 | {:else} 51 |
Untagged
52 | {/if} 53 |
54 |
55 |
56 |
57 | -------------------------------------------------------------------------------- /src/lib/components/Tabs.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 | 16 |
17 | -------------------------------------------------------------------------------- /src/lib/components/Tag.svelte: -------------------------------------------------------------------------------- 1 | 3 | 4 |
5 | 6 |
7 | -------------------------------------------------------------------------------- /src/lib/components/Toggle.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 19 | -------------------------------------------------------------------------------- /src/lib/components/Tooltip.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 | {title} 9 |
10 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | import type { TShortDay, TShortDayLower } from './types'; 2 | 3 | export const WEEKDAYS: TShortDay[] = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; 4 | 5 | export const WEEKDAYS_LOWER: TShortDayLower[] = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; 6 | -------------------------------------------------------------------------------- /src/lib/errors.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosError } from 'axios'; 2 | import type { IAPIError } from './types'; 3 | 4 | export const getErrorMessage = (e: unknown) => { 5 | const defaultError = 'There was an error, please try again.'; 6 | 7 | if (!axios.isAxiosError(e)) { 8 | if (typeof e === 'string') { 9 | return e; 10 | } else if (e instanceof Error) { 11 | return e.message; 12 | } else { 13 | return defaultError; 14 | } 15 | } 16 | 17 | return (e as AxiosError)?.response?.data.message || defaultError; 18 | }; 19 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | // place files you want to import through the `$lib` alias in this folder. 2 | -------------------------------------------------------------------------------- /src/lib/server/api/client.ts: -------------------------------------------------------------------------------- 1 | import { env } from '$env/dynamic/private'; 2 | import type { ICookie } from '$lib/types'; 3 | import axios, { type AxiosInstance } from 'axios'; 4 | 5 | const apiClientUnauthed = axios.create({ 6 | baseURL: env.API_URL, 7 | timeout: 30000, 8 | headers: { 9 | Accept: 'application/json', 10 | 'Content-Type': 'application/json' 11 | } 12 | }); 13 | 14 | export { apiClientUnauthed }; 15 | 16 | const apiClient = (cookies: ICookie[]): AxiosInstance => { 17 | const accessToken = cookies.find((c) => c.name === env.COOKIE_ACCESS_TOKEN); 18 | // const refreshToken = cookies.find((c) => c.name === COOKIE_REFRESH_TOKEN); 19 | 20 | const apiClientAuthed = axios.create({ 21 | baseURL: env.API_URL, 22 | timeout: 30000, 23 | headers: { 24 | Accept: 'application/json', 25 | 'Content-Type': 'application/json' 26 | } 27 | }); 28 | 29 | apiClientAuthed.interceptors.request.use( 30 | (config) => { 31 | config.headers.Authorization = `Bearer ${accessToken?.value}`; 32 | return config; 33 | }, 34 | (error) => { 35 | return Promise.reject(error); 36 | } 37 | ); 38 | 39 | // apiClientAuthed.interceptors.response.use( 40 | // (response) => { 41 | // return response; 42 | // }, 43 | // async (error) => { 44 | // const originalConfig = error.config; 45 | 46 | // if (error.response && error.response.status === 401 && !originalConfig._retry) { 47 | // originalConfig._retry = true; 48 | 49 | // try { 50 | // const refreshRes = await apiClientUnauthed.post('/auth/refresh-token', { 51 | // token: refreshToken 52 | // }); 53 | // apiClientAuthed.defaults.headers.common['Authorization'] = refreshRes.data.accessToken; 54 | // } catch (error2) { 55 | // return Promise.reject(error2); 56 | // } 57 | // } 58 | 59 | // return Promise.reject(error); 60 | // } 61 | // ); 62 | 63 | return apiClientAuthed; 64 | }; 65 | 66 | export default apiClient; 67 | -------------------------------------------------------------------------------- /src/lib/server/infra/health.ts: -------------------------------------------------------------------------------- 1 | let isLbShutdown: boolean = false; 2 | 3 | export const initLbShutdown = () => { 4 | isLbShutdown = false; 5 | }; 6 | 7 | export const setLbShutdown = (val: boolean) => { 8 | isLbShutdown = val; 9 | }; 10 | 11 | export const getLbShutdown = () => { 12 | return isLbShutdown; 13 | }; 14 | -------------------------------------------------------------------------------- /src/lib/stores.ts: -------------------------------------------------------------------------------- 1 | import { writable } from 'svelte/store'; 2 | import type { ISidebarLink } from './types'; 3 | 4 | export const sidebarLinks = writable([]); 5 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); 2 | -------------------------------------------------------------------------------- /src/print.css: -------------------------------------------------------------------------------- 1 | @media print { 2 | 3 | nav, 4 | div.bg-center, 5 | div.fixed.top-16.w-16, 6 | a[title="Edit"] { 7 | display: none !important; 8 | } 9 | 10 | main { 11 | padding-left: 0 !important; 12 | } 13 | 14 | .text-orange-500, .text-blue-500 { 15 | color: #000; 16 | } 17 | 18 | svg { 19 | color: #000 !important; 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/routes/(app)/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | import type { LayoutServerLoad } from './$types'; 3 | 4 | export const load: LayoutServerLoad = async ({ locals, parent, url }) => { 5 | const parentData = await parent(); 6 | 7 | if (!locals.user && !url.pathname.startsWith('/share')) { 8 | redirect(307, '/auth/login'); 9 | } 10 | 11 | return { user: parentData.user }; 12 | }; 13 | -------------------------------------------------------------------------------- /src/routes/(app)/admin/(nosidebar)/recipes/[slug]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import { error } from '@sveltejs/kit'; 3 | import type { PageServerLoad } from './$types'; 4 | import type { IRecipe } from '$lib/types'; 5 | 6 | export const load: PageServerLoad = async ({ params, cookies }) => { 7 | const { slug } = params; 8 | 9 | try { 10 | const recipeRes = await apiClient(cookies.getAll()).get(`/admin/recipes/${slug}`); 11 | return { 12 | recipe: recipeRes.data as IRecipe 13 | }; 14 | } catch (e) { 15 | console.log(e); 16 | throw error(404, 'Recipe not found'); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/routes/(app)/admin/(sidebar)/+layout.svelte: -------------------------------------------------------------------------------- 1 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/routes/(app)/admin/(sidebar)/recipes/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { IPaginated, IRecipe } from '$lib/types'; 3 | import type { PageServerLoad } from './$types'; 4 | 5 | export const load: PageServerLoad = async ({ cookies, url }) => { 6 | let page = url.searchParams.get('page') || '1'; 7 | if (isNaN(parseInt(page, 10))) { 8 | page = '1'; 9 | } 10 | 11 | try { 12 | const recipesRes = await apiClient(cookies.getAll()).get( 13 | `/admin/recipes?page=${page}&sort=-createdAt` 14 | ); 15 | return { 16 | recipes: recipesRes.data as IPaginated 17 | }; 18 | } catch (e) { 19 | console.log(e); 20 | throw new Error('Error loading recipes'); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/routes/(app)/admin/(sidebar)/recipes/+page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | Recipes - Admin - {env.PUBLIC_MAIN_TITLE} 12 | 13 | 14 |
15 |
16 |

Latest Recipes

17 |
{data.recipes.total} recipes
18 |
19 | 20 | {#if !data.recipes.total} 21 |

No recipes

22 | {/if} 23 | 24 |
25 | {#each data.recipes.data as recipe} 26 | 27 | {/each} 28 |
29 | 30 |
31 | 36 |
37 |
38 | -------------------------------------------------------------------------------- /src/routes/(app)/admin/(sidebar)/status/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { IAdminStatus } from '$lib/types'; 3 | import type { PageServerLoad } from './$types'; 4 | 5 | export const load: PageServerLoad = async ({ cookies }) => { 6 | try { 7 | const res = await apiClient(cookies.getAll()).get('/admin/status'); 8 | return res.data as IAdminStatus; 9 | } catch (e) { 10 | console.log(e); 11 | throw new Error('Error loading status'); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /src/routes/(app)/admin/(sidebar)/status/+page.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | Status - Admin - {env.PUBLIC_MAIN_TITLE} 10 | 11 | 12 |
13 |

Status

14 | 15 |
16 |
17 |
Users
18 |
{data.totalUsers}
19 |
20 |
21 |
Recipes
22 |
{data.totalRecipes}
23 |
24 |
25 |
Categories
26 |
{data.totalCategories}
27 |
28 |
29 |
Tags
30 |
{data.totalTags}
31 |
32 |
33 |
Shopping Lists
34 |
{data.totalShoppingLists}
35 |
36 |
37 |
Meal Plans
38 |
{data.totalMealPlans}
39 |
40 |
41 |
Shared recipes
42 |
{data.totalShareRecipes}
43 |
44 |
45 |
46 | -------------------------------------------------------------------------------- /src/routes/(app)/admin/(sidebar)/users/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { IUser } from '$lib/types'; 3 | import type { PageServerLoad } from './$types'; 4 | 5 | export const load: PageServerLoad = async ({ cookies }) => { 6 | try { 7 | const usersRes = await apiClient(cookies.getAll()).get('/admin/users?sort=-createdAt'); 8 | return { 9 | users: usersRes.data as IUser[] 10 | }; 11 | } catch (e) { 12 | console.log(e); 13 | throw new Error('Error loading users'); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/routes/(app)/admin/(sidebar)/users/+page.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | Users - Admin - {env.PUBLIC_MAIN_TITLE} 11 | 12 | 13 |
14 |
15 |

Users

16 |
{data.users.length} users
17 |
18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | {#each data.users as user} 32 | 33 | 34 | 35 | 36 | 43 | 44 | 45 | {/each} 46 | 47 |
NameEmailSubscriptionAdminCreated At
{user.name}{user.email}{user.subscriptionType} 37 | {#if user.isAdmin} 38 | Yes 39 | {:else} 40 | No 41 | {/if} 42 | {format(user.createdAt, 'd MMM yyyy')}
48 |
49 |
50 | -------------------------------------------------------------------------------- /src/routes/(app)/admin/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | import type { LayoutServerLoad } from './$types'; 3 | 4 | export const load: LayoutServerLoad = ({ locals }) => { 5 | if (!locals.user?.isAdmin) { 6 | redirect(307, '/categories'); 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /src/routes/(app)/categories/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { ICategory } from '$lib/types'; 3 | import type { LayoutServerLoad } from './$types'; 4 | 5 | export const load: LayoutServerLoad = async ({ cookies }) => { 6 | try { 7 | const categoriesRes = await apiClient(cookies.getAll()).get('/categories'); 8 | return { 9 | categories: categoriesRes.data as ICategory[] 10 | }; 11 | } catch (e) { 12 | console.log(e); 13 | throw new Error('Error loading categories'); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/routes/(app)/categories/+layout.svelte: -------------------------------------------------------------------------------- 1 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/routes/(app)/categories/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { IPaginated, IRecipe, ITag } from '$lib/types'; 3 | import type { PageServerLoad } from './$types'; 4 | 5 | export const load: PageServerLoad = async ({ cookies, url, parent }) => { 6 | let page = url.searchParams.get('page') || '1'; 7 | if (isNaN(parseInt(page, 10))) { 8 | page = '1'; 9 | } 10 | 11 | try { 12 | const tagsRes = await apiClient(cookies.getAll()).get('/tags'); 13 | const tagsResData = tagsRes.data as ITag[]; 14 | 15 | const parentRes = await parent(); 16 | 17 | let selectedCategories = ['Categories']; 18 | const categoriesQ = url.searchParams.get('categories'); 19 | let categoriesFilter: string[] = []; 20 | if (categoriesQ) { 21 | categoriesFilter = categoriesQ.split(',').map((c) => { 22 | const category = parentRes.categories.find((pc) => pc.slug === c); 23 | return `categories=${category?.uuid}`; 24 | }); 25 | selectedCategories = categoriesQ.split(',').map((c) => { 26 | const category = parentRes.categories.find((pc) => pc.slug === c); 27 | return category?.name || ''; 28 | }); 29 | } 30 | 31 | let selectedTags = ['Tags']; 32 | const tagsQ = url.searchParams.get('tags'); 33 | let tagsFilter: string[] = []; 34 | if (tagsQ) { 35 | tagsFilter = tagsQ.split(',').map((t) => { 36 | const tag = tagsResData.find((td) => td.slug === t); 37 | return `tags=${tag?.uuid}`; 38 | }); 39 | selectedTags = tagsQ.split(',').map((t) => { 40 | const tag = tagsResData.find((td) => td.slug === t); 41 | return tag?.name || ''; 42 | }); 43 | } 44 | 45 | const recipesRes = await apiClient(cookies.getAll()).get( 46 | `/recipes?page=${page}${categoriesFilter.length ? `&${categoriesFilter.join('&')}` : ''}${tagsFilter.length ? `&${tagsFilter.join('&')}` : ''}` 47 | ); 48 | 49 | return { 50 | recipes: recipesRes.data as IPaginated, 51 | tags: tagsResData, 52 | selectedCategories, 53 | selectedTags, 54 | selectedCategoriesSlugs: (categoriesQ || '').split(','), 55 | selectedTagsSlugs: (tagsQ || '').split(',') 56 | }; 57 | } catch (e) { 58 | console.log(e); 59 | throw new Error('Error loading recipes'); 60 | } 61 | }; 62 | -------------------------------------------------------------------------------- /src/routes/(app)/categories/+page.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | Categories - {env.PUBLIC_MAIN_TITLE} 21 | 22 | 23 |
24 |
25 |
26 |

All Categories

27 |
{data.recipes.total} recipes
28 |
29 | 30 |
31 |
32 | 42 | {#if categoriesBtnEl} 43 | 83 | {/if} 84 |
85 | 86 |
87 | 97 | {#if tagsBtnEl} 98 | 135 | {/if} 136 |
137 |
138 |
139 | 140 | {#if !data.recipes.total} 141 |

No recipes

142 | {/if} 143 | 144 |
145 | {#each data.recipes.data as recipe} 146 | 147 | {/each} 148 |
149 | 150 |
151 | 156 |
157 |
158 | -------------------------------------------------------------------------------- /src/routes/(app)/categories/[slug]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { ICategory, IPaginated, IRecipe } from '$lib/types'; 3 | import { error } from '@sveltejs/kit'; 4 | import type { PageServerLoad } from './$types'; 5 | 6 | export const load: PageServerLoad = async ({ cookies, url, params }) => { 7 | const { slug } = params; 8 | 9 | let page = url.searchParams.get('page') || '1'; 10 | if (isNaN(parseInt(page, 10))) { 11 | page = '1'; 12 | } 13 | 14 | let category: ICategory; 15 | try { 16 | const categoryRes = await apiClient(cookies.getAll()).get(`/categories/${slug}`); 17 | category = categoryRes.data; 18 | } catch (e) { 19 | console.log(e); 20 | throw error(404, 'Category not found'); 21 | } 22 | 23 | try { 24 | const recipesRes = await apiClient(cookies.getAll()).get( 25 | `/recipes?page=${page}&categories=${category.uuid}` 26 | ); 27 | return { 28 | category, 29 | recipes: recipesRes.data as IPaginated 30 | }; 31 | } catch (e) { 32 | console.log(e); 33 | throw new Error('Error loading recipes'); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /src/routes/(app)/categories/[slug]/+page.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | {data.category.name} - Categories - {env.PUBLIC_MAIN_TITLE} 13 | 14 | 15 |
16 |
17 |
18 |

{data.category.name}

19 |
{data.recipes.total} recipes
20 |
21 | 26 | 27 | 28 |
29 | 30 | {#if !data.recipes.total} 31 |

No recipes in this category

32 | {/if} 33 | 34 |
35 | {#each data.recipes.data as recipe} 36 | 37 | {/each} 38 |
39 | 40 |
41 | 46 |
47 |
48 | -------------------------------------------------------------------------------- /src/routes/(app)/categories/[slug]/edit/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { ICategory, IEnhanceFailRes, IEnhanceRes } from '$lib/types'; 3 | import { fail, redirect } from '@sveltejs/kit'; 4 | import type { PageServerLoad } from './$types'; 5 | import { getErrorMessage } from '$lib/errors'; 6 | 7 | export const load: PageServerLoad = async ({ cookies, params }) => { 8 | const { slug } = params; 9 | 10 | try { 11 | const res = await apiClient(cookies.getAll()).get(`/categories/${slug}`); 12 | return { 13 | category: res.data as ICategory 14 | }; 15 | } catch (e) { 16 | console.log(e); 17 | throw new Error('Error loading category'); 18 | } 19 | }; 20 | 21 | export const actions = { 22 | edit: async ({ request, cookies, params }) => { 23 | const { slug } = params; 24 | 25 | const data = await request.formData(); 26 | const name = data.get('name') as string; 27 | 28 | const failObj: IEnhanceFailRes = { inputs: { name }, errors: {} }; 29 | 30 | if (!name) { 31 | failObj.errors.name = 'Name is empty'; 32 | } 33 | 34 | if (Object.keys(failObj.errors).length) { 35 | return fail(400, failObj); 36 | } 37 | 38 | try { 39 | await apiClient(cookies.getAll()).patch(`/categories/${slug}`, { 40 | name 41 | }); 42 | } catch (e) { 43 | console.log(e); 44 | failObj.messageType = 'error'; 45 | failObj.message = getErrorMessage(e); 46 | return fail(500, failObj); 47 | } 48 | 49 | const successObj: IEnhanceRes = { 50 | message: 'Category updated.', 51 | messageType: 'success', 52 | slug, 53 | name 54 | }; 55 | 56 | return successObj; 57 | }, 58 | 59 | delete: async ({ cookies, params }) => { 60 | const { slug } = params; 61 | 62 | try { 63 | await apiClient(cookies.getAll()).delete(`/categories/${slug}`, { 64 | headers: { 65 | 'Content-Type': null 66 | } 67 | }); 68 | } catch (e) { 69 | console.log(e); 70 | const failObj: IEnhanceRes = { 71 | deleteMessageType: 'error', 72 | deleteMessage: getErrorMessage(e) 73 | }; 74 | return fail(500, failObj); 75 | } 76 | 77 | return redirect(303, '/categories'); 78 | } 79 | }; 80 | -------------------------------------------------------------------------------- /src/routes/(app)/categories/[slug]/edit/+page.svelte: -------------------------------------------------------------------------------- 1 | 35 | 36 | 37 | {data.category.name} - Edit - Categories - {env.PUBLIC_MAIN_TITLE} 38 | 39 | 40 |
41 |
42 |

Edit Category

43 | 52 |
53 | {#if form?.message} 54 |
55 | 56 | {form?.message} 57 | 58 |
59 | {/if} 60 |
{ 64 | return async ({ update }) => { 65 | await update({ reset: false }); 66 | }; 67 | }} 68 | > 69 |
70 | 71 | 79 | {#if form?.errors?.name} 80 |
{form?.errors?.name}
81 | {/if} 82 |
83 |
84 | 89 |
90 |
91 |
92 | 93 | 94 |
Delete Category
95 | {#if form?.deleteMessage} 96 |
97 | 98 | {form?.deleteMessage} 99 | 100 |
101 | {/if} 102 |

103 | Deleting a category will not delete any recipes. It will only remove the deleted category from 104 | the recipes. 105 |

106 |
107 | 110 |
111 |
112 | -------------------------------------------------------------------------------- /src/routes/(app)/categories/create/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { getErrorMessage } from '$lib/errors'; 2 | import apiClient from '$lib/server/api/client'; 3 | import type { IEnhanceFailRes, IEnhanceRes } from '$lib/types'; 4 | import { fail } from '@sveltejs/kit'; 5 | 6 | export const actions = { 7 | default: async ({ request, cookies }) => { 8 | const data = await request.formData(); 9 | const name = data.get('name') as string; 10 | 11 | const failObj: IEnhanceFailRes = { inputs: { name }, errors: {} }; 12 | 13 | if (!name) { 14 | failObj.errors.name = 'Name is empty'; 15 | } 16 | 17 | if (Object.keys(failObj.errors).length) { 18 | return fail(400, failObj); 19 | } 20 | 21 | let slug = ''; 22 | try { 23 | const res = await apiClient(cookies.getAll()).post('/categories', { 24 | name 25 | }); 26 | slug = res.data.slug; 27 | } catch (e) { 28 | console.log(e); 29 | failObj.messageType = 'error'; 30 | failObj.message = getErrorMessage(e); 31 | return fail(500, failObj); 32 | } 33 | 34 | const successObj: IEnhanceRes = { 35 | name: name as string, 36 | slug: slug as string 37 | }; 38 | 39 | return successObj; 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /src/routes/(app)/categories/create/+page.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 | 38 | Create Category - Categories - {env.PUBLIC_MAIN_TITLE} 39 | 40 | 41 |
42 |

Create Category

43 | {#if form?.message} 44 |
45 | 46 | {form?.message} 47 | 48 |
49 | {/if} 50 |
51 |
52 | 53 | 61 | {#if form?.errors?.name} 62 |
{form?.errors?.name}
63 | {/if} 64 |
65 |
66 | 71 |
72 |
73 |
74 | -------------------------------------------------------------------------------- /src/routes/(app)/categories/recent/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { IPaginated, IRecipe } from '$lib/types'; 3 | import type { PageServerLoad } from './$types'; 4 | 5 | export const load: PageServerLoad = async ({ cookies, url }) => { 6 | let page = url.searchParams.get('page') || '1'; 7 | if (isNaN(parseInt(page, 10))) { 8 | page = '1'; 9 | } 10 | 11 | try { 12 | const recipesRes = await apiClient(cookies.getAll()).get( 13 | `/recipes?page=${page}&sort=-createdAt` 14 | ); 15 | return { 16 | recipes: recipesRes.data as IPaginated 17 | }; 18 | } catch (e) { 19 | console.log(e); 20 | throw new Error('Error loading recipes'); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/routes/(app)/categories/recent/+page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | Most Recent - Categories - {env.PUBLIC_MAIN_TITLE} 12 | 13 | 14 |
15 |
16 |
17 |

Most Recent

18 |
{data.recipes.total} recipes
19 |
20 |
21 | 22 | {#if !data.recipes.total} 23 |

No recipes

24 | {/if} 25 | 26 |
27 | {#each data.recipes.data as recipe} 28 | 29 | {/each} 30 |
31 | 32 |
33 | 38 |
39 |
40 | -------------------------------------------------------------------------------- /src/routes/(app)/categories/uncategorized/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { IPaginated, IRecipe } from '$lib/types'; 3 | import type { PageServerLoad } from './$types'; 4 | 5 | export const load: PageServerLoad = async ({ cookies, url }) => { 6 | let page = url.searchParams.get('page') || '1'; 7 | if (isNaN(parseInt(page, 10))) { 8 | page = '1'; 9 | } 10 | 11 | try { 12 | const recipesRes = await apiClient(cookies.getAll()).get(`/recipes?page=${page}&categories=[]`); 13 | return { 14 | recipes: recipesRes.data as IPaginated 15 | }; 16 | } catch (e) { 17 | console.log(e); 18 | throw new Error('Error loading recipes'); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/routes/(app)/categories/uncategorized/+page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | Uncategorized - Categories - {env.PUBLIC_MAIN_TITLE} 12 | 13 | 14 |
15 |
16 |
17 |

Uncategorized

18 |
{data.recipes.total} recipes
19 |
20 |
21 | 22 | {#if !data.recipes.total} 23 |

No recipes

24 | {/if} 25 | 26 |
27 | {#each data.recipes.data as recipe} 28 | 29 | {/each} 30 |
31 | 32 |
33 | 38 |
39 |
40 | -------------------------------------------------------------------------------- /src/routes/(app)/charts/(nosidebar)/recipes/[slug]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import { error } from '@sveltejs/kit'; 3 | import type { PageServerLoad } from './$types'; 4 | import type { IRecipe } from '$lib/types'; 5 | 6 | export const load: PageServerLoad = async ({ params, cookies }) => { 7 | const { slug } = params; 8 | 9 | try { 10 | const recipeRes = await apiClient(cookies.getAll()).get(`/recipes/popular/${slug}`); 11 | return { 12 | recipe: recipeRes.data as IRecipe 13 | }; 14 | } catch (e) { 15 | console.log(e); 16 | throw error(404, 'Recipe not found'); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/routes/(app)/charts/(sidebar)/+layout.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/routes/(app)/charts/(sidebar)/recipes/popular/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { IPopularRecipe } from '$lib/types'; 3 | import type { PageServerLoad } from './$types'; 4 | 5 | export const load: PageServerLoad = async ({ cookies }) => { 6 | try { 7 | const recipesRes = await apiClient(cookies.getAll()).get('/recipes/popular?limit=20'); 8 | 9 | return { 10 | popularRecipes: recipesRes.data as IPopularRecipe[] 11 | }; 12 | } catch (e) { 13 | console.log(e); 14 | throw new Error('Error loading popular recipes'); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/routes/(app)/charts/(sidebar)/recipes/popular/+page.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | Popular - Recipes - {env.PUBLIC_MAIN_TITLE} 11 | 12 | 13 |
14 |

Popular Recipes

15 | 16 | {#if !data.popularRecipes.length} 17 |

No recipes

18 | {/if} 19 | 20 |
21 | {#each data.popularRecipes as popularRecipe} 22 | 23 | {/each} 24 |
25 |
26 | -------------------------------------------------------------------------------- /src/routes/(app)/help/+layout.svelte: -------------------------------------------------------------------------------- 1 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/routes/(app)/help/bookmarklet/+page.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | Bookmarklet - Help - {env.PUBLIC_MAIN_TITLE} 11 | 12 | 13 |
14 |

Bookmarklet

15 | 16 |
17 |
18 |

19 | Bookmarklets are special browser bookmarks that can do more than just open websites. The 20 | bookmarklet below will take the current URL, of the recipe website you're on, and open the 21 | recipe import form with the URL input already filled in with that URL. 22 |

23 | 24 |

25 | Simply drag the link below onto the browser bookmark toolbar and use it when browser recipe 26 | websites. 27 |

28 | 29 | 35 |
36 |
37 |
38 | -------------------------------------------------------------------------------- /src/routes/(app)/help/contact/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { getErrorMessage } from '$lib/errors'; 2 | import apiClient from '$lib/server/api/client'; 3 | import type { IEnhanceFailRes, IEnhanceRes } from '$lib/types'; 4 | import { fail } from '@sveltejs/kit'; 5 | 6 | export const actions = { 7 | default: async ({ request, cookies }) => { 8 | const data = await request.formData(); 9 | const subject = data.get('subject') as string; 10 | const message = data.get('message') as string; 11 | 12 | const failObj: IEnhanceFailRes = { inputs: { subject, message }, errors: {} }; 13 | 14 | if (!subject) { 15 | failObj.errors.subject = 'Subject is empty'; 16 | } 17 | 18 | if (!message) { 19 | failObj.errors.message = 'Message is empty'; 20 | } 21 | 22 | if (Object.keys(failObj.errors).length) { 23 | return fail(400, failObj); 24 | } 25 | 26 | try { 27 | await apiClient(cookies.getAll()).post('/help/contact', { 28 | subject, 29 | message 30 | }); 31 | } catch (e) { 32 | console.log(e); 33 | failObj.messageType = 'error'; 34 | failObj.message = getErrorMessage(e); 35 | return fail(500, failObj); 36 | } 37 | 38 | const successObj: IEnhanceRes = { 39 | message: 'Message sent, we will get back to you soon.', 40 | messageType: 'success' 41 | }; 42 | 43 | return successObj; 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /src/routes/(app)/help/contact/+page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | Contact - Help - {env.PUBLIC_MAIN_TITLE} 12 | 13 | 14 |
15 |

Contact

16 |
17 |
18 |

19 | If you got any questions, feedback, feature requests, or anything else, please send us a 20 | message. We reply as soon as possible, usually within a couple of hours. 21 |

22 | {#if form?.message} 23 |
24 | 25 | {form?.message} 26 | 27 |
28 | {/if} 29 |
30 |
31 | 32 | 40 | {#if form?.errors?.subject} 41 |
{form?.errors?.subject}
42 | {/if} 43 |
44 |
45 | 46 | 54 | {#if form?.errors?.message} 55 |
{form?.errors?.message}
56 | {/if} 57 |
58 |
59 | 64 |
65 |
66 |
67 |
68 |
69 | -------------------------------------------------------------------------------- /src/routes/(app)/help/donate/+page.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 | Bookmarklet - Help - {env.PUBLIC_MAIN_TITLE} 8 | 9 | 10 |
11 |

Donate

12 | 13 |
14 |
15 |

16 | ManageMeals started as a hobby project. I wanted a way to save recipes I found online, 17 | without the wall of text and all the ads. Just the recipe ingredients and directions. Then I 18 | decided to open it up for registration and make it fully open source 22 | and 23 | self hostable, for other people to use. 27 |

28 | 29 |

30 | I want ManageMeals to stay free, and without ads, forever. There are some costs involved, 31 | mostly server costs, so any donations would be greatly appreciated. 32 |

33 | 34 |

Thank you!

35 | 36 | 48 |
49 |
50 |
51 | -------------------------------------------------------------------------------- /src/routes/(app)/help/faq/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | FAQ - Help - {env.PUBLIC_MAIN_TITLE} 7 | 8 | 9 |
10 |

FAQ

11 | 12 |
13 |

Does it work with any recipe website?

14 |

15 | It can import recipe data from all the most popular recipe websites, for example BBC Good Food 16 | and Allrecipes. There is also support for hundreds more, and if there is not direct support it 17 | will try to guess the structure of the recipe data. If that doesn't work it will result in an 18 | error. However, an error doesn't mean we can't support it. If you'd like to request support 19 | for a recipe website, please 20 | contact us and we will add support 21 | for it. 22 |

23 |
24 | 25 |
26 |

What's the difference between a category and a tag?

27 |

28 | The idea is that categories are for top-level organization, usually meaning which course a 29 | recipe belongs to: Starter, Main, Side Dish, Dessert. Tags can then be used to attach more 30 | specific info to a recipe, for example: Vegan, BBQ, Steak, Gluten-free. It can then be used to 31 | find a Main which is Vegan. 32 |

33 |
34 | 35 |
36 |

I have a feature request, can you add it?

37 |

38 | Yes, most likely. We want to add the features that people ask for. Please 39 | contact us and tell us about 40 | your idea. 41 |

42 |
43 | 44 | {#if env.PUBLIC_SHOW_SUBSCRIPTION_PAGE === 'true'} 45 |
46 |

Do I have to have the Premium subscription?

47 |

48 | No, Premium is only to unlock more advanced features like Meal Planning and importing 49 | recipes from YouTube. All the basic features, like importing and organizing recipes, is 50 | available on the free plan. 51 |

52 |
53 | {/if} 54 | 55 | {#if env.PUBLIC_SHOW_SUBSCRIPTION_PAGE === 'true'} 56 |
57 |

How do I cancel my subscription?

58 |

59 | You are completely in control of the subscription, and can cancel at any time. Either go to 60 | the subscription settings page or cancel the Direct Debit in your banking app. 63 |

64 |
65 | {/if} 66 |
67 | -------------------------------------------------------------------------------- /src/routes/(app)/recipes/[slug]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { IEnhanceFailRes, IEnhanceRes, IRecipe } from '$lib/types'; 3 | import { error, fail, redirect } from '@sveltejs/kit'; 4 | import type { PageServerLoad } from './$types'; 5 | import { getErrorMessage } from '$lib/errors'; 6 | 7 | export const load: PageServerLoad = async ({ params, cookies }) => { 8 | const { slug } = params; 9 | 10 | try { 11 | const recipeRes = await apiClient(cookies.getAll()).get(`/recipes/${slug}`); 12 | return { 13 | recipe: recipeRes.data as IRecipe 14 | }; 15 | } catch (e) { 16 | console.log(e); 17 | throw error(404, 'Recipe not found'); 18 | } 19 | }; 20 | 21 | export const actions = { 22 | shoppinglist: async ({ request, cookies }) => { 23 | const data = await request.formData(); 24 | const title = data.get('title') as string; 25 | const recipeUuids = data.get('recipeUuids') as string; 26 | const ingredients = data.get('ingredients') as string; 27 | 28 | const failObj: IEnhanceFailRes = { 29 | inputs: {}, 30 | errors: {} 31 | }; 32 | 33 | if (!title) { 34 | failObj.shoppingListMessageType = 'error'; 35 | failObj.shoppingListMessage = 'Missing inputs'; 36 | return fail(400, failObj); 37 | } 38 | 39 | let slug = ''; 40 | try { 41 | const res = await apiClient(cookies.getAll()).post('/shopping-lists', { 42 | title, 43 | ingredients: ingredients.split('|||').filter(Boolean), 44 | recipeUuids: recipeUuids.split(',').filter(Boolean) 45 | }); 46 | slug = res.data.slug; 47 | } catch (e) { 48 | console.log(e); 49 | failObj.shoppingListMessageType = 'error'; 50 | failObj.shoppingListMessage = getErrorMessage(e); 51 | return fail(500, failObj); 52 | } 53 | 54 | return redirect(303, `/shopping-lists/${slug}`); 55 | }, 56 | 57 | share: async ({ request, cookies }) => { 58 | const data = await request.formData(); 59 | const recipeUuid = data.get('recipeUuid') as string; 60 | 61 | const failObj: IEnhanceFailRes = { 62 | inputs: {}, 63 | errors: {} 64 | }; 65 | 66 | if (!recipeUuid) { 67 | failObj.shareMessageType = 'error'; 68 | failObj.shareMessage = 'Missing inputs'; 69 | return fail(400, failObj); 70 | } 71 | 72 | let slug = ''; 73 | try { 74 | const res = await apiClient(cookies.getAll()).post('/share/recipes', { 75 | recipeUuid 76 | }); 77 | slug = res.data.slug; 78 | } catch (e) { 79 | console.log(e); 80 | failObj.shareMessageType = 'error'; 81 | failObj.shareMessage = getErrorMessage(e); 82 | return fail(500, failObj); 83 | } 84 | 85 | const successObj: IEnhanceRes = { 86 | shareSlug: slug as string 87 | }; 88 | 89 | return successObj; 90 | }, 91 | 92 | deleteshare: async ({ request, cookies }) => { 93 | const data = await request.formData(); 94 | const slug = data.get('slug') as string; 95 | 96 | const failObj: IEnhanceFailRes = { 97 | inputs: {}, 98 | errors: {} 99 | }; 100 | 101 | if (!slug) { 102 | failObj.shareMessageType = 'error'; 103 | failObj.shareMessage = 'Missing inputs'; 104 | return fail(400, failObj); 105 | } 106 | 107 | try { 108 | await apiClient(cookies.getAll()).delete(`/share/recipes/${slug}`, { 109 | headers: { 110 | 'Content-Type': null 111 | } 112 | }); 113 | } catch (e) { 114 | console.log(e); 115 | failObj.shareMessageType = 'error'; 116 | failObj.shareMessage = getErrorMessage(e); 117 | return fail(500, failObj); 118 | } 119 | 120 | const successObj: IEnhanceRes = { 121 | shareDeleteSlug: slug 122 | }; 123 | 124 | return successObj; 125 | } 126 | }; 127 | -------------------------------------------------------------------------------- /src/routes/(app)/recipes/create/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { ICategory, IEnhanceFailRes, IEnhanceRes, ITag } from '$lib/types'; 3 | import { fail, redirect } from '@sveltejs/kit'; 4 | import type { PageServerLoad } from './$types'; 5 | import { getErrorMessage } from '$lib/errors'; 6 | 7 | export const load: PageServerLoad = async ({ cookies }) => { 8 | try { 9 | const categoriesRes = await apiClient(cookies.getAll()).get('/categories'); 10 | const tagsRes = await apiClient(cookies.getAll()).get('/tags'); 11 | return { 12 | categories: categoriesRes.data as ICategory[], 13 | tags: tagsRes.data as ITag[] 14 | }; 15 | } catch (e) { 16 | console.log(e); 17 | throw new Error('Error loading tags/categories'); 18 | } 19 | }; 20 | 21 | export const actions = { 22 | create: async ({ request, cookies }) => { 23 | const data = await request.formData(); 24 | const dataTitle = data.get('data.title') as string; 25 | const categories = data.get('categories') as string; 26 | const tags = data.get('tags') as string; 27 | const dataDescription = data.get('data.description') as string; 28 | const dataCanonicalUrl = data.get('data.canonical_url') as string; 29 | const dataCookTime = data.get('data.cook_time') as string; 30 | const dataPrepTime = data.get('data.prep_time') as string; 31 | const dataTotalTime = data.get('data.total_time') as string; 32 | const dataYields = data.get('data.yields') as string; 33 | const dataIngredients = data.get('data.ingredients') as string; 34 | const dataInstructions = data.get('data.instructions_list') as string; 35 | const dataNutrientsCalories = data.get('data.nutrients.calories') as string; 36 | const dataNutrientsCarbohydrate = data.get('data.nutrients.carbohydrateContent') as string; 37 | const dataNutrientsCholesterol = data.get('data.nutrients.cholesterolContent') as string; 38 | const dataNutrientsFat = data.get('data.nutrients.fatContent') as string; 39 | const dataNutrientsFiber = data.get('data.nutrients.fiberContent') as string; 40 | const dataNutrientsProtein = data.get('data.nutrients.proteinContent') as string; 41 | const dataNutrientsSaturatedFat = data.get('data.nutrients.saturatedFatContent') as string; 42 | const dataNutrientsSodium = data.get('data.nutrients.sodiumContent') as string; 43 | const dataNutrientsSugar = data.get('data.nutrients.sugarContent') as string; 44 | const dataNutrientsUnsaturatedFat = data.get('data.nutrients.unsaturatedFatContent') as string; 45 | 46 | const failObj: IEnhanceFailRes = { inputs: { data: { title: dataTitle } }, errors: {} }; 47 | 48 | if (!dataTitle) { 49 | failObj.errors.data = { 50 | title: 'Title is empty' 51 | }; 52 | } 53 | 54 | if (Object.keys(failObj.errors).length) { 55 | return fail(400, failObj); 56 | } 57 | 58 | let slug = ''; 59 | try { 60 | const res = await apiClient(cookies.getAll()).post('/recipes', { 61 | categoryUuids: categories ? categories.split(',') : [], 62 | tagUuids: tags ? tags.split(',') : [], 63 | rating: 0, 64 | data: { 65 | title: dataTitle, 66 | description: dataDescription, 67 | canonical_url: dataCanonicalUrl, 68 | cook_time: dataCookTime, 69 | prep_time: dataPrepTime, 70 | total_time: dataTotalTime, 71 | yields: dataYields, 72 | ingredients: dataIngredients 73 | .replaceAll('\r', '\n') 74 | .split('\n\n') 75 | .filter((d) => d) 76 | .map((d) => d.trim()), 77 | instructions_list: dataInstructions 78 | .replaceAll('\r', '\n') 79 | .split('\n\n') 80 | .filter((d) => d) 81 | .map((d) => d.trim()), 82 | nutrients: { 83 | calories: dataNutrientsCalories, 84 | carbohydrateContent: dataNutrientsCarbohydrate, 85 | cholesterolContent: dataNutrientsCholesterol, 86 | fatContent: dataNutrientsFat, 87 | fiberContent: dataNutrientsFiber, 88 | proteinContent: dataNutrientsProtein, 89 | saturatedFatContent: dataNutrientsSaturatedFat, 90 | sodiumContent: dataNutrientsSodium, 91 | sugarContent: dataNutrientsSugar, 92 | unsaturatedFatContent: dataNutrientsUnsaturatedFat 93 | } 94 | } 95 | }); 96 | slug = res.data.slug; 97 | } catch (e) { 98 | console.log(e); 99 | failObj.messageType = 'error'; 100 | failObj.message = getErrorMessage(e); 101 | return fail(500, failObj); 102 | } 103 | 104 | return redirect(303, `/recipes/${slug}`); 105 | }, 106 | 107 | createcategory: async ({ request, cookies }) => { 108 | const data = await request.formData(); 109 | const name = data.get('name') as string; 110 | 111 | const failObj: IEnhanceFailRes = { inputs: { createCategoryName: name }, errors: {} }; 112 | 113 | if (!name) { 114 | failObj.errors.createCategoryName = 'Name is empty'; 115 | } 116 | 117 | if (Object.keys(failObj.errors).length) { 118 | return fail(400, failObj); 119 | } 120 | 121 | let slug = ''; 122 | try { 123 | const res = await apiClient(cookies.getAll()).post('/categories', { 124 | name 125 | }); 126 | slug = res.data.slug; 127 | } catch (e) { 128 | console.log(e); 129 | failObj.createCategoryMessageType = 'error'; 130 | failObj.createCategoryMessage = getErrorMessage(e); 131 | return fail(500, failObj); 132 | } 133 | 134 | const successObj: IEnhanceRes = { 135 | createCategorySlug: slug as string 136 | }; 137 | 138 | return successObj; 139 | }, 140 | 141 | createtag: async ({ request, cookies }) => { 142 | const data = await request.formData(); 143 | const name = data.get('name') as string; 144 | 145 | const failObj: IEnhanceFailRes = { inputs: { createTagName: name }, errors: {} }; 146 | 147 | if (!name) { 148 | failObj.errors.createTagName = 'Name is empty'; 149 | } 150 | 151 | if (Object.keys(failObj.errors).length) { 152 | return fail(400, failObj); 153 | } 154 | 155 | let slug = ''; 156 | try { 157 | const res = await apiClient(cookies.getAll()).post('/tags', { 158 | name 159 | }); 160 | slug = res.data.slug; 161 | } catch (e) { 162 | console.log(e); 163 | failObj.createTagMessageType = 'error'; 164 | failObj.createTagMessage = getErrorMessage(e); 165 | return fail(500, failObj); 166 | } 167 | 168 | const successObj: IEnhanceRes = { 169 | createTagSlug: slug as string 170 | }; 171 | 172 | return successObj; 173 | } 174 | }; 175 | -------------------------------------------------------------------------------- /src/routes/(app)/recipes/import/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { ICategory, IEnhanceFailRes, IEnhanceRes, ITag } from '$lib/types'; 3 | import { fail, redirect } from '@sveltejs/kit'; 4 | import type { PageServerLoad } from './$types'; 5 | import { getErrorMessage } from '$lib/errors'; 6 | 7 | export const load: PageServerLoad = async ({ cookies }) => { 8 | try { 9 | const categoriesRes = await apiClient(cookies.getAll()).get('/categories'); 10 | const tagsRes = await apiClient(cookies.getAll()).get('/tags'); 11 | return { 12 | categories: categoriesRes.data as ICategory[], 13 | tags: tagsRes.data as ITag[] 14 | }; 15 | } catch (e) { 16 | console.log(e); 17 | throw new Error('Error loading tags/categories'); 18 | } 19 | }; 20 | 21 | export const actions = { 22 | import: async ({ request, cookies }) => { 23 | const data = await request.formData(); 24 | const url = data.get('url') as string; 25 | const categories = data.get('categories') as string; 26 | const tags = data.get('tags') as string; 27 | 28 | const failObj: IEnhanceFailRes = { inputs: { url }, errors: {} }; 29 | 30 | if (!url) { 31 | failObj.errors.url = 'URL is empty'; 32 | } 33 | 34 | if (Object.keys(failObj.errors).length) { 35 | return fail(400, failObj); 36 | } 37 | 38 | let slug = ''; 39 | try { 40 | const res = await apiClient(cookies.getAll()).post(`/recipes/import?url=${url}`, { 41 | categoryUuids: categories ? categories.split(',') : [], 42 | tagUuids: tags ? tags.split(',') : [] 43 | }); 44 | slug = res.data.slug; 45 | } catch (e) { 46 | console.log(e); 47 | failObj.messageType = 'error'; 48 | failObj.message = getErrorMessage(e); 49 | return fail(500, failObj); 50 | } 51 | 52 | return redirect(303, `/recipes/${slug}`); 53 | }, 54 | 55 | createcategory: async ({ request, cookies }) => { 56 | const data = await request.formData(); 57 | const name = data.get('name') as string; 58 | 59 | const failObj: IEnhanceFailRes = { inputs: { createCategoryName: name }, errors: {} }; 60 | 61 | if (!name) { 62 | failObj.errors.createCategoryName = 'Name is empty'; 63 | } 64 | 65 | if (Object.keys(failObj.errors).length) { 66 | return fail(400, failObj); 67 | } 68 | 69 | let slug = ''; 70 | try { 71 | const res = await apiClient(cookies.getAll()).post('/categories', { 72 | name 73 | }); 74 | slug = res.data.slug; 75 | } catch (e) { 76 | console.log(e); 77 | failObj.createCategoryMessageType = 'error'; 78 | failObj.createCategoryMessage = getErrorMessage(e); 79 | return fail(500, failObj); 80 | } 81 | 82 | const successObj: IEnhanceRes = { 83 | createCategorySlug: slug as string 84 | }; 85 | 86 | return successObj; 87 | }, 88 | 89 | createtag: async ({ request, cookies }) => { 90 | const data = await request.formData(); 91 | const name = data.get('name') as string; 92 | 93 | const failObj: IEnhanceFailRes = { inputs: { createTagName: name }, errors: {} }; 94 | 95 | if (!name) { 96 | failObj.errors.createTagName = 'Name is empty'; 97 | } 98 | 99 | if (Object.keys(failObj.errors).length) { 100 | return fail(400, failObj); 101 | } 102 | 103 | let slug = ''; 104 | try { 105 | const res = await apiClient(cookies.getAll()).post('/tags', { 106 | name 107 | }); 108 | slug = res.data.slug; 109 | } catch (e) { 110 | console.log(e); 111 | failObj.createTagMessageType = 'error'; 112 | failObj.createTagMessage = getErrorMessage(e); 113 | return fail(500, failObj); 114 | } 115 | 116 | const successObj: IEnhanceRes = { 117 | createTagSlug: slug as string 118 | }; 119 | 120 | return successObj; 121 | } 122 | }; 123 | -------------------------------------------------------------------------------- /src/routes/(app)/recipes/random/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | import type { PageServerLoad } from './$types'; 3 | import apiClient from '$lib/server/api/client'; 4 | import type { IRecipe } from '$lib/types'; 5 | 6 | export const load: PageServerLoad = async ({ cookies }) => { 7 | let recipe: IRecipe | null = null; 8 | try { 9 | const res = await apiClient(cookies.getAll()).get('/recipes/random'); 10 | recipe = res.data as IRecipe; 11 | } catch (e) { 12 | console.log(e); 13 | } 14 | 15 | if (!recipe) { 16 | return redirect(303, '/categories'); 17 | } 18 | 19 | return redirect(303, `/recipes/${recipe.slug}`); 20 | }; 21 | -------------------------------------------------------------------------------- /src/routes/(app)/recipes/random/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | Random - Recipes - {env.PUBLIC_MAIN_TITLE} 7 | 8 | -------------------------------------------------------------------------------- /src/routes/(app)/search/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { ISearch, ISearchRecipe } from '$lib/types'; 3 | import type { PageServerLoad } from './$types'; 4 | 5 | export const load: PageServerLoad = async ({ cookies, url }) => { 6 | const q = url.searchParams.get('q'); 7 | let page = url.searchParams.get('page') || '1'; 8 | if (isNaN(parseInt(page, 10))) { 9 | page = '1'; 10 | } 11 | 12 | if (!q) { 13 | return { 14 | search: undefined 15 | }; 16 | } 17 | 18 | try { 19 | const searchRes = await apiClient(cookies.getAll()).get(`/search?q=${q}&c=recipes&p=${page}`); 20 | return { 21 | q, 22 | search: searchRes.data as ISearch 23 | }; 24 | } catch (e) { 25 | console.log(e); 26 | throw new Error('Error searching'); 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /src/routes/(app)/search/+page.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | {data.q ? `${data.q} - ` : ''}Search - {env.PUBLIC_MAIN_TITLE} 13 | 14 | 15 |
16 |

Search

17 |
18 |
21 | 29 | 32 |
33 |
34 | {#if data.q && (!data.search?.hits || !data.search?.hits.length)} 35 |

No results found

36 | {/if} 37 |
38 | {#each data.search?.hits || [] as hit} 39 |
40 | 41 |
42 | {/each} 43 |
44 |
45 | 50 |
51 |
52 | -------------------------------------------------------------------------------- /src/routes/(app)/settings/+layout.svelte: -------------------------------------------------------------------------------- 1 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /src/routes/(app)/settings/logout/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | Logout - Settings - {env.PUBLIC_MAIN_TITLE} 7 | 8 | 9 |
10 |

Logout

11 | 20 |
21 | -------------------------------------------------------------------------------- /src/routes/(app)/settings/subscription/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { IEnhanceFailRes, IEnhanceRes, ISubscriptionUpcomingPayment } from '$lib/types'; 3 | import { fail, redirect } from '@sveltejs/kit'; 4 | import type { PageServerLoad } from './$types'; 5 | 6 | export const load: PageServerLoad = async ({ cookies, parent }) => { 7 | const parentData = await parent(); 8 | 9 | let upcomingPayments: ISubscriptionUpcomingPayment[] = []; 10 | 11 | try { 12 | const res = await apiClient(cookies.getAll()).get('/subscriptions/payments'); 13 | upcomingPayments = res.data; 14 | } catch (e) { 15 | console.log(e); 16 | } 17 | 18 | return { upcomingPayments, user: parentData.user }; 19 | }; 20 | 21 | export const actions = { 22 | cancel: async ({ cookies }) => { 23 | try { 24 | await apiClient(cookies.getAll()).post('/subscriptions/cancel', {}); 25 | } catch (e) { 26 | console.log(e); 27 | const failObj: IEnhanceRes = { 28 | messageType: 'error', 29 | message: 'There was an error cancelling subscription, please try again.' 30 | }; 31 | return fail(500, failObj); 32 | } 33 | 34 | return redirect(303, '/settings/subscription/cancel'); 35 | }, 36 | paypal: async ({ request, cookies }) => { 37 | const data = await request.formData(); 38 | const subscriptionId = data.get('subscriptionId') as string; 39 | 40 | const failObj: IEnhanceFailRes = { 41 | inputs: { subscriptionId }, 42 | errors: {}, 43 | message: '', 44 | messageType: 'error' 45 | }; 46 | 47 | if (!subscriptionId) { 48 | failObj.message = 'Subscription ID is empty.'; 49 | return fail(400, failObj); 50 | } 51 | 52 | try { 53 | await apiClient(cookies.getAll()).post('/subscriptions/paypal', { 54 | subscriptionId 55 | }); 56 | } catch (e) { 57 | console.log(e); 58 | throw new Error('Error creating subscription'); 59 | } 60 | 61 | return redirect(303, '/settings/subscription/success'); 62 | } 63 | }; 64 | -------------------------------------------------------------------------------- /src/routes/(app)/settings/subscription/+page.svelte: -------------------------------------------------------------------------------- 1 | 56 | 57 | 58 | Subscription - Settings - {env.PUBLIC_MAIN_TITLE} 59 | 60 | 61 |
62 |
63 |

Subscription

64 | 72 |
73 | 74 | {#if form?.message} 75 |
76 | 77 | {form?.message} 78 | 79 |
80 | {/if} 81 |

82 | Subscription type: {data.user.subscriptionType} 83 |

84 |

Upcoming Payments

85 | {#if data.upcomingPayments.length === 0} 86 |
No upcoming payments
87 | {/if} 88 |
89 | {#each data.upcomingPayments as payment} 90 |
91 |
{format(payment.chargeDate, 'd MMM yyyy')}
92 |
93 | {(payment.amount / 100).toLocaleString('en-GB', { style: 'currency', currency: 'GBP' })} 94 |
95 |
96 | {/each} 97 |
98 | 99 | {#if data.user.subscriptionType !== 'PREMIUM'} 100 |

101 | Premium subscription is {env.PUBLIC_PREMIUM_PRICE} 102 | per month and gives you access to more advanced features: 103 |

104 | 105 |
    106 |
  • Meal Planner
  • 107 |
  • Import recipes from YouTube videos
  • 108 |
  • Import recipes from images (coming soon)
  • 109 |
  • Shopping lists
  • 110 |
  • And more features in the works
  • 111 |
112 | 113 |

There are two ways of setting up a subscription, PayPal or GoCardless.

114 | 115 |
116 |
117 |
118 | 124 |
125 |
126 | 127 |
128 |
129 | 134 | 135 | 136 |
137 |
138 | {/if} 139 |
140 | 141 | 142 |
Cancel subscription
143 |

Clicking the button below will cancel your subscription.

144 |
{ 148 | return async ({ update }) => { 149 | await update(); 150 | showCancelModal = false; 151 | }; 152 | }} 153 | > 154 | 157 |
158 |
159 | -------------------------------------------------------------------------------- /src/routes/(app)/settings/subscription/callback/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { error, redirect } from '@sveltejs/kit'; 2 | import apiClient from '$lib/server/api/client'; 3 | import type { PageServerLoad } from './$types'; 4 | import axios, { AxiosError } from 'axios'; 5 | import type { IAPIError } from '$lib/types'; 6 | 7 | export const load: PageServerLoad = async ({ cookies }) => { 8 | try { 9 | await apiClient(cookies.getAll()).post('/subscriptions', {}); 10 | } catch (e) { 11 | console.log(e); 12 | if ( 13 | axios.isAxiosError(e) && 14 | ((e as AxiosError)?.response?.data.message || '').includes('already subscribed') 15 | ) { 16 | throw error(400, (e as AxiosError)?.response?.data.message); 17 | } 18 | throw new Error('Error creating subscription'); 19 | } 20 | 21 | return redirect(303, '/settings/subscription/success'); 22 | }; 23 | -------------------------------------------------------------------------------- /src/routes/(app)/settings/subscription/callback/+page.svelte: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/managemeals/manage-meals-web/9ae9e6cbdcae94faa50460e26d6af06a42a1e927/src/routes/(app)/settings/subscription/callback/+page.svelte -------------------------------------------------------------------------------- /src/routes/(app)/settings/subscription/cancel/+page.server.ts: -------------------------------------------------------------------------------- 1 | import type { PageServerLoad } from './$types'; 2 | 3 | export const load: PageServerLoad = async ({ parent }) => { 4 | const parentData = await parent(); 5 | 6 | return { user: parentData.user }; 7 | }; 8 | -------------------------------------------------------------------------------- /src/routes/(app)/settings/subscription/cancel/+page.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 |

Subscription has been cancelled.

9 |

10 | Your subscription has been cancelled. Your subscription type is now {data.user.subscriptionType}. 13 |

14 |
15 | -------------------------------------------------------------------------------- /src/routes/(app)/settings/subscription/mandate/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | import apiClient from '$lib/server/api/client'; 3 | import axios, { AxiosError } from 'axios'; 4 | import type { IAPIError } from '$lib/types'; 5 | import type { PageServerLoad } from './$types'; 6 | 7 | export const load: PageServerLoad = async ({ cookies }) => { 8 | let authorisationUrl = ''; 9 | let hasMandate = false; 10 | try { 11 | const res = await apiClient(cookies.getAll()).get('/subscriptions/mandate'); 12 | authorisationUrl = res.data.authorisationUrl; 13 | } catch (e) { 14 | console.log(e); 15 | if ( 16 | axios.isAxiosError(e) && 17 | ((e as AxiosError)?.response?.data.message || '').includes('already has one') 18 | ) { 19 | hasMandate = true; 20 | } else { 21 | throw new Error('Error getting authorisation URL'); 22 | } 23 | } 24 | 25 | if (hasMandate) { 26 | return redirect(303, '/settings/subscription/callback'); 27 | } 28 | 29 | return redirect(303, authorisationUrl); 30 | }; 31 | -------------------------------------------------------------------------------- /src/routes/(app)/settings/subscription/mandate/+page.svelte: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/managemeals/manage-meals-web/9ae9e6cbdcae94faa50460e26d6af06a42a1e927/src/routes/(app)/settings/subscription/mandate/+page.svelte -------------------------------------------------------------------------------- /src/routes/(app)/settings/subscription/success/+page.server.ts: -------------------------------------------------------------------------------- 1 | import type { PageServerLoad } from './$types'; 2 | 3 | export const load: PageServerLoad = async ({ parent }) => { 4 | const parentData = await parent(); 5 | 6 | return { user: parentData.user }; 7 | }; 8 | -------------------------------------------------------------------------------- /src/routes/(app)/settings/subscription/success/+page.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 |

Subscription has been set up

9 |

10 | Thanks for subscribing. Your subscription type is now {data.user.subscriptionType}. 13 |

14 |
15 | -------------------------------------------------------------------------------- /src/routes/(app)/settings/user/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { env } from '$env/dynamic/private'; 2 | import { getErrorMessage } from '$lib/errors'; 3 | import apiClient from '$lib/server/api/client'; 4 | import type { IEnhanceFailRes, IEnhanceRes, IPatchUserReq } from '$lib/types'; 5 | import { fail, redirect } from '@sveltejs/kit'; 6 | import type { PageServerLoad } from './$types'; 7 | 8 | export const load: PageServerLoad = async ({ parent }) => { 9 | const parentData = await parent(); 10 | 11 | return { 12 | user: parentData.user 13 | }; 14 | }; 15 | 16 | export const actions = { 17 | settings: async ({ request, cookies }) => { 18 | const data = await request.formData(); 19 | const name = data.get('name') as string; 20 | const email = data.get('email') as string; 21 | const password = data.get('password') as string; 22 | 23 | const failObj: IEnhanceFailRes = { inputs: { name, email }, errors: {} }; 24 | 25 | if (!name) { 26 | failObj.errors.name = 'Name is empty'; 27 | } 28 | 29 | if (!email) { 30 | failObj.errors.email = 'Email is empty'; 31 | } 32 | 33 | if (password && password.length < parseInt(env.PASSWORD_MIN_LENGTH, 10)) { 34 | failObj.errors.password = 'Password is too short'; 35 | } 36 | 37 | if (Object.keys(failObj.errors).length) { 38 | return fail(400, failObj); 39 | } 40 | 41 | const patchObj: IPatchUserReq = { name, email }; 42 | 43 | if (password) { 44 | patchObj.password = password; 45 | } 46 | 47 | try { 48 | await apiClient(cookies.getAll()).patch('/settings/user', patchObj); 49 | } catch (e) { 50 | console.log(e); 51 | failObj.messageType = 'error'; 52 | failObj.message = getErrorMessage(e); 53 | return fail(500, failObj); 54 | } 55 | 56 | const successObj: IEnhanceRes = { 57 | message: 'User settings updated.', 58 | messageType: 'success' 59 | }; 60 | 61 | return successObj; 62 | }, 63 | 64 | delete: async ({ cookies }) => { 65 | try { 66 | await apiClient(cookies.getAll()).delete('/settings/user', { 67 | headers: { 68 | 'Content-Type': null 69 | } 70 | }); 71 | } catch (e) { 72 | console.log(e); 73 | const failObj: IEnhanceRes = { 74 | deleteUserType: 'error', 75 | deleteUser: getErrorMessage(e) 76 | }; 77 | return fail(500, failObj); 78 | } 79 | 80 | return redirect(303, '/logout'); 81 | } 82 | }; 83 | -------------------------------------------------------------------------------- /src/routes/(app)/settings/user/+page.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | User - Settings - {env.PUBLIC_MAIN_TITLE} 18 | 19 | 20 |
21 |
22 |

User Settings

23 | 32 |
33 |
34 |
35 | {#if form?.message} 36 |
37 | 38 | {form?.message} 39 | 40 |
41 | {/if} 42 |
{ 46 | return async ({ update }) => { 47 | await update({ reset: false }); 48 | }; 49 | }} 50 | > 51 |
52 | 53 | 61 | {#if form?.errors?.name} 62 |
{form?.errors?.name}
63 | {/if} 64 |
65 |
66 | 67 | 75 | {#if env.PUBLIC_EMAIL_VERIFY_ENABLED === 'true'} 76 |

77 | Changing your email will send a verification email 78 |

79 | {/if} 80 | {#if form?.errors?.email} 81 |
{form?.errors?.email}
82 | {/if} 83 |
84 |
85 | 86 | 93 |

94 | Only fill this out if you want to change your password 95 |

96 | {#if form?.errors?.password} 97 |
{form?.errors?.password}
98 | {/if} 99 |
100 |
101 | 106 |
107 |
108 |
109 |
110 |
111 | 112 | 113 |
Delete User
114 | {#if form?.deleteUser} 115 |
116 | 117 | {form?.deleteUser} 118 | 119 |
120 | {/if} 121 |

122 | Clicking the delete button below will delete your user and all data related to the user, such as 123 | recipes. 124 |

125 |
126 | 129 |
130 |
131 | -------------------------------------------------------------------------------- /src/routes/(app)/share/recipes/[slug]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import { error } from '@sveltejs/kit'; 3 | import type { PageServerLoad } from './$types'; 4 | import type { IShareRecipe } from '$lib/types'; 5 | 6 | export const load: PageServerLoad = async ({ params, cookies }) => { 7 | const { slug } = params; 8 | 9 | try { 10 | const res = await apiClient(cookies.getAll()).get(`/share/recipes/${slug}`); 11 | return { 12 | shareRecipe: res.data as IShareRecipe 13 | }; 14 | } catch (e) { 15 | console.log(e); 16 | throw error(404, 'Share recipe not found'); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/routes/(app)/shopping-lists/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { IShoppingList } from '$lib/types'; 3 | import axios from 'axios'; 4 | import type { PageServerLoad } from './$types'; 5 | 6 | export const load: PageServerLoad = async ({ cookies, parent }) => { 7 | let shoppingLists: IShoppingList[] = []; 8 | 9 | const parentData = await parent(); 10 | 11 | try { 12 | const res = await apiClient(cookies.getAll()).get('/shopping-lists'); 13 | shoppingLists = res.data as IShoppingList[]; 14 | } catch (e) { 15 | console.log(e); 16 | if (!axios.isAxiosError(e) || e.response?.data.message === 'Invalid Authorization header') { 17 | throw new Error('Error loading shopping lists'); 18 | } 19 | } 20 | 21 | return { 22 | user: parentData.user, 23 | shoppingLists 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /src/routes/(app)/shopping-lists/+page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | Shopping Lists - {env.PUBLIC_MAIN_TITLE} 12 | 13 | 14 |
15 |
16 |

Shopping Lists

17 | {#if data.user.subscriptionType !== 'PREMIUM' && env.PUBLIC_SHOW_SUBSCRIPTION_PAGE === 'true'} 18 | Premium 21 | {/if} 22 | {#if data.user.subscriptionType === 'PREMIUM'} 23 | 28 | 29 | 30 | {/if} 31 |
32 | 33 | {#if !data.shoppingLists || !data.shoppingLists.length} 34 |

No shopping lists

35 | {/if} 36 | 37 | 65 |
66 | -------------------------------------------------------------------------------- /src/routes/(app)/shopping-lists/[slug]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import type { IShoppingList } from '$lib/types'; 2 | import { error } from '@sveltejs/kit'; 3 | import type { PageServerLoad } from './$types'; 4 | import apiClient from '$lib/server/api/client'; 5 | 6 | export const load: PageServerLoad = async ({ cookies, params }) => { 7 | const { slug } = params; 8 | 9 | let shoppingList: IShoppingList; 10 | try { 11 | const res = await apiClient(cookies.getAll()).get(`/shopping-lists/${slug}`); 12 | shoppingList = res.data as IShoppingList; 13 | } catch (e) { 14 | console.log(e); 15 | throw error(404, 'Shopping list not found'); 16 | } 17 | 18 | return { 19 | shoppingList 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /src/routes/(app)/shopping-lists/[slug]/+page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | {data.shoppingList.title} - Shopping Lists - {env.PUBLIC_MAIN_TITLE} 12 | 13 | 14 |
15 |
16 |
17 |

{data.shoppingList.title}

18 |
19 | {data.shoppingList.ingredients?.length || 0} 20 | {(data.shoppingList.ingredients?.length || 0) === 1 ? 'item' : 'items'} 21 |
22 |
23 | 28 | 29 | 30 |
31 | 32 | {#if !data.shoppingList.ingredients?.length} 33 |

No items in this shopping list

34 | {/if} 35 | 36 |
37 | {#each data.shoppingList.ingredients || [] as ingredient, i} 38 |
39 | 40 | 41 |
42 | {/each} 43 |
44 | 45 |
46 |

Linked Recipes

47 | {#if !(data.shoppingList?.recipes || []).length} 48 |

No linked recipes

49 | {/if} 50 |
51 | {#each data.shoppingList.recipes || [] as recipe} 52 | 53 | {/each} 54 |
55 |
56 |
57 | -------------------------------------------------------------------------------- /src/routes/(app)/shopping-lists/[slug]/edit/+page.server.ts: -------------------------------------------------------------------------------- 1 | import type { IEnhanceFailRes, IEnhanceRes, IShoppingList } from '$lib/types'; 2 | import { error, fail, redirect } from '@sveltejs/kit'; 3 | import type { PageServerLoad } from './$types'; 4 | import apiClient from '$lib/server/api/client'; 5 | import { getErrorMessage } from '$lib/errors'; 6 | 7 | export const load: PageServerLoad = async ({ cookies, params }) => { 8 | const { slug } = params; 9 | 10 | let shoppingList: IShoppingList; 11 | try { 12 | const res = await apiClient(cookies.getAll()).get(`/shopping-lists/${slug}`); 13 | shoppingList = res.data as IShoppingList; 14 | } catch (e) { 15 | console.log(e); 16 | throw error(404, 'Shopping list not found'); 17 | } 18 | 19 | return { 20 | shoppingList 21 | }; 22 | }; 23 | 24 | export const actions = { 25 | edit: async ({ request, cookies, params }) => { 26 | const { slug } = params; 27 | 28 | const data = await request.formData(); 29 | const title = data.get('title') as string; 30 | const ingredients = data.get('ingredients') as string; 31 | const recipeUuids = data.get('recipeUuids') as string; 32 | 33 | const failObj: IEnhanceFailRes = { inputs: { title }, errors: {} }; 34 | 35 | if (!title) { 36 | failObj.errors.title = 'Title is empty'; 37 | } 38 | 39 | if (Object.keys(failObj.errors).length) { 40 | return fail(400, failObj); 41 | } 42 | 43 | try { 44 | await apiClient(cookies.getAll()).put(`/shopping-lists/${slug}`, { 45 | title, 46 | ingredients: ingredients 47 | .replaceAll('\r', '\n') 48 | .split('\n\n') 49 | .filter((d) => d) 50 | .map((d) => d.trim()), 51 | recipeUuids: recipeUuids ? recipeUuids.split(',') : [] 52 | }); 53 | } catch (e) { 54 | console.log(e); 55 | failObj.messageType = 'error'; 56 | failObj.message = getErrorMessage(e); 57 | return fail(500, failObj); 58 | } 59 | 60 | return redirect(303, `/shopping-lists/${slug}`); 61 | }, 62 | 63 | delete: async ({ cookies, params }) => { 64 | const { slug } = params; 65 | 66 | try { 67 | await apiClient(cookies.getAll()).delete(`/shopping-lists/${slug}`, { 68 | headers: { 69 | 'Content-Type': null 70 | } 71 | }); 72 | } catch (e) { 73 | console.log(e); 74 | const failObj: IEnhanceRes = { 75 | deleteMessageType: 'error', 76 | deleteMessage: getErrorMessage(e) 77 | }; 78 | return fail(500, failObj); 79 | } 80 | 81 | return redirect(303, '/shopping-lists'); 82 | } 83 | }; 84 | -------------------------------------------------------------------------------- /src/routes/(app)/shopping-lists/create/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { getErrorMessage } from '$lib/errors'; 2 | import apiClient from '$lib/server/api/client'; 3 | import type { IEnhanceFailRes } from '$lib/types'; 4 | import { fail, redirect } from '@sveltejs/kit'; 5 | 6 | export const actions = { 7 | default: async ({ request, cookies }) => { 8 | const data = await request.formData(); 9 | const title = data.get('title') as string; 10 | const ingredients = data.get('ingredients') as string; 11 | const recipeUuids = data.get('recipeUuids') as string; 12 | 13 | const failObj: IEnhanceFailRes = { inputs: { title }, errors: {} }; 14 | 15 | if (!title) { 16 | failObj.errors.title = 'Title is empty'; 17 | } 18 | 19 | if (Object.keys(failObj.errors).length) { 20 | return fail(400, failObj); 21 | } 22 | 23 | let slug = ''; 24 | try { 25 | const res = await apiClient(cookies.getAll()).post('/shopping-lists', { 26 | title, 27 | ingredients: ingredients 28 | .replaceAll('\r', '\n') 29 | .split('\n\n') 30 | .filter((d) => d) 31 | .map((d) => d.trim()), 32 | recipeUuids: recipeUuids ? recipeUuids.split(',') : [] 33 | }); 34 | slug = res.data.slug; 35 | } catch (e) { 36 | console.log(e); 37 | failObj.messageType = 'error'; 38 | failObj.message = getErrorMessage(e); 39 | return fail(500, failObj); 40 | } 41 | 42 | return redirect(303, `/shopping-lists/${slug}`); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /src/routes/(app)/tags/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { ITag } from '$lib/types'; 3 | import type { LayoutServerLoad } from './$types'; 4 | 5 | export const load: LayoutServerLoad = async ({ cookies }) => { 6 | try { 7 | const tagsRes = await apiClient(cookies.getAll()).get('/tags'); 8 | return { 9 | tags: tagsRes.data as ITag[] 10 | }; 11 | } catch (e) { 12 | console.log(e); 13 | throw new Error('Error loading tags'); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/routes/(app)/tags/+layout.svelte: -------------------------------------------------------------------------------- 1 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/routes/(app)/tags/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { ICategory, IPaginated, IRecipe } from '$lib/types'; 3 | import type { PageServerLoad } from './$types'; 4 | 5 | export const load: PageServerLoad = async ({ cookies, url, parent }) => { 6 | let page = url.searchParams.get('page') || '1'; 7 | if (isNaN(parseInt(page, 10))) { 8 | page = '1'; 9 | } 10 | 11 | try { 12 | const categoriesRes = await apiClient(cookies.getAll()).get('/categories'); 13 | const categoriesResData = categoriesRes.data as ICategory[]; 14 | 15 | const parentRes = await parent(); 16 | 17 | let selectedCategories = ['Categories']; 18 | const categoriesQ = url.searchParams.get('categories'); 19 | let categoriesFilter: string[] = []; 20 | if (categoriesQ) { 21 | categoriesFilter = categoriesQ.split(',').map((c) => { 22 | const category = categoriesResData.find((pc) => pc.slug === c); 23 | return `categories=${category?.uuid}`; 24 | }); 25 | selectedCategories = categoriesQ.split(',').map((c) => { 26 | const category = categoriesResData.find((pc) => pc.slug === c); 27 | return category?.name || ''; 28 | }); 29 | } 30 | 31 | let selectedTags = ['Tags']; 32 | const tagsQ = url.searchParams.get('tags'); 33 | let tagsFilter: string[] = []; 34 | if (tagsQ) { 35 | tagsFilter = tagsQ.split(',').map((t) => { 36 | const tag = parentRes.tags.find((td) => td.slug === t); 37 | return `tags=${tag?.uuid}`; 38 | }); 39 | selectedTags = tagsQ.split(',').map((t) => { 40 | const tag = parentRes.tags.find((td) => td.slug === t); 41 | return tag?.name || ''; 42 | }); 43 | } 44 | 45 | const recipesRes = await apiClient(cookies.getAll()).get( 46 | `/recipes?page=${page}${categoriesFilter.length ? `&${categoriesFilter.join('&')}` : ''}${tagsFilter.length ? `&${tagsFilter.join('&')}` : ''}` 47 | ); 48 | 49 | return { 50 | recipes: recipesRes.data as IPaginated, 51 | categories: categoriesResData, 52 | selectedCategories, 53 | selectedTags, 54 | selectedCategoriesSlugs: (categoriesQ || '').split(','), 55 | selectedTagsSlugs: (tagsQ || '').split(',') 56 | }; 57 | } catch (e) { 58 | console.log(e); 59 | throw new Error('Error loading recipes'); 60 | } 61 | }; 62 | -------------------------------------------------------------------------------- /src/routes/(app)/tags/+page.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | Tags - {env.PUBLIC_MAIN_TITLE} 21 | 22 | 23 |
24 |
25 |
26 |

All Tags

27 |
{data.recipes.total} recipes
28 |
29 | 30 |
31 |
32 | 42 | {#if categoriesBtnEl} 43 | 83 | {/if} 84 |
85 | 86 |
87 | 97 | {#if tagsBtnEl} 98 | 135 | {/if} 136 |
137 |
138 |
139 | 140 | {#if !data.recipes.total} 141 |

No recipes

142 | {/if} 143 | 144 |
145 | {#each data.recipes.data as recipe} 146 | 147 | {/each} 148 |
149 | 150 |
151 | 156 |
157 |
158 | -------------------------------------------------------------------------------- /src/routes/(app)/tags/[slug]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { IPaginated, IRecipe, ITag } from '$lib/types'; 3 | import { error } from '@sveltejs/kit'; 4 | import type { PageServerLoad } from './$types'; 5 | 6 | export const load: PageServerLoad = async ({ cookies, url, params }) => { 7 | const { slug } = params; 8 | 9 | let page = url.searchParams.get('page') || '1'; 10 | if (isNaN(parseInt(page, 10))) { 11 | page = '1'; 12 | } 13 | 14 | let tag: ITag; 15 | try { 16 | const tagRes = await apiClient(cookies.getAll()).get(`/tags/${slug}`); 17 | tag = tagRes.data; 18 | } catch (e) { 19 | console.log(e); 20 | throw error(404, 'Tag not found'); 21 | } 22 | 23 | try { 24 | const recipesRes = await apiClient(cookies.getAll()).get( 25 | `/recipes?page=${page}&tags=${tag.uuid}` 26 | ); 27 | return { 28 | tag, 29 | recipes: recipesRes.data as IPaginated 30 | }; 31 | } catch (e) { 32 | console.log(e); 33 | throw new Error('Error loading recipes'); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /src/routes/(app)/tags/[slug]/+page.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | {data.tag.name} - Tags - {env.PUBLIC_MAIN_TITLE} 13 | 14 | 15 |
16 |
17 |
18 |

{data.tag.name}

19 |
{data.recipes.total} recipes
20 |
21 | 22 | 23 | 24 |
25 | 26 | {#if !data.recipes.total} 27 |

No recipes with this tag

28 | {/if} 29 | 30 |
31 | {#each data.recipes.data as recipe} 32 | 33 | {/each} 34 |
35 | 36 |
37 | 42 |
43 |
44 | -------------------------------------------------------------------------------- /src/routes/(app)/tags/[slug]/edit/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { IEnhanceFailRes, IEnhanceRes, ITag } from '$lib/types'; 3 | import { fail, redirect } from '@sveltejs/kit'; 4 | import type { PageServerLoad } from './$types'; 5 | import { getErrorMessage } from '$lib/errors'; 6 | 7 | export const load: PageServerLoad = async ({ cookies, params }) => { 8 | const { slug } = params; 9 | 10 | try { 11 | const res = await apiClient(cookies.getAll()).get(`/tags/${slug}`); 12 | return { 13 | tag: res.data as ITag 14 | }; 15 | } catch (e) { 16 | console.log(e); 17 | throw new Error('Error loading tag'); 18 | } 19 | }; 20 | 21 | export const actions = { 22 | edit: async ({ request, cookies, params }) => { 23 | const { slug } = params; 24 | 25 | const data = await request.formData(); 26 | const name = data.get('name') as string; 27 | 28 | const failObj: IEnhanceFailRes = { inputs: { name }, errors: {} }; 29 | 30 | if (!name) { 31 | failObj.errors.name = 'Name is empty'; 32 | } 33 | 34 | if (Object.keys(failObj.errors).length) { 35 | return fail(400, failObj); 36 | } 37 | 38 | try { 39 | await apiClient(cookies.getAll()).patch(`/tags/${slug}`, { 40 | name 41 | }); 42 | } catch (e) { 43 | console.log(e); 44 | failObj.messageType = 'error'; 45 | failObj.message = getErrorMessage(e); 46 | return fail(500, failObj); 47 | } 48 | 49 | const successObj: IEnhanceRes = { 50 | message: 'Tag updated.', 51 | messageType: 'success', 52 | slug, 53 | name 54 | }; 55 | 56 | return successObj; 57 | }, 58 | delete: async ({ cookies, params }) => { 59 | const { slug } = params; 60 | 61 | try { 62 | await apiClient(cookies.getAll()).delete(`/tags/${slug}`, { 63 | headers: { 64 | 'Content-Type': null 65 | } 66 | }); 67 | } catch (e) { 68 | console.log(e); 69 | const failObj: IEnhanceRes = { 70 | deleteMessageType: 'error', 71 | deleteMessage: 'There was an error deleting tag, please try again.' 72 | }; 73 | return fail(500, failObj); 74 | } 75 | 76 | return redirect(303, '/tags'); 77 | } 78 | }; 79 | -------------------------------------------------------------------------------- /src/routes/(app)/tags/[slug]/edit/+page.svelte: -------------------------------------------------------------------------------- 1 | 35 | 36 | 37 | {data.tag.name} - Edit - Tags - {env.PUBLIC_MAIN_TITLE} 38 | 39 | 40 |
41 |
42 |

Edit Tag

43 | 52 |
53 | {#if form?.message} 54 |
55 | 56 | {form?.message} 57 | 58 |
59 | {/if} 60 |
{ 64 | return async ({ update }) => { 65 | await update({ reset: false }); 66 | }; 67 | }} 68 | > 69 |
70 | 71 | 79 | {#if form?.errors?.name} 80 |
{form?.errors?.name}
81 | {/if} 82 |
83 |
84 | 89 |
90 |
91 |
92 | 93 | 94 |
Delete Tag
95 | {#if form?.deleteMessage} 96 |
97 | 98 | {form?.deleteMessage} 99 | 100 |
101 | {/if} 102 |

103 | Deleting a tag will not delete any recipes. It will only remove the deleted tag from the 104 | recipes. 105 |

106 |
107 | 110 |
111 |
112 | -------------------------------------------------------------------------------- /src/routes/(app)/tags/create/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { getErrorMessage } from '$lib/errors'; 2 | import apiClient from '$lib/server/api/client'; 3 | import type { IEnhanceFailRes, IEnhanceRes } from '$lib/types'; 4 | import { fail } from '@sveltejs/kit'; 5 | 6 | export const actions = { 7 | default: async ({ request, cookies }) => { 8 | const data = await request.formData(); 9 | const name = data.get('name') as string; 10 | 11 | const failObj: IEnhanceFailRes = { inputs: { name }, errors: {} }; 12 | 13 | if (!name) { 14 | failObj.errors.name = 'Name is empty'; 15 | } 16 | 17 | if (Object.keys(failObj.errors).length) { 18 | return fail(400, failObj); 19 | } 20 | 21 | let slug = ''; 22 | try { 23 | const res = await apiClient(cookies.getAll()).post('/tags', { 24 | name 25 | }); 26 | slug = res.data.slug; 27 | } catch (e) { 28 | console.log(e); 29 | failObj.messageType = 'error'; 30 | failObj.message = getErrorMessage(e); 31 | return fail(500, failObj); 32 | } 33 | 34 | const successObj: IEnhanceRes = { 35 | name: name as string, 36 | slug: slug as string 37 | }; 38 | 39 | return successObj; 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /src/routes/(app)/tags/create/+page.svelte: -------------------------------------------------------------------------------- 1 | 36 | 37 | 38 | Create Tag - Tags - {env.PUBLIC_MAIN_TITLE} 39 | 40 | 41 |
42 |

Create Tag

43 | {#if form?.message} 44 |
45 | 46 | {form?.message} 47 | 48 |
49 | {/if} 50 |
51 |
52 | 53 | 61 | {#if form?.errors?.name} 62 |
{form?.errors?.name}
63 | {/if} 64 |
65 |
66 | 71 |
72 |
73 |
74 | -------------------------------------------------------------------------------- /src/routes/(app)/tags/recent/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { IPaginated, IRecipe } from '$lib/types'; 3 | import type { PageServerLoad } from './$types'; 4 | 5 | export const load: PageServerLoad = async ({ cookies, url }) => { 6 | let page = url.searchParams.get('page') || '1'; 7 | if (isNaN(parseInt(page, 10))) { 8 | page = '1'; 9 | } 10 | 11 | try { 12 | const recipesRes = await apiClient(cookies.getAll()).get( 13 | `/recipes?page=${page}&sort=-createdAt` 14 | ); 15 | return { 16 | recipes: recipesRes.data as IPaginated 17 | }; 18 | } catch (e) { 19 | console.log(e); 20 | throw new Error('Error loading recipes'); 21 | } 22 | }; 23 | -------------------------------------------------------------------------------- /src/routes/(app)/tags/recent/+page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | Most Recent - Tags - {env.PUBLIC_MAIN_TITLE} 12 | 13 | 14 |
15 |
16 |
17 |

Most Recent

18 |
{data.recipes.total} recipes
19 |
20 |
21 | 22 | {#if !data.recipes.total} 23 |

No recipes

24 | {/if} 25 | 26 |
27 | {#each data.recipes.data as recipe} 28 | 29 | {/each} 30 |
31 | 32 |
33 | 38 |
39 |
40 | -------------------------------------------------------------------------------- /src/routes/(app)/tags/untagged/+page.server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import type { IPaginated, IRecipe } from '$lib/types'; 3 | import type { PageServerLoad } from './$types'; 4 | 5 | export const load: PageServerLoad = async ({ cookies, url }) => { 6 | let page = url.searchParams.get('page') || '1'; 7 | if (isNaN(parseInt(page, 10))) { 8 | page = '1'; 9 | } 10 | 11 | try { 12 | const recipesRes = await apiClient(cookies.getAll()).get(`/recipes?page=${page}&tags=[]`); 13 | return { 14 | recipes: recipesRes.data as IPaginated 15 | }; 16 | } catch (e) { 17 | console.log(e); 18 | throw new Error('Error loading recipes'); 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /src/routes/(app)/tags/untagged/+page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | Untagged - Tags - {env.PUBLIC_MAIN_TITLE} 12 | 13 | 14 |
15 |
16 |
17 |

Untagged

18 |
{data.recipes.total} recipes
19 |
20 |
21 | 22 | {#if !data.recipes.total} 23 |

No recipes

24 | {/if} 25 | 26 |
27 | {#each data.recipes.data as recipe} 28 | 29 | {/each} 30 |
31 | 32 | {#if data.recipes.total} 33 |
34 | 39 |
40 | {/if} 41 |
42 | -------------------------------------------------------------------------------- /src/routes/(auth)/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | import type { LayoutServerLoad } from './$types'; 3 | 4 | export const load: LayoutServerLoad = ({ locals }) => { 5 | if (locals.user) { 6 | redirect(307, '/'); 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /src/routes/(auth)/+layout.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | {#if env.PUBLIC_MOCK_INSTANCE === 'yes'} 7 |
8 | DEMO MODE - Create operations are disabled 9 |
10 | {/if} 11 | 12 |
13 |
14 |
15 | 25 |
26 | 27 |
28 |
29 |
30 |
31 | -------------------------------------------------------------------------------- /src/routes/(auth)/auth/email-verify/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { getErrorMessage } from '$lib/errors'; 2 | import { apiClientUnauthed } from '$lib/server/api/client.js'; 3 | import type { IEnhanceFailRes, IEnhanceRes } from '$lib/types'; 4 | import { fail } from '@sveltejs/kit'; 5 | 6 | export const actions = { 7 | default: async ({ request }) => { 8 | const data = await request.formData(); 9 | const token = data.get('token') as string; 10 | 11 | const failObj: IEnhanceFailRes = { 12 | inputs: { token }, 13 | errors: {}, 14 | message: '', 15 | messageType: 'error' 16 | }; 17 | 18 | if (!token) { 19 | failObj.message = 'Token is empty.'; 20 | return fail(400, failObj); 21 | } 22 | 23 | try { 24 | await apiClientUnauthed.post('/auth/email-verify', { 25 | token 26 | }); 27 | } catch (e) { 28 | console.log(e); 29 | failObj.message = getErrorMessage(e); 30 | return fail(500, failObj); 31 | } 32 | 33 | const successObj: IEnhanceRes = { 34 | message: 'Email verified, you can now login.', 35 | messageType: 'success' 36 | }; 37 | 38 | return successObj; 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /src/routes/(auth)/auth/email-verify/+page.svelte: -------------------------------------------------------------------------------- 1 | 17 | 18 | 19 | Verify Email - {env.PUBLIC_MAIN_TITLE} 20 | 21 | 22 |

Verify Email

23 | {#if form?.message} 24 |
25 | 26 | {form?.message} 27 | 28 |
29 | {/if} 30 |
31 | 32 | 37 |
38 |

39 | Please click here to login. 40 |

41 | -------------------------------------------------------------------------------- /src/routes/(auth)/auth/forgot-password/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { getErrorMessage } from '$lib/errors'; 2 | import { apiClientUnauthed } from '$lib/server/api/client.js'; 3 | import type { IEnhanceFailRes, IEnhanceRes } from '$lib/types'; 4 | import { fail } from '@sveltejs/kit'; 5 | 6 | export const actions = { 7 | default: async ({ request }) => { 8 | const data = await request.formData(); 9 | const email = data.get('email') as string; 10 | 11 | const failObj: IEnhanceFailRes = { 12 | inputs: { email }, 13 | errors: {}, 14 | message: '', 15 | messageType: 'error' 16 | }; 17 | 18 | if (!email) { 19 | failObj.message = 'Email is empty.'; 20 | return fail(400, failObj); 21 | } 22 | 23 | try { 24 | await apiClientUnauthed.post('/auth/forgot-password', { 25 | email 26 | }); 27 | } catch (e) { 28 | console.log(e); 29 | failObj.message = getErrorMessage(e); 30 | return fail(500, failObj); 31 | } 32 | 33 | const successObj: IEnhanceRes = { 34 | message: 'Password reset link sent, please check your email.', 35 | messageType: 'success' 36 | }; 37 | 38 | return successObj; 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /src/routes/(auth)/auth/forgot-password/+page.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | Forgot Password - {env.PUBLIC_MAIN_TITLE} 12 | 13 | 14 |

Forgot Password

15 |

16 | Please enter your email and you'll receive an email with a link to reset your password. 17 |

18 | {#if form?.message} 19 |
20 | 21 | {form?.message} 22 | 23 |
24 | {/if} 25 |
26 |
27 | 28 | 36 | {#if form?.errors?.email} 37 |
{form?.errors?.email}
38 | {/if} 39 |
40 |
41 | 46 |
47 |
48 | -------------------------------------------------------------------------------- /src/routes/(auth)/auth/login/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { env } from '$env/dynamic/private'; 2 | import { apiClientUnauthed } from '$lib/server/api/client.js'; 3 | import type { IAPIError, IEnhanceFailRes, IEnhanceRes } from '$lib/types'; 4 | import { fail, redirect } from '@sveltejs/kit'; 5 | import axios, { AxiosError } from 'axios'; 6 | import { getErrorMessage } from '$lib/errors'; 7 | import type { Actions } from './$types'; 8 | 9 | export const actions = { 10 | login: async ({ request, cookies }) => { 11 | const data = await request.formData(); 12 | const email = data.get('email') as string; 13 | const password = data.get('password') as string; 14 | const gotoPath = data.get('goto') as string; 15 | 16 | const failObj: IEnhanceFailRes = { inputs: { email }, errors: {} }; 17 | 18 | if (!email) { 19 | failObj.errors.email = 'Email is empty'; 20 | } 21 | 22 | if (!password || password.length < parseInt(env.PASSWORD_MIN_LENGTH, 10)) { 23 | failObj.errors.password = 'Password is too short'; 24 | } 25 | 26 | if (Object.keys(failObj.errors).length) { 27 | return fail(400, failObj); 28 | } 29 | 30 | try { 31 | const res = await apiClientUnauthed.post('/auth/login', { 32 | email, 33 | password 34 | }); 35 | cookies.set(env.COOKIE_ACCESS_TOKEN, res.data.accessToken, { 36 | path: '/', 37 | maxAge: parseInt(env.COOKIE_ACCESS_TOKEN_EXPIRE_SEC, 10) 38 | }); 39 | cookies.set(env.COOKIE_REFRESH_TOKEN, res.data.refreshToken, { 40 | path: '/', 41 | maxAge: parseInt(env.COOKIE_REFRESH_TOKEN_EXPIRE_SEC, 10) 42 | }); 43 | } catch (e) { 44 | console.log(e); 45 | failObj.messageType = 'error'; 46 | failObj.message = 47 | 'There was an error logging in, please check if email and password are correct.'; 48 | 49 | if (axios.isAxiosError(e)) { 50 | if ( 51 | ((e as AxiosError)?.response?.data.message || '').startsWith( 52 | 'Email not verified' 53 | ) 54 | ) { 55 | failObj.messageType = 'warning'; 56 | failObj.message = (e as AxiosError)?.response?.data.message; 57 | } 58 | } 59 | 60 | return fail(500, failObj); 61 | } 62 | 63 | return redirect(303, gotoPath || '/categories'); 64 | }, 65 | verify: async ({ request }) => { 66 | const data = await request.formData(); 67 | const email = data.get('email') as string; 68 | 69 | const failObj: IEnhanceFailRes = { 70 | inputs: { email }, 71 | errors: {}, 72 | message: '', 73 | messageType: 'error' 74 | }; 75 | 76 | if (!email) { 77 | failObj.message = 'Verify email is empty.'; 78 | return fail(400, failObj); 79 | } 80 | 81 | try { 82 | await apiClientUnauthed.post('/auth/email-verify-resend', { 83 | email 84 | }); 85 | } catch (e) { 86 | console.log(e); 87 | failObj.message = getErrorMessage(e); 88 | return fail(500, failObj); 89 | } 90 | 91 | const successObj: IEnhanceRes = { 92 | message: 'Verify email sent, please check your inbox.', 93 | messageType: 'success' 94 | }; 95 | 96 | return successObj; 97 | } 98 | } satisfies Actions; 99 | -------------------------------------------------------------------------------- /src/routes/(auth)/auth/login/+page.svelte: -------------------------------------------------------------------------------- 1 | 15 | 16 | 17 | Login - {env.PUBLIC_MAIN_TITLE} 18 | 19 | 20 |

Login

21 | 22 | {#if env.PUBLIC_MOCK_INSTANCE === 'yes'} 23 |
24 | For the demo, please login using the email demo@example.com with the password 25 | secret. 26 |
27 | {/if} 28 | 29 | {#if env.PUBLIC_ENABLE_GOOGLE_OAUTH === 'true'} 30 | 39 | {/if} 40 | 41 | {#if form?.message} 42 |
43 | 44 | {#if form?.message.startsWith('Email not verified')} 45 | Email not verified, please check your email for a link to verify. Didn't receive a verify 46 | email? Please 47 | to resend. 54 | {:else} 55 | {form?.message} 56 | {/if} 57 | 58 |
59 | {/if} 60 | 61 |
{ 65 | submitting = true; 66 | return async ({ update }) => { 67 | await update(); 68 | submitting = false; 69 | }; 70 | }} 71 | > 72 | 73 |
74 | 75 | 83 | {#if form?.errors?.email} 84 |
{form?.errors?.email}
85 | {/if} 86 |
87 |
88 | 89 | 96 | {#if form?.errors?.password} 97 |
{form?.errors?.password}
98 | {/if} 99 |
100 |
101 | 108 |
109 |
110 |

111 | Not registered? Please click here register. 114 |

115 |

116 | Forgot password? Please click here to reset your password. 119 |

120 | 121 |
122 | 123 |
124 | -------------------------------------------------------------------------------- /src/routes/(auth)/auth/oauth/callback/+page.server.ts: -------------------------------------------------------------------------------- 1 | import type { IAccessRefreshToken, IEnhanceFailRes } from '$lib/types'; 2 | import { fail, redirect } from '@sveltejs/kit'; 3 | import type { Actions, PageServerLoad } from './$types'; 4 | import { apiClientUnauthed } from '$lib/server/api/client'; 5 | import { env } from '$env/dynamic/private'; 6 | 7 | export const load: PageServerLoad = async ({ url }) => { 8 | const state = url.searchParams.get('state'); 9 | 10 | if (!state) { 11 | return; 12 | } 13 | }; 14 | 15 | export const actions = { 16 | default: async ({ request, cookies }) => { 17 | const data = await request.formData(); 18 | const state = data.get('state') as string; 19 | 20 | const failObj: IEnhanceFailRes = { inputs: { state }, errors: {} }; 21 | 22 | if (!state) { 23 | failObj.messageType = 'error'; 24 | failObj.message = 'State is empty'; 25 | failObj.errors.state = 'State is empty'; 26 | } 27 | 28 | if (Object.keys(failObj.errors).length) { 29 | return fail(400, failObj); 30 | } 31 | 32 | try { 33 | const res = await apiClientUnauthed.post('/auth/oauth/exchange-token', { 34 | state 35 | }); 36 | cookies.set(env.COOKIE_ACCESS_TOKEN, res.data.accessToken, { 37 | path: '/', 38 | maxAge: parseInt(env.COOKIE_ACCESS_TOKEN_EXPIRE_SEC, 10) 39 | }); 40 | cookies.set(env.COOKIE_REFRESH_TOKEN, res.data.refreshToken, { 41 | path: '/', 42 | maxAge: parseInt(env.COOKIE_REFRESH_TOKEN_EXPIRE_SEC, 10) 43 | }); 44 | } catch (e) { 45 | console.log(e); 46 | failObj.messageType = 'error'; 47 | failObj.message = 'There was an error exchanging tokens, please try logging in again.'; 48 | return fail(500, failObj); 49 | } 50 | 51 | return redirect(303, '/categories'); 52 | } 53 | } satisfies Actions; 54 | -------------------------------------------------------------------------------- /src/routes/(auth)/auth/oauth/callback/+page.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 20 | OAuth Callback - {env.PUBLIC_MAIN_TITLE} 21 | 22 | 23 |

OAuth Callback

24 | 25 | {#if form?.message} 26 |
27 | 28 | {form?.message} 29 | 30 |
31 | {/if} 32 | 33 |
34 | 35 |
36 | 37 |
38 | 39 | 44 |
45 | -------------------------------------------------------------------------------- /src/routes/(auth)/auth/register/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { env } from '$env/dynamic/private'; 2 | import { env as envPublic } from '$env/dynamic/public'; 3 | import { getErrorMessage } from '$lib/errors'; 4 | import { apiClientUnauthed } from '$lib/server/api/client.js'; 5 | import type { IEnhanceFailRes, IEnhanceRes } from '$lib/types'; 6 | import { fail } from '@sveltejs/kit'; 7 | 8 | export const actions = { 9 | default: async ({ request }) => { 10 | const data = await request.formData(); 11 | const name = data.get('name') as string; 12 | const email = data.get('email') as string; 13 | const password = data.get('password') as string; 14 | 15 | const failObj: IEnhanceFailRes = { inputs: { name, email }, errors: {} }; 16 | 17 | if (!name) { 18 | failObj.errors.name = 'Name is empty'; 19 | } 20 | 21 | if (!email) { 22 | failObj.errors.email = 'Email is empty'; 23 | } 24 | 25 | if (!password || password.length < parseInt(env.PASSWORD_MIN_LENGTH, 10)) { 26 | failObj.errors.password = 'Password is too short'; 27 | } 28 | 29 | if (Object.keys(failObj.errors).length) { 30 | return fail(400, failObj); 31 | } 32 | 33 | try { 34 | await apiClientUnauthed.post('/auth/register', { 35 | name, 36 | email, 37 | password 38 | }); 39 | } catch (e) { 40 | console.log(e); 41 | failObj.messageType = 'error'; 42 | failObj.message = getErrorMessage(e); 43 | return fail(500, failObj); 44 | } 45 | 46 | const successObj: IEnhanceRes = { 47 | message: `You have been registered! ${envPublic.PUBLIC_EMAIL_VERIFY_ENABLED === 'true' ? 'Please verify your email before logging in.' : 'You can now log in.'}`, 48 | messageType: 'success' 49 | }; 50 | 51 | return successObj; 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /src/routes/(auth)/auth/register/+page.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | Register - {env.PUBLIC_MAIN_TITLE} 15 | 16 | 17 |

Register

18 | 19 | {#if env.PUBLIC_ENABLE_GOOGLE_OAUTH === 'true'} 20 | 29 | {/if} 30 | 31 | {#if form?.message} 32 |
33 | 34 | {form?.message} 35 | 36 |
37 | {/if} 38 | 39 |
{ 42 | submitting = true; 43 | return async ({ update }) => { 44 | await update(); 45 | submitting = false; 46 | }; 47 | }} 48 | > 49 |
50 | 51 | 59 | {#if form?.errors?.name} 60 |
{form?.errors?.name}
61 | {/if} 62 |
63 |
64 | 65 | 73 | {#if form?.errors?.email} 74 |
{form?.errors?.email}
75 | {/if} 76 |
77 |
78 | 79 | 86 | {#if form?.errors?.password} 87 |
{form?.errors?.password}
88 | {/if} 89 |
90 |
91 | 97 |
98 |
99 |

100 | Already registered? Please click here to login. 103 |

104 | -------------------------------------------------------------------------------- /src/routes/(auth)/auth/reset-password/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { env } from '$env/dynamic/private'; 2 | import { getErrorMessage } from '$lib/errors'; 3 | import { apiClientUnauthed } from '$lib/server/api/client.js'; 4 | import type { IEnhanceFailRes, IEnhanceRes } from '$lib/types'; 5 | import { fail } from '@sveltejs/kit'; 6 | 7 | export const actions = { 8 | default: async ({ request }) => { 9 | const data = await request.formData(); 10 | const token = data.get('token') as string; 11 | const password = data.get('password') as string; 12 | 13 | const failObj: IEnhanceFailRes = { inputs: {}, errors: {} }; 14 | 15 | if (!token) { 16 | failObj.messageType = 'error'; 17 | failObj.message = 'Token is empty.'; 18 | failObj.errors.token = 'Token is empty'; 19 | } 20 | 21 | if (!password || password.length < parseInt(env.PASSWORD_MIN_LENGTH, 10)) { 22 | failObj.errors.password = 'Password is too short'; 23 | } 24 | 25 | if (Object.keys(failObj.errors).length) { 26 | return fail(400, failObj); 27 | } 28 | 29 | try { 30 | await apiClientUnauthed.post('/auth/reset-password', { 31 | token, 32 | password 33 | }); 34 | } catch (e) { 35 | console.log(e); 36 | failObj.messageType = 'error'; 37 | failObj.message = getErrorMessage(e); 38 | return fail(500, failObj); 39 | } 40 | 41 | const successObj: IEnhanceRes = { 42 | message: 'Password changed, please login with your new password.', 43 | messageType: 'success' 44 | }; 45 | 46 | return successObj; 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /src/routes/(auth)/auth/reset-password/+page.svelte: -------------------------------------------------------------------------------- 1 | 10 | 11 | 12 | Reset Password - {env.PUBLIC_MAIN_TITLE} 13 | 14 | 15 |

Reset Password

16 |

Please choose a new password.

17 | {#if form?.message} 18 |
19 | 20 | {form?.message} 21 | 22 |
23 | {/if} 24 |
25 | 26 |
27 | 28 | 35 | {#if form?.errors?.password} 36 |
{form?.errors?.password}
37 | {/if} 38 |
39 |
40 | 45 |
46 |
47 |

48 | Please click here to login. 49 |

50 | -------------------------------------------------------------------------------- /src/routes/(marketing)/+layout.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | {#if env.PUBLIC_MOCK_INSTANCE === 'yes'} 7 |
8 | DEMO MODE - Create operations are disabled 9 |
10 | {/if} 11 | 12 | 35 | 36 |
37 | 38 |
39 | 40 | 90 | -------------------------------------------------------------------------------- /src/routes/(marketing)/privacy-policy/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | Privacy Policy - {env.PUBLIC_MAIN_TITLE} 7 | 8 | 9 |
10 |
11 |

Privacy Policy

12 | 13 |

14 | At ManageMeals, we are committed to protecting your privacy. This Privacy Policy explains how 15 | we collect, use, and disclose information about you when you use our recipe manager web 16 | application. 17 |

18 | 19 |
20 |

Information We Collect

21 |

22 | Personal Information: When you create an account with us, 23 | we collect personal information such as your name, email address, and any other information 24 | you choose to provide. 25 |

26 |

27 | Usage Information: We automatically collect information 28 | about how you use our application, such as the recipes you view, save, or share, and your 29 | interactions with our features. 30 |

31 |

32 | Device Information: We may collect information about the 33 | device you use to access our application, including the type of device, operating system, 34 | and unique device identifiers. 35 |

36 |
37 | 38 |
39 |

How We Use Your Information

40 |

We use the information we collect to:

41 |
    42 |
  • Provide, maintain, and improve our recipe manager application
  • 43 |
  • Personalize your experience and deliver relevant content and recommendations
  • 44 |
  • Communicate with you about your account, updates, and promotional offers
  • 45 |
  • Analyze usage trends and gather statistical data to improve our application
  • 46 |
  • Prevent fraud, enforce our policies, and comply with legal obligations
  • 47 |
48 |
49 | 50 |
51 |

Information Sharing and Disclosure

52 |

53 | We do not sell, trade, or rent your personal information to third parties. However, we may 54 | share your information with trusted third-party service providers who assist us in operating 55 | our application, conducting business activities, or providing services on our behalf. 56 |

57 |

58 | We may also disclose your information if required by law, to protect our rights or the 59 | rights of others, or in connection with a merger, acquisition, or sale of our assets. 60 |

61 |
62 | 63 |
64 |

Data Security

65 |

66 | We implement reasonable security measures to protect your personal information from 67 | unauthorized access, use, or disclosure. However, no method of transmission over the 68 | Internet or electronic storage is 100% secure. 69 |

70 |
71 | 72 |
73 |

Your Choices and Rights

74 |

75 | You can review, update, or delete your personal information by logging into your account. 76 | You may also opt-out of receiving promotional communications from us by following the 77 | unsubscribe instructions provided in those communications. 78 |

79 |
80 | 81 |
82 |

Changes to This Privacy Policy

83 |

84 | We may update this Privacy Policy from time to time to reflect changes in our practices or 85 | for other operational, legal, or regulatory reasons. We will notify you of any material 86 | changes by posting the updated Privacy Policy on our website. 87 |

88 |
89 | 90 |
91 |

Contact

92 |

93 | If you have any questions about the Privacy Policy, please contact us at support@managemeals.com. 97 |

98 |
99 |
100 |
101 | -------------------------------------------------------------------------------- /src/routes/(marketing)/terms-and-conditions/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | Terms & Conditions - {env.PUBLIC_MAIN_TITLE} 7 | 8 | 9 |
10 |
11 |

Terms & Conditions

12 | 13 |

14 | Welcome to ManageMeals. By accessing or using our website and services, you agree to be bound 15 | by these terms and conditions and our Privacy Policy. Please read them carefully. 16 |

17 | 18 |
19 |

Acceptance of Terms

20 |

21 | By using our services, you accept and agree to be bound by these Terms and Conditions. If 22 | you do not agree to these terms, you may not use our services. 23 |

24 |
25 | 26 |
27 |

Description of Services

28 |

29 | ManageMeals provides an online platform for users to create, store, organize, and share 30 | recipes. Our services may change from time to time without notice. 31 |

32 |
33 | 34 |
35 |

User Accounts

36 |

37 | You must create an account to access certain features of our services. You are responsible 38 | for maintaining the confidentiality of your account credentials and for restricting access 39 | to your account. You agree to accept responsibility for all activities that occur under your 40 | account. 41 |

42 |
43 | 44 |
45 |

User Content

46 |

47 | Our services allow you to upload, submit, store, send, receive and share content, including 48 | recipes, photos, and other materials. You accept resposibility for all content posted to 49 | your account, and agree not to use ManageMeals as a means to store or distribute illegal 50 | material. 51 |

52 |
53 | 54 |
55 |

Prohibited Conduct

56 |

57 | You agree not to use our services for any unlawful purpose or to upload or share any content 58 | that is defamatory, harassing, obscene, or otherwise objectionable. We reserve the right to 59 | remove any content that violates these terms. 60 |

61 |
62 | 63 |
64 |

Disclaimers and Limitation of Liability

65 |

66 | Our services are provided on an "as is" and "as available" basis. We disclaim all 67 | warranties, express or implied, including warranties of merchantability, fitness for a 68 | particular purpose, and non-infringement. We will not be liable for any indirect, 69 | incidental, special, or consequential damages arising out of or relating to your use of our 70 | services. 71 |

72 |
73 | 74 |
75 |

Termination

76 |

77 | We reserve the right to terminate or suspend your access to our services at any time, with 78 | or without cause. 79 |

80 |
81 | 82 |
83 |

Changes to Terms

84 |

85 | We may revise these Terms and Conditions from time to time. By continuing to use our 86 | services after any changes, you accept the revised terms. If you have any questions about 87 | these Terms and Conditions, please contact us at support@managemeals.com. 91 |

92 |
93 |
94 |
95 | -------------------------------------------------------------------------------- /src/routes/+error.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | {$page.status} - {env.PUBLIC_MAIN_TITLE} 9 | 10 | 11 | 19 | 20 |
21 |

{$page.status}

22 |

23 | {$page.error?.message || ''} 24 |

25 |
26 | -------------------------------------------------------------------------------- /src/routes/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import type { LayoutServerLoad } from './$types'; 2 | 3 | export const load: LayoutServerLoad = ({ locals }) => { 4 | return { 5 | user: locals.user 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | {#if env.PUBLIC_UMAMI_ANALYTICS_ENABLED === 'true'} 9 | 16 | {/if} 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/routes/api/meal-plans/latest/+server.ts: -------------------------------------------------------------------------------- 1 | import apiClient from '$lib/server/api/client'; 2 | import { json } from '@sveltejs/kit'; 3 | import type { RequestHandler } from './$types'; 4 | import type { IMealPlan } from '$lib/types'; 5 | 6 | export const GET: RequestHandler = async ({ cookies }) => { 7 | try { 8 | const res = await apiClient(cookies.getAll()).get('/meal-plans/latest'); 9 | return json(res.data as IMealPlan); 10 | } catch (e) { 11 | console.log(e); 12 | throw new Error('Error getting latest meal plan'); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/routes/api/recipes/[slug]/shares/+server.ts: -------------------------------------------------------------------------------- 1 | import { json } from '@sveltejs/kit'; 2 | import type { RequestHandler } from './$types'; 3 | import apiClient from '$lib/server/api/client'; 4 | import type { IShareRecipe } from '$lib/types'; 5 | 6 | export const GET: RequestHandler = async ({ cookies, params }) => { 7 | const { slug } = params; 8 | 9 | try { 10 | const res = await apiClient(cookies.getAll()).get(`/recipes/${slug}/shares`); 11 | return json(res.data as IShareRecipe[]); 12 | } catch (e) { 13 | console.log(e); 14 | throw new Error('Error getting share recipes'); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/routes/api/search/+server.ts: -------------------------------------------------------------------------------- 1 | import { error, json } from '@sveltejs/kit'; 2 | import type { RequestHandler } from './$types'; 3 | import apiClient from '$lib/server/api/client'; 4 | import type { ISearch, ISearchRecipe } from '$lib/types'; 5 | 6 | export const GET: RequestHandler = async ({ cookies, url }) => { 7 | const q = url.searchParams.get('q'); 8 | if (!q) { 9 | throw error(400, 'Invalid query'); 10 | } 11 | 12 | try { 13 | const searchRes = await apiClient(cookies.getAll()).get(`/search?q=${q}&c=recipes&p=1`); 14 | return json(searchRes.data as ISearch); 15 | } catch (e) { 16 | console.log(e); 17 | throw new Error('Error searching'); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /src/routes/infra/health/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { env } from '$env/dynamic/private'; 2 | import { getLbShutdown } from '$lib/server/infra/health'; 3 | import { error } from '@sveltejs/kit'; 4 | import type { PageServerLoad } from './$types'; 5 | 6 | export const load: PageServerLoad = async ({ url }) => { 7 | const key = url.searchParams.get('key'); 8 | if (!key || key !== env.INFRA_ENDPOINT_KEY) { 9 | throw error(403, 'Invalid infra key'); 10 | } 11 | 12 | const shutdown = getLbShutdown(); 13 | if (shutdown) { 14 | throw error(503, 'Shutdown'); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /src/routes/infra/health/+page.svelte: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/managemeals/manage-meals-web/9ae9e6cbdcae94faa50460e26d6af06a42a1e927/src/routes/infra/health/+page.svelte -------------------------------------------------------------------------------- /src/routes/infra/shutdown/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { env } from '$env/dynamic/private'; 2 | import { setLbShutdown } from '$lib/server/infra/health'; 3 | import { error } from '@sveltejs/kit'; 4 | import type { PageServerLoad } from './$types'; 5 | 6 | export const load: PageServerLoad = async ({ url }) => { 7 | const key = url.searchParams.get('key'); 8 | if (!key || key !== env.INFRA_ENDPOINT_KEY) { 9 | throw error(403, 'Invalid infra key'); 10 | } 11 | 12 | setLbShutdown(true); 13 | }; 14 | -------------------------------------------------------------------------------- /src/routes/infra/shutdown/+page.svelte: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/managemeals/manage-meals-web/9ae9e6cbdcae94faa50460e26d6af06a42a1e927/src/routes/infra/shutdown/+page.svelte -------------------------------------------------------------------------------- /src/routes/infra/startup/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { env } from '$env/dynamic/private'; 2 | import { setLbShutdown } from '$lib/server/infra/health'; 3 | import { error } from '@sveltejs/kit'; 4 | import type { PageServerLoad } from './$types'; 5 | 6 | export const load: PageServerLoad = async ({ url }) => { 7 | const key = url.searchParams.get('key'); 8 | if (!key || key !== env.INFRA_ENDPOINT_KEY) { 9 | throw error(403, 'Invalid infra key'); 10 | } 11 | 12 | setLbShutdown(false); 13 | }; 14 | -------------------------------------------------------------------------------- /src/routes/infra/startup/+page.svelte: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/managemeals/manage-meals-web/9ae9e6cbdcae94faa50460e26d6af06a42a1e927/src/routes/infra/startup/+page.svelte -------------------------------------------------------------------------------- /src/routes/logout/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | import type { PageServerLoad } from './$types'; 3 | import { env } from '$env/dynamic/private'; 4 | 5 | export const load: PageServerLoad = async ({ cookies }) => { 6 | cookies.delete(env.COOKIE_ACCESS_TOKEN, { path: '/' }); 7 | cookies.delete(env.COOKIE_REFRESH_TOKEN, { path: '/' }); 8 | 9 | redirect(307, '/auth/login'); 10 | }; 11 | -------------------------------------------------------------------------------- /src/routes/logout/+page.svelte: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/managemeals/manage-meals-web/9ae9e6cbdcae94faa50460e26d6af06a42a1e927/src/routes/logout/+page.svelte -------------------------------------------------------------------------------- /static/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/managemeals/manage-meals-web/9ae9e6cbdcae94faa50460e26d6af06a42a1e927/static/android-chrome-192x192.png -------------------------------------------------------------------------------- /static/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/managemeals/manage-meals-web/9ae9e6cbdcae94faa50460e26d6af06a42a1e927/static/android-chrome-512x512.png -------------------------------------------------------------------------------- /static/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/managemeals/manage-meals-web/9ae9e6cbdcae94faa50460e26d6af06a42a1e927/static/apple-touch-icon.png -------------------------------------------------------------------------------- /static/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/managemeals/manage-meals-web/9ae9e6cbdcae94faa50460e26d6af06a42a1e927/static/favicon-16x16.png -------------------------------------------------------------------------------- /static/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/managemeals/manage-meals-web/9ae9e6cbdcae94faa50460e26d6af06a42a1e927/static/favicon-32x32.png -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/managemeals/manage-meals-web/9ae9e6cbdcae94faa50460e26d6af06a42a1e927/static/favicon.ico -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/managemeals/manage-meals-web/9ae9e6cbdcae94faa50460e26d6af06a42a1e927/static/favicon.png -------------------------------------------------------------------------------- /static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /static/site.webmanifest: -------------------------------------------------------------------------------- 1 | {"name":"","short_name":"","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"} -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | // import adapter from '@sveltejs/adapter-auto'; 2 | import adapter from '@sveltejs/adapter-node'; 3 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 4 | 5 | /** @type {import('@sveltejs/kit').Config} */ 6 | const config = { 7 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 8 | // for more information about preprocessors 9 | preprocess: vitePreprocess(), 10 | 11 | kit: { 12 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 13 | // If your environment is not supported or you settled on a specific environment, switch out the adapter. 14 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 15 | adapter: adapter() 16 | } 17 | }; 18 | 19 | export default config; 20 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./src/**/*.{html,js,svelte,ts}'], 4 | theme: { 5 | extend: {} 6 | }, 7 | plugins: [] 8 | }; 9 | -------------------------------------------------------------------------------- /tests/test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from '@playwright/test'; 2 | 3 | test('index page has expected h1', async ({ page }) => { 4 | await page.goto('/'); 5 | await expect(page.getByRole('heading', { name: 'Welcome to SvelteKit' })).toBeVisible(); 6 | }); 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "bundler", 13 | "types": ["unplugin-icons/types/svelte"] 14 | } 15 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 16 | // 17 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 18 | // from the referenced tsconfig.json - TypeScript does not merge them in 19 | } 20 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vitest/config'; 3 | import Icons from 'unplugin-icons/vite'; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | sveltekit(), 8 | Icons({ 9 | compiler: 'svelte' 10 | }) 11 | ], 12 | test: { 13 | include: ['src/**/*.{test,spec}.{js,ts}'] 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | 4 | WAITFORIT_cmdname=${0##*/} 5 | 6 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 7 | 8 | usage() 9 | { 10 | cat << USAGE >&2 11 | Usage: 12 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 13 | -h HOST | --host=HOST Host or IP under test 14 | -p PORT | --port=PORT TCP port under test 15 | Alternatively, you specify the host and port as host:port 16 | -s | --strict Only execute subcommand if the test succeeds 17 | -q | --quiet Don't output any status messages 18 | -t TIMEOUT | --timeout=TIMEOUT 19 | Timeout in seconds, zero for no timeout 20 | -- COMMAND ARGS Execute command with args after the test finishes 21 | USAGE 22 | exit 1 23 | } 24 | 25 | wait_for() 26 | { 27 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 28 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 29 | else 30 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 31 | fi 32 | WAITFORIT_start_ts=$(date +%s) 33 | while : 34 | do 35 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 36 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 37 | WAITFORIT_result=$? 38 | else 39 | (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 40 | WAITFORIT_result=$? 41 | fi 42 | if [[ $WAITFORIT_result -eq 0 ]]; then 43 | WAITFORIT_end_ts=$(date +%s) 44 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 45 | break 46 | fi 47 | sleep 1 48 | done 49 | return $WAITFORIT_result 50 | } 51 | 52 | wait_for_wrapper() 53 | { 54 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 55 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 56 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 57 | else 58 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 59 | fi 60 | WAITFORIT_PID=$! 61 | trap "kill -INT -$WAITFORIT_PID" INT 62 | wait $WAITFORIT_PID 63 | WAITFORIT_RESULT=$? 64 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 65 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 66 | fi 67 | return $WAITFORIT_RESULT 68 | } 69 | 70 | # process arguments 71 | while [[ $# -gt 0 ]] 72 | do 73 | case "$1" in 74 | *:* ) 75 | WAITFORIT_hostport=(${1//:/ }) 76 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 77 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 78 | shift 1 79 | ;; 80 | --child) 81 | WAITFORIT_CHILD=1 82 | shift 1 83 | ;; 84 | -q | --quiet) 85 | WAITFORIT_QUIET=1 86 | shift 1 87 | ;; 88 | -s | --strict) 89 | WAITFORIT_STRICT=1 90 | shift 1 91 | ;; 92 | -h) 93 | WAITFORIT_HOST="$2" 94 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 95 | shift 2 96 | ;; 97 | --host=*) 98 | WAITFORIT_HOST="${1#*=}" 99 | shift 1 100 | ;; 101 | -p) 102 | WAITFORIT_PORT="$2" 103 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 104 | shift 2 105 | ;; 106 | --port=*) 107 | WAITFORIT_PORT="${1#*=}" 108 | shift 1 109 | ;; 110 | -t) 111 | WAITFORIT_TIMEOUT="$2" 112 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 113 | shift 2 114 | ;; 115 | --timeout=*) 116 | WAITFORIT_TIMEOUT="${1#*=}" 117 | shift 1 118 | ;; 119 | --) 120 | shift 121 | WAITFORIT_CLI=("$@") 122 | break 123 | ;; 124 | --help) 125 | usage 126 | ;; 127 | *) 128 | echoerr "Unknown argument: $1" 129 | usage 130 | ;; 131 | esac 132 | done 133 | 134 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 135 | echoerr "Error: you need to provide a host and port to test." 136 | usage 137 | fi 138 | 139 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} 140 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 141 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 142 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 143 | 144 | # Check to see if timeout is from busybox? 145 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 146 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 147 | 148 | WAITFORIT_BUSYTIMEFLAG="" 149 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 150 | WAITFORIT_ISBUSY=1 151 | # Check if busybox timeout uses -t flag 152 | # (recent Alpine versions don't support -t anymore) 153 | if timeout &>/dev/stdout | grep -q -e '-t '; then 154 | WAITFORIT_BUSYTIMEFLAG="-t" 155 | fi 156 | else 157 | WAITFORIT_ISBUSY=0 158 | fi 159 | 160 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 161 | wait_for 162 | WAITFORIT_RESULT=$? 163 | exit $WAITFORIT_RESULT 164 | else 165 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 166 | wait_for_wrapper 167 | WAITFORIT_RESULT=$? 168 | else 169 | wait_for 170 | WAITFORIT_RESULT=$? 171 | fi 172 | fi 173 | 174 | if [[ $WAITFORIT_CLI != "" ]]; then 175 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 176 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 177 | exit $WAITFORIT_RESULT 178 | fi 179 | exec "${WAITFORIT_CLI[@]}" 180 | else 181 | exit $WAITFORIT_RESULT 182 | fi 183 | --------------------------------------------------------------------------------