├── .eslintrc.js ├── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── docs ├── backups.md └── data-model.md ├── functions ├── _shared │ ├── fields.js │ └── headers.js ├── api-methods │ ├── create.js │ ├── delete.js │ ├── index.js │ ├── readUser.js │ └── update.js ├── api.js ├── backup.js ├── read-all.js ├── read.js └── sitemap.js ├── index.html ├── netlify.toml ├── package-lock.json ├── package.json ├── postcss.config.js ├── prettier.config.js ├── public ├── _redirects ├── ads.txt ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-180x180-solid.png ├── browserconfig.xml ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── img │ ├── blurred.png │ ├── contribute_on_codeberg.png │ ├── duration.svg │ ├── filter │ │ ├── bread.svg │ │ ├── dessert.svg │ │ ├── drink.svg │ │ ├── keto.svg │ │ ├── main.svg │ │ ├── none.svg │ │ ├── other.svg │ │ ├── pastry.svg │ │ ├── salad.svg │ │ ├── snack.svg │ │ ├── soup.svg │ │ ├── vegan.svg │ │ └── vegetarian.svg │ ├── happy.svg │ ├── loading.svg │ ├── logo.svg │ ├── portions.svg │ ├── sad.svg │ ├── sahar.svg │ └── tom.svg ├── mstile-144x144.png ├── mstile-150x150.png ├── mstile-310x150.png ├── mstile-310x310.png ├── mstile-70x70.png ├── robots.txt ├── safari-pinned-tab.svg ├── site.webmanifest └── splash-social.jpg ├── src ├── App.vue ├── assets │ └── fonts │ │ ├── nunito-v12-latin-600.woff │ │ ├── nunito-v12-latin-600.woff2 │ │ ├── nunito-v12-latin-700.woff │ │ ├── nunito-v12-latin-700.woff2 │ │ ├── nunito-v12-latin-regular.woff │ │ └── nunito-v12-latin-regular.woff2 ├── components │ ├── Footer.vue │ ├── LazyWrapper.vue │ ├── Navbar.vue │ ├── SearchBar.vue │ ├── button │ │ ├── ButtonCheckbox.vue │ │ ├── ButtonDefault.vue │ │ ├── ButtonDelete.vue │ │ ├── ButtonDuplicate.vue │ │ ├── ButtonFilter.vue │ │ ├── ButtonFilterIcon.vue │ │ ├── ButtonMenu.vue │ │ ├── ButtonShare.vue │ │ ├── ButtonSort.vue │ │ ├── ButtonTop.vue │ │ ├── ButtonUser.vue │ │ └── ButtonX.vue │ ├── conditional │ │ ├── Auth.vue │ │ ├── AuthLogin.vue │ │ ├── AuthSignup.vue │ │ ├── LoadingMessage.vue │ │ ├── MobileMenu.vue │ │ ├── ToastMessage.vue │ │ └── UserMenu.vue │ ├── home │ │ ├── HomeFilterMenu.vue │ │ └── HomeRecipeCard.vue │ ├── icon │ │ ├── IconResolver.vue │ │ ├── calories.vue │ │ ├── duration.vue │ │ ├── filter │ │ │ ├── bread.vue │ │ │ ├── cookies.vue │ │ │ ├── dessert.vue │ │ │ ├── dip.vue │ │ │ ├── drink.vue │ │ │ ├── histamine.vue │ │ │ ├── jam.vue │ │ │ ├── keto.vue │ │ │ ├── lowcal.vue │ │ │ ├── main.vue │ │ │ ├── other.vue │ │ │ ├── pastry.vue │ │ │ ├── salad.vue │ │ │ ├── sauce.vue │ │ │ ├── side.vue │ │ │ ├── snack.vue │ │ │ ├── soup.vue │ │ │ ├── spread.vue │ │ │ ├── vegan.vue │ │ │ └── vegetarian.vue │ │ ├── grip-vertical.vue │ │ ├── happy.vue │ │ ├── hat.vue │ │ ├── loading.vue │ │ ├── portions.vue │ │ └── sad.vue │ ├── input │ │ ├── InputSelect.vue │ │ ├── InputText.vue │ │ └── InputToggle.vue │ ├── recipe │ │ ├── RecipeDiet.vue │ │ ├── RecipeImage.vue │ │ ├── RecipeIngredients.vue │ │ └── readonly │ │ │ ├── RecipeDiet.vue │ │ │ ├── RecipeIngredients.vue │ │ │ ├── RecipeMeta.vue │ │ │ └── RecipeShare.vue │ └── user │ │ ├── UserRecipeCard.vue │ │ └── UserRecipeSorting.vue ├── index.css ├── main.ts ├── router.ts ├── shim.d.ts ├── store │ ├── index.ts │ └── modules │ │ ├── app.js │ │ ├── data.js │ │ └── user.js ├── types.d.ts ├── utils │ ├── index.ts │ ├── useAPI.ts │ ├── useCloudinary.ts │ └── useToken.js └── views │ ├── About.vue │ ├── Home.vue │ ├── Profile.vue │ ├── RecipeEditable.vue │ ├── RecipeReadonly.vue │ ├── Signup.vue │ └── UserRecipes.vue ├── tailwind.config.js ├── tsconfig.json └── vite.config.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: 'vue-eslint-parser', 3 | parserOptions: { 4 | parser: '@typescript-eslint/parser', 5 | ecmaVersion: 2020, 6 | sourceType: 'module', 7 | ecmaFeatures: { 8 | jsx: true 9 | } 10 | }, 11 | extends: [ 12 | 'plugin:vue/vue3-recommended', 13 | 'plugin:@typescript-eslint/recommended', 14 | 'prettier/@typescript-eslint', 15 | 'plugin:prettier/recommended' 16 | ], 17 | rules: { 18 | '@typescript-eslint/ban-ts-ignore': 'off', 19 | '@typescript-eslint/explicit-function-return-type': 'off', 20 | '@typescript-eslint/no-explicit-any': 'off', 21 | '@typescript-eslint/no-var-requires': 'off', 22 | '@typescript-eslint/no-empty-function': 'off', 23 | 'vue/custom-event-name-casing': 'off', 24 | 'no-use-before-define': 'off', 25 | // 'no-use-before-define': [ 26 | // 'error', 27 | // { 28 | // functions: false, 29 | // classes: true, 30 | // }, 31 | // ], 32 | '@typescript-eslint/no-use-before-define': 'off', 33 | // '@typescript-eslint/no-use-before-define': [ 34 | // 'error', 35 | // { 36 | // functions: false, 37 | // classes: true, 38 | // }, 39 | // ], 40 | '@typescript-eslint/ban-ts-comment': 'off', 41 | '@typescript-eslint/ban-types': 'off', 42 | '@typescript-eslint/no-non-null-assertion': 'off', 43 | '@typescript-eslint/explicit-module-boundary-types': 'off', 44 | '@typescript-eslint/no-unused-vars': [ 45 | 'error', 46 | { 47 | argsIgnorePattern: '^h$', 48 | varsIgnorePattern: '^h$' 49 | } 50 | ], 51 | 'no-unused-vars': [ 52 | 'error', 53 | { 54 | argsIgnorePattern: '^h$', 55 | varsIgnorePattern: '^h$' 56 | } 57 | ], 58 | 'space-before-function-paren': 'off', 59 | quotes: ['error', 'single'], 60 | 'comma-dangle': ['error', 'never'] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | dist-ssr 5 | .env 6 | *.local 7 | backup/*.json 8 | 9 | # local env files 10 | .env.local 11 | .env.*.local 12 | 13 | # Log files 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | 18 | # Editor directories and files 19 | .idea 20 | .vscode 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | # Local Netlify folder 28 | .netlify 29 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.16 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## About 2 | 3 | This is the 2021 version of recept0r, a minimal open source recipes app. 4 | 5 | Re-written from scratch using Vue 3 with TypeScript. 6 | 7 | A little background info in an article I wrote: [How I Built a Serverless Recipes App with FaunaDB and Vue.js](https://ttntm.me/blog/serverless-recipes-app-faunadb-vuejs/) (a little outdated in terms of the code samples, but the app's architecture is still the same) 8 | 9 | **IMPORTANT: Fauna EoL'd their database service, and shuts down on May 30, 2025.** 10 | 11 | In response to that, this application has moved to supabase. 12 | 13 | ## Deployment 14 | 15 | If you'd like to fork this repository and deploy your own recipes app: 16 | 17 | 1. Sign up @ Cloudinary, Netlify, and supabase 18 | 2. Create a new database and a table called `recipes` in supabase 19 | - Refer to everything in `./functions` to see what's required or get in touch 20 | 3. Configure necessary environment variables 21 | - `SPB_API_KEY`: supabase API key (Project > Data API > `service_role`) 22 | - `SPB_TABLE`: name of your supabase table (i.e. `recipes`) 23 | - `SPB_URL`: your supabase Project URL 24 | - `VITE_APP_API`: something like `/.netlify/functions/api` 25 | - `VITE_APP_CDNRY`: something like `https://api.cloudinary.com/v1_1/USERNAME/image/upload` 26 | - `VITE_APP_CDNRY_UPRESET`: a short ID generated in Cloudinary 27 | - `VITE_APP_IDENTITY`: an absolute URL to your site's identity endpoint like `https://your.domain/.netlify/identity` 28 | - `VITE_APP_READ`: path to the public "read" function `/.netlify/functions/read` 29 | - `VITE_APP_READALL`: path to the public "readAll" function `/.netlify/functions/read-all` 30 | 4. Build and deploy your instance 31 | 32 | ### User Accounts 33 | 34 | **Public=anonymous user signup is disabled by default.** 35 | 36 | If you want to enable that, you've got to import and use the `AuthSignup` component in `./src/components/conditional/Auth.vue`. 37 | 38 | NB: invite processing and pwd reset are not working atm - the needed routes/views are missing. 39 | 40 | Regarding Netlify: any serverless "back end" code (functions) can probably run elsewhere without bigger changes, but re-building the whole user management (Netlify Identity) might end up being a major change. 41 | 42 | ## Documentation 43 | 44 | - Backups: [docs/backups](./docs/backups.md) 45 | - Data Model: [docs/data-model](./docs/data-model.md) 46 | 47 | ## Contribute 48 | 49 | All future development of this project has moved to Codeberg: 50 | 51 | [![Contribute on Codeberg](/public/img/contribute_on_codeberg.png)](https://codeberg.org/ttntm/recept0r) 52 | 53 | Commits are mirrored to GitHub, this project still utilizes GH > Netlify CI/CD for the time being. 54 | -------------------------------------------------------------------------------- /docs/backups.md: -------------------------------------------------------------------------------- 1 | # Backups 2 | 3 | Data stored in the database should be subject to regular backups. 4 | 5 | A script handling backup can be found here: `./backup/backup-db.js`. 6 | -------------------------------------------------------------------------------- /docs/data-model.md: -------------------------------------------------------------------------------- 1 | # Database Data Model 2 | 3 | This application was migrated to supabase in April 2025. 4 | 5 | The old documentation that used to be in this file is obsolete, new documentation should be written. 6 | -------------------------------------------------------------------------------- /functions/_shared/fields.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | recipe_deleted: ` 3 | id, 4 | title 5 | `, 6 | recipe_full: ` 7 | id, 8 | slug, 9 | status, 10 | owner, 11 | updated, 12 | title, 13 | description, 14 | image, 15 | portions, 16 | preparation, 17 | duration, 18 | calories, 19 | diet, 20 | category, 21 | ingredients, 22 | body 23 | ` 24 | } -------------------------------------------------------------------------------- /functions/_shared/headers.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'Content-Type': 'application/json', 3 | 'Strict-Transport-Security': 'max-age=31536000', 4 | 'X-Frame-Options': 'DENY' 5 | } -------------------------------------------------------------------------------- /functions/api-methods/create.js: -------------------------------------------------------------------------------- 1 | const fnHeaders = require('../_shared/headers.js') 2 | const spb = require('@supabase/supabase-js') 3 | 4 | module.exports = async (event, context) => { 5 | const { body } = event 6 | const headers = { ...fnHeaders } 7 | const origin = event.headers.Origin || event.headers.origin 8 | const recipe = JSON.parse(body) 9 | const supabase = spb.createClient(process.env.SPB_URL, process.env.SPB_API_KEY, { 10 | auth: { 11 | autoRefreshToken: false, 12 | detectSessionInUrl: false, 13 | persistSession: false 14 | } 15 | }) 16 | 17 | headers['Access-Control-Allow-Origin'] = origin ? origin : '*' 18 | 19 | console.log(`Function 'create' invoked. ${recipe?.title}`) 20 | 21 | if (!recipe) { 22 | return { 23 | statusCode: 400, 24 | headers: { ...fnHeaders }, 25 | body: 'Bad Request' 26 | } 27 | } else { 28 | try { 29 | const { data, error } = await supabase 30 | .from(process.env.SPB_TABLE) 31 | .insert(recipe) 32 | .select() 33 | 34 | if (error) { 35 | throw JSON.stringify(error) 36 | } 37 | 38 | return { 39 | statusCode: 200, 40 | headers: headers, 41 | body: JSON.stringify(data) 42 | } 43 | } catch (ex) { 44 | console.log('error', ex) 45 | 46 | return { 47 | statusCode: 400, 48 | headers: headers, 49 | body: typeof ex === 'string' 50 | ? ex 51 | : JSON.stringify(ex) 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /functions/api-methods/delete.js: -------------------------------------------------------------------------------- 1 | const fields = require('../_shared/fields.js') 2 | const fnHeaders = require('../_shared/headers.js') 3 | const spb = require('@supabase/supabase-js') 4 | 5 | module.exports = async (event, context) => { 6 | const { target } = event 7 | const headers = { ...fnHeaders } 8 | const origin = event.headers.Origin || event.headers.origin 9 | const supabase = spb.createClient(process.env.SPB_URL, process.env.SPB_API_KEY, { 10 | auth: { 11 | autoRefreshToken: false, 12 | detectSessionInUrl: false, 13 | persistSession: false 14 | } 15 | }) 16 | 17 | headers['Access-Control-Allow-Origin'] = origin ? origin : '*' 18 | 19 | console.log(`Function 'delete' invoked. delete id: ${target}`) 20 | 21 | if (!target) { 22 | return { 23 | statusCode: 400, 24 | headers: { ...fnHeaders }, 25 | body: 'Bad Request' 26 | } 27 | } else { 28 | try { 29 | const { data, error } = await supabase 30 | .from(process.env.SPB_TABLE) 31 | .delete() 32 | .eq('id', target) 33 | .select(fields.recipe_deleted) 34 | 35 | if (error) { 36 | throw JSON.stringify(error) 37 | } 38 | 39 | return { 40 | statusCode: 200, 41 | headers: headers, 42 | body: JSON.stringify(data) 43 | } 44 | } catch (ex) { 45 | console.log('error', ex) 46 | 47 | return { 48 | statusCode: 400, 49 | headers: headers, 50 | body: typeof ex === 'string' 51 | ? ex 52 | : JSON.stringify(ex) 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /functions/api-methods/index.js: -------------------------------------------------------------------------------- 1 | const Create = require('./create') 2 | const ReadUser = require('./readUser') 3 | const Update = require('./update') 4 | const Delete = require('./delete') 5 | 6 | module.exports = { 7 | create: Create, 8 | readUser: ReadUser, 9 | update: Update, 10 | delete: Delete 11 | } 12 | -------------------------------------------------------------------------------- /functions/api-methods/readUser.js: -------------------------------------------------------------------------------- 1 | const fields = require('../_shared/fields.js') 2 | const fnHeaders = require('../_shared/headers.js') 3 | const spb = require('@supabase/supabase-js') 4 | 5 | module.exports = async (event, context) => { 6 | console.log("Function 'readUser' invoked") 7 | 8 | const headers = { ...fnHeaders } 9 | const origin = event.headers.Origin || event.headers.origin 10 | const supabase = spb.createClient(process.env.SPB_URL, process.env.SPB_API_KEY) 11 | const { user } = event 12 | 13 | headers['Access-Control-Allow-Origin'] = origin ? origin : '*' 14 | 15 | if (!user) { 16 | return { 17 | statusCode: 400, 18 | headers: headers, 19 | body: 'Bad Request' 20 | } 21 | } else { 22 | try { 23 | const { data, error } = await supabase 24 | .from(process.env.SPB_TABLE) 25 | .select(fields.recipe_full) 26 | .eq('owner', user) 27 | .order('updated', { ascending: false }) 28 | 29 | if (error) { 30 | throw JSON.stringify(error) 31 | } 32 | 33 | return { 34 | statusCode: 200, 35 | headers: headers, 36 | body: JSON.stringify(data) 37 | } 38 | } catch (ex) { 39 | console.log('error', ex) 40 | 41 | return { 42 | statusCode: 400, 43 | headers: headers, 44 | body: typeof ex === 'string' 45 | ? ex 46 | : JSON.stringify(ex) 47 | } 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /functions/api-methods/update.js: -------------------------------------------------------------------------------- 1 | const fnHeaders = require('../_shared/headers.js') 2 | const spb = require('@supabase/supabase-js') 3 | 4 | module.exports = async (event, context) => { 5 | const { body, target } = event 6 | const headers = { ...fnHeaders } 7 | const origin = event.headers.Origin || event.headers.origin 8 | const supabase = spb.createClient(process.env.SPB_URL, process.env.SPB_API_KEY, { 9 | auth: { 10 | autoRefreshToken: false, 11 | detectSessionInUrl: false, 12 | persistSession: false 13 | } 14 | }) 15 | const update = JSON.parse(body) 16 | 17 | headers['Access-Control-Allow-Origin'] = origin ? origin : '*' 18 | 19 | console.log(`Function 'update' invoked. update id: ${target}`) 20 | 21 | if (!update || !target) { 22 | return { 23 | statusCode: 400, 24 | headers: { ...fnHeaders }, 25 | body: 'Bad Request' 26 | } 27 | } else { 28 | try { 29 | const { data, error } = await supabase 30 | .from(process.env.SPB_TABLE) 31 | .update(update) 32 | .eq('id', target) 33 | .select() 34 | 35 | if (error) { 36 | throw JSON.stringify(error) 37 | } 38 | 39 | return { 40 | statusCode: 200, 41 | headers: headers, 42 | body: JSON.stringify(data) 43 | } 44 | } catch (ex) { 45 | console.log('error', ex) 46 | 47 | return { 48 | statusCode: 400, 49 | headers: headers, 50 | body: typeof ex === 'string' 51 | ? ex 52 | : JSON.stringify(ex) 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /functions/api.js: -------------------------------------------------------------------------------- 1 | const api = require('./api-methods') 2 | const fnHeaders = require('./_shared/headers.js') 3 | 4 | // get the last element from the URL, i.e. example.com/last => 'last' 5 | const getPath = (urlPath) => urlPath.match(/([^\/]*)\/*$/)[0] 6 | 7 | // get the last 2 elements from the URL, i.e. example.com/one/two => [one,two] 8 | const getMethodPath = (urlPath) => urlPath.match(/\w+\/([^\/]*)\/*$/)[0].split('/') 9 | 10 | const pathError = { 11 | statusCode: 500, 12 | headers: { ...fnHeaders }, 13 | body: 'No path specified' 14 | } 15 | 16 | exports.handler = async (event, context) => { 17 | const claims = context.clientContext && context.clientContext.user 18 | 19 | if (!claims) { 20 | return { 21 | statusCode: 401, 22 | headers: { ...fnHeaders }, 23 | body: 'NOT ALLOWED' 24 | } 25 | } 26 | 27 | const target = getPath(event.path) 28 | 29 | if (target) { 30 | event.target = target 31 | } 32 | 33 | switch (event.httpMethod) { 34 | case 'GET': 35 | // only used for getting user specific recipes - single read has to be public 36 | const [tgt, usr] = getMethodPath(event.path) 37 | 38 | if (tgt !== 'owner' || !usr) { 39 | return pathError 40 | } 41 | 42 | event.target = tgt // target = sub-path for method distinction 43 | event.user = usr // user id for the DB index 44 | 45 | return api.readUser(event, context) 46 | 47 | case 'POST': 48 | return api.create(event, context) 49 | 50 | case 'PUT': 51 | // target = recipe id 52 | return event.target 53 | ? api.update(event, context) 54 | : pathError 55 | 56 | case 'DELETE': 57 | // target = recipe id 58 | return event.target 59 | ? api.delete(event, context) 60 | : pathError 61 | 62 | default: 63 | return { 64 | statusCode: 500, 65 | headers: { ...fnHeaders }, 66 | body: 'Unrecognized HTTP Method, must be one of GET/POST/PUT/DELETE' 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /functions/backup.js: -------------------------------------------------------------------------------- 1 | const fields = require('./_shared/fields.js') 2 | const fnHeaders = require('./_shared/headers.js') 3 | const spb = require('@supabase/supabase-js') 4 | 5 | function getTimestamp() { 6 | const d = new Date() 7 | return d.toISOString().split('T')[0].replaceAll('-', '') 8 | } 9 | 10 | exports.handler = async (event, context) => { 11 | console.log("Function 'readAll' invoked") 12 | 13 | const headers = { ...fnHeaders } 14 | const origin = event.headers.Origin || event.headers.origin 15 | const supabase = spb.createClient(process.env.SPB_URL, process.env.SPB_API_KEY) 16 | 17 | headers['Access-Control-Allow-Origin'] = origin ? origin : '*' 18 | 19 | try { 20 | const { data, error } = await supabase 21 | .from(process.env.SPB_TABLE) 22 | .select(fields.recipe_full) 23 | .order('updated', { ascending: false }) 24 | 25 | if (error) { 26 | throw JSON.stringify(error) 27 | } 28 | 29 | if (data) { 30 | const now = getTimestamp() 31 | 32 | await supabase 33 | .storage 34 | .from('backup') 35 | .upload( 36 | `${now}_allRecipes.json`, 37 | JSON.stringify(data), 38 | { contentType: 'application/json' } 39 | ) 40 | } 41 | 42 | return { 43 | statusCode: 200, 44 | headers: headers 45 | } 46 | } catch (ex) { 47 | console.log('error', ex) 48 | 49 | return { 50 | statusCode: 400, 51 | headers: headers, 52 | body: typeof ex === 'string' 53 | ? ex 54 | : JSON.stringify(ex) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /functions/read-all.js: -------------------------------------------------------------------------------- 1 | const fields = require('./_shared/fields.js') 2 | const fnHeaders = require('./_shared/headers.js') 3 | const spb = require('@supabase/supabase-js') 4 | 5 | exports.handler = async (event, context) => { 6 | console.log("Function 'readAll' invoked") 7 | 8 | const headers = { ...fnHeaders } 9 | const origin = event.headers.Origin || event.headers.origin 10 | const supabase = spb.createClient(process.env.SPB_URL, process.env.SPB_API_KEY) 11 | 12 | headers['Access-Control-Allow-Origin'] = origin ? origin : '*' 13 | 14 | try { 15 | const { data, error } = await supabase 16 | .from(process.env.SPB_TABLE) 17 | .select(fields.recipe_full) 18 | .eq('status', 'published') 19 | .order('updated', { ascending: false }) 20 | 21 | if (error) { 22 | throw JSON.stringify(error) 23 | } 24 | 25 | return { 26 | statusCode: 200, 27 | headers: headers, 28 | body: JSON.stringify(data) 29 | } 30 | } catch (ex) { 31 | console.log('error', ex) 32 | 33 | return { 34 | statusCode: 400, 35 | headers: headers, 36 | body: typeof ex === 'string' 37 | ? ex 38 | : JSON.stringify(ex) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /functions/read.js: -------------------------------------------------------------------------------- 1 | const fields = require('./_shared/fields.js') 2 | const fnHeaders = require('./_shared/headers.js') 3 | const spb = require('@supabase/supabase-js') 4 | 5 | // get the last element from the URL, i.e. example.com/last => 'last' 6 | const getPath = (urlPath) => urlPath.match(/([^\/]*)\/*$/)[0] 7 | 8 | exports.handler = async (event, context) => { 9 | console.log(`Function 'read' invoked. Read id: ${target}`) 10 | 11 | const headers = { ...fnHeaders } 12 | const origin = event.headers.Origin || event.headers.origin 13 | const supabase = spb.createClient(process.env.SPB_URL, process.env.SPB_API_KEY) 14 | const target = getPath(event.path) 15 | 16 | headers['Access-Control-Allow-Origin'] = origin ? origin : '*' 17 | 18 | if (!target) { 19 | return { 20 | statusCode: 400, 21 | headers: headers, 22 | body: 'Bad Request' 23 | } 24 | } else { 25 | try { 26 | const { data, error } = await supabase 27 | .from(process.env.SPB_TABLE) 28 | .select(fields.recipe_full) 29 | .eq('id', target) 30 | 31 | if (error) { 32 | throw JSON.stringify(error) 33 | } 34 | 35 | return { 36 | statusCode: 200, 37 | headers: headers, 38 | body: JSON.stringify(data) 39 | } 40 | } catch (ex) { 41 | console.log('error', ex) 42 | 43 | return { 44 | statusCode: 400, 45 | headers: headers, 46 | body: typeof ex === 'string' 47 | ? ex 48 | : JSON.stringify(ex) 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /functions/sitemap.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch') 2 | const jsonHeaders = require('./_shared/headers.js') 3 | 4 | const xmlHeaders = { 5 | 'Content-Type': 'text/xml', 6 | 'Strict-Transport-Security': 'max-age=31536000', 7 | 'X-Frame-Options': 'DENY' 8 | } 9 | const readAll = process.env.VITE_APP_READALL 10 | const site = 'https://recept0r.com' 11 | 12 | const initSitemap = () => { 13 | return { 14 | start: ` 15 | `, 16 | mid: ` 17 | ${site}/ 18 | weekly 19 | 1.0 20 | 21 | ${site}/about 22 | yearly 23 | 0.7 24 | `, 25 | end: `` 26 | } 27 | } 28 | 29 | exports.handler = async (event, context) => { 30 | console.log("Function 'sitemap' invoked") 31 | 32 | const sitemap = initSitemap() 33 | 34 | try { 35 | console.log('Fetching database content') 36 | 37 | const request = await fetch(`${site}${readAll}`, { method: 'GET' }) 38 | let response = await request.json() 39 | 40 | // Process dynamic content from the db (if there is any) 41 | if (response && response.length > 0) { 42 | console.log('Processing database content') 43 | 44 | // Processing type Recipe[] here; see: ./src/types.d.ts 45 | response.forEach(el => { 46 | const itemURL = `${site}/recipe/${el.data.id}/${el.ref['@ref'].id}` 47 | 48 | sitemap.mid += ` 49 | ${itemURL} 50 | yearly 51 | 0.7 52 | ` 53 | }) 54 | } else { 55 | console.log('No database content received') 56 | } 57 | } catch (error) { 58 | console.log('Error building sitemap: ', error) 59 | 60 | return { 61 | statusCode: 400, 62 | headers: jsonHeaders, 63 | body: JSON.stringify(error) 64 | } 65 | } finally { 66 | const sitemapFinal = `${sitemap.start}${sitemap.mid}${sitemap.end}` 67 | 68 | console.log('Sitemap complete: ', sitemapFinal) 69 | 70 | return { 71 | statusCode: 200, 72 | headers: xmlHeaders, 73 | body: sitemapFinal 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | recept0r 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 30 |
31 | me@fosstodon 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | command = "npm run build" 3 | functions = "functions" 4 | publish = "dist" 5 | 6 | [dev] 7 | command = "npm run start" 8 | framework = "#custom" 9 | functions = "functions" 10 | publish = "dist" 11 | targetPort = 3000 12 | 13 | [[headers]] 14 | for = "/*" 15 | [headers.values] 16 | Referrer-Policy = "same-origin" 17 | Strict-Transport-Security = "max-age=31536000; includeSubDomains" 18 | X-Content-Type-Options = "nosniff" 19 | X-Frame-Options = "DENY" 20 | X-XSS-Protection = "1; mode=block" 21 | 22 | [[redirects]] 23 | from = "/api/backup" 24 | to = "/.netlify/functions/backup" 25 | status = 200 26 | force = true 27 | 28 | [[redirects]] 29 | from = "/api/read" 30 | to = "/.netlify/functions/read" 31 | status = 200 32 | force = true 33 | 34 | [[redirects]] 35 | from = "/api/read-all" 36 | to = "/.netlify/functions/read-all" 37 | status = 200 38 | force = true 39 | 40 | [[redirects]] 41 | from = "/api/*" 42 | to = "/.netlify/functions/api/:splat" 43 | status = 200 44 | force = true 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "recept0r", 3 | "version": "2.0.0", 4 | "scripts": { 5 | "dev": "netlify dev", 6 | "build": "vite build", 7 | "start": "vite", 8 | "format": "prettier . --write", 9 | "backup": "node ./backup/backup-db.js" 10 | }, 11 | "dependencies": { 12 | "@vueup/vue-quill": "^1.0.0-beta.7", 13 | "body-scroll-lock": "^4.0.0-beta.0", 14 | "gotrue-js": "^0.9.29", 15 | "secure-ls": "^1.2.6", 16 | "vue": "^3.3.4", 17 | "vue-router": "^4.2.4", 18 | "vuedraggable": "^4.1.0", 19 | "vuex": "^4.1.0", 20 | "vuex-multi-tab-state": "^1.0.16", 21 | "vuex-persistedstate": "^4.0.0-beta.3" 22 | }, 23 | "devDependencies": { 24 | "@supabase/supabase-js": "^2.49.4", 25 | "@types/body-scroll-lock": "^2.6.2", 26 | "@typescript-eslint/eslint-plugin": "^4.18.0", 27 | "@typescript-eslint/parser": "^4.18.0", 28 | "@unlazy/vue": "^0.10.1", 29 | "@vitejs/plugin-vue": "^1.1.5", 30 | "@vue/compiler-sfc": "^3.0.4", 31 | "dotenv": "^16.4.5", 32 | "eslint": "^7.22.0", 33 | "eslint-config-prettier": "^8.1.0", 34 | "eslint-plugin-prettier": "^3.3.1", 35 | "eslint-plugin-vue": "^7.7.0", 36 | "node-fetch": "^2.6.7", 37 | "prettier": "^2.2.1", 38 | "tailwindcss": "^1.8.10", 39 | "typescript": "^4.2.3", 40 | "vite": "^2.0.1" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const autoprefixer = require('autoprefixer') 2 | const tailwindcss = require('tailwindcss') 3 | 4 | module.exports = { 5 | plugins: [tailwindcss, autoprefixer] 6 | } 7 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 100, 3 | tabWidth: 2, 4 | useTabs: false, 5 | semi: false, // semicolon at the end of lines; if false, it'll only fix things that will break otherwise 6 | vueIndentScriptAndStyle: true, 7 | singleQuote: true, // Single quotation marks 8 | quoteProps: 'as-needed', 9 | bracketSpacing: true, 10 | trailingComma: 'none', // last comma for object definitions 11 | jsxBracketSameLine: false, 12 | jsxSingleQuote: false, 13 | arrowParens: 'always', 14 | insertPragma: false, 15 | requirePragma: false, 16 | proseWrap: 'never', 17 | htmlWhitespaceSensitivity: 'strict', 18 | endOfLine: 'lf' 19 | } 20 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | https://next-recept0r.netlify.app/* https://recept0r.com/:splat 301! 2 | https://main--next-recept0r.netlify.app/* https://recept0r.com/:splat 301! 3 | 4 | # Ads 5 | /app-ads.txt /ads.txt 301! 6 | # API 7 | 8 | /api/backup /.netlify/functions/backup 200 9 | /api/read /.netlify/functions/read 200 10 | /api/read-all /.netlify/functions/read-all 200 11 | /api/* /.netlify/functions/api/:splat 200 12 | 13 | # Sitemap 14 | /sitemap.xml /.netlify/functions/sitemap 200 15 | 16 | # SPA 17 | /* /index.html 200 18 | -------------------------------------------------------------------------------- /public/ads.txt: -------------------------------------------------------------------------------- 1 | placeholder.example.com, placeholder, DIRECT, placeholder -------------------------------------------------------------------------------- /public/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttntm/recept0r-ts/7d8d6c170d8fa49ee767a993d7cb2e65866fc6cf/public/android-chrome-192x192.png -------------------------------------------------------------------------------- /public/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttntm/recept0r-ts/7d8d6c170d8fa49ee767a993d7cb2e65866fc6cf/public/android-chrome-512x512.png -------------------------------------------------------------------------------- /public/apple-180x180-solid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttntm/recept0r-ts/7d8d6c170d8fa49ee767a993d7cb2e65866fc6cf/public/apple-180x180-solid.png -------------------------------------------------------------------------------- /public/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #f2f6ff 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttntm/recept0r-ts/7d8d6c170d8fa49ee767a993d7cb2e65866fc6cf/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttntm/recept0r-ts/7d8d6c170d8fa49ee767a993d7cb2e65866fc6cf/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttntm/recept0r-ts/7d8d6c170d8fa49ee767a993d7cb2e65866fc6cf/public/favicon.ico -------------------------------------------------------------------------------- /public/img/blurred.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttntm/recept0r-ts/7d8d6c170d8fa49ee767a993d7cb2e65866fc6cf/public/img/blurred.png -------------------------------------------------------------------------------- /public/img/contribute_on_codeberg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttntm/recept0r-ts/7d8d6c170d8fa49ee767a993d7cb2e65866fc6cf/public/img/contribute_on_codeberg.png -------------------------------------------------------------------------------- /public/img/duration.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/img/filter/bread.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/img/filter/dessert.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/img/filter/drink.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/img/filter/keto.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/img/filter/main.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/img/filter/none.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/img/filter/other.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/img/filter/pastry.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/img/filter/salad.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/img/filter/snack.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/img/filter/soup.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/img/filter/vegan.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/img/filter/vegetarian.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/img/happy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/img/loading.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/img/portions.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/img/sad.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttntm/recept0r-ts/7d8d6c170d8fa49ee767a993d7cb2e65866fc6cf/public/mstile-144x144.png -------------------------------------------------------------------------------- /public/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttntm/recept0r-ts/7d8d6c170d8fa49ee767a993d7cb2e65866fc6cf/public/mstile-150x150.png -------------------------------------------------------------------------------- /public/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttntm/recept0r-ts/7d8d6c170d8fa49ee767a993d7cb2e65866fc6cf/public/mstile-310x150.png -------------------------------------------------------------------------------- /public/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttntm/recept0r-ts/7d8d6c170d8fa49ee767a993d7cb2e65866fc6cf/public/mstile-310x310.png -------------------------------------------------------------------------------- /public/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttntm/recept0r-ts/7d8d6c170d8fa49ee767a993d7cb2e65866fc6cf/public/mstile-70x70.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | 4 | # I opt out of online advertising so malware that injects ads on my site won't get paid. 5 | # You should do the same. my ads.txt file contains a standard placeholder to forbid any 6 | # compliant ad networks from paying for ad placement on my domain. 7 | User-Agent: Adsbot 8 | Disallow: / 9 | Allow: /ads.txt 10 | Allow: /app-ads.txt 11 | 12 | User-agent: AdsBot-Google 13 | Disallow: / 14 | 15 | User-agent: AdsBot-Google-Mobile 16 | Disallow: / 17 | 18 | User-agent: Amazonbot 19 | Disallow: / 20 | 21 | # AI Data Scraper 22 | # https://darkvisitors.com/agents/anthropic-ai 23 | User-agent: anthropic-ai 24 | Disallow: / 25 | 26 | User-agent: Applebot 27 | Disallow: / 28 | 29 | User-agent: AwarioRssBot 30 | Disallow: / 31 | 32 | User-agent: AwarioSmartBot 33 | Disallow: / 34 | 35 | # BLEXBot assists internet marketers to get information on the link structure of sites and their interlinking on the web, to avoid any technical and possible legal issues and improve overall online experience. (http://webmeup-crawler.com/) 36 | # --> fuck off. 37 | User-Agent: BLEXBot 38 | Disallow: / 39 | 40 | # Stop trademark violations and affiliate non-compliance in paid search. Automatically monitor your partner and affiliates’ online marketing to protect yourself from harmful brand violations and regulatory risks. We regularly crawl websites on behalf of our clients to ensure content compliance with brand and regulatory guidelines. (https://www.brandverity.com/why-is-brandverity-visiting-me) 41 | # --> fuck off. 42 | User-agent: BrandVerity/1.0 43 | Disallow: / 44 | 45 | # AI Data Scraper 46 | # https://darkvisitors.com/agents/bytespider 47 | User-agent: Bytespider 48 | Disallow: / 49 | 50 | User-agent: CCBot 51 | Disallow: / 52 | 53 | # Eat shit, OpenAI. 54 | User-agent: ChatGPT-User 55 | Disallow: / 56 | 57 | # Providing Intellectual Property professionals with superior brand protection services by artfully merging the latest technology with expert analysis. (https://www.checkmarknetwork.com/spider.html/) 58 | # "The Internet is just way too big to effectively police alone." (ACTUAL quote) 59 | # --> fuck off. 60 | User-agent: CheckMarkNetwork/1.0 (+https://www.checkmarknetwork.com/spider.html) 61 | Disallow: / 62 | 63 | User-agent: ClaudeBot 64 | Disallow: / 65 | 66 | User-agent: Claude-Web 67 | Disallow: / 68 | 69 | User-agent: cohere-ai 70 | Disallow: / 71 | 72 | User-agent: DataForSeoBot 73 | Disallow: / 74 | 75 | User-agent: FacebookBot 76 | Disallow: / 77 | 78 | User-agent: Google-Extended 79 | Disallow: / 80 | 81 | User-agent: GoogleOther 82 | Disallow: / 83 | 84 | User-agent: GPTBot 85 | Disallow: / 86 | 87 | User-agent: ImagesiftBot 88 | Disallow: / 89 | 90 | User-agent: magpie-crawler 91 | Disallow: / 92 | 93 | User-agent: Mediapartners-Google 94 | Disallow: / 95 | 96 | # > NameProtect engages in crawling activity in search of a wide range of brand and other intellectual property violations that may be of interest to our clients. (http://www.nameprotect.com/botinfo.html) 97 | # --> fuck off. 98 | User-Agent: NPBot 99 | Disallow: / 100 | 101 | # AI Data Scraper 102 | # https://darkvisitors.com/agents/omgili 103 | User-agent: omgili 104 | Disallow: / 105 | 106 | User-agent: Omgilibot 107 | Disallow: / 108 | 109 | User-agent: peer39_crawler 110 | Disallow: / 111 | 112 | User-agent: peer39_crawler/1.0 113 | Disallow: / 114 | 115 | User-agent: PerplexityBot 116 | Disallow: / 117 | 118 | # iThenticate is a new service we have developed to combat the piracy of intellectual property and ensure the originality of written work for# publishers, non-profit agencies, corporations, and newspapers. (http://www.slysearch.com/) 119 | # --> fuck off. 120 | User-Agent: SlySearch 121 | Disallow: / 122 | 123 | # > This robot collects content from the Internet for the sole purpose of # helping educational institutions prevent plagiarism. [...] we compare student papers against the content we find on the Internet to see if we # can find similarities. (http://www.turnitin.com/robot/crawlerinfo.html) 124 | # --> fuck off. 125 | User-Agent: TurnitinBot 126 | Disallow: / 127 | 128 | User-agent: YouBot 129 | Disallow: / 130 | -------------------------------------------------------------------------------- /public/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 40 | 44 | 47 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /public/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "recept0r", 3 | "short_name": "recept0r", 4 | "description": "A recipes app.", 5 | "icons": [ 6 | { 7 | "src": "/android-chrome-192x192.png", 8 | "sizes": "192x192", 9 | "type": "image/png" 10 | }, 11 | { 12 | "src": "/android-chrome-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png" 15 | } 16 | ], 17 | "theme_color": "#ffffff", 18 | "background_color": "#ffffff", 19 | "start_url": "https://recept0r.com", 20 | "display": "standalone" 21 | } 22 | -------------------------------------------------------------------------------- /public/splash-social.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttntm/recept0r-ts/7d8d6c170d8fa49ee767a993d7cb2e65866fc6cf/public/splash-social.jpg -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | -------------------------------------------------------------------------------- /src/assets/fonts/nunito-v12-latin-600.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttntm/recept0r-ts/7d8d6c170d8fa49ee767a993d7cb2e65866fc6cf/src/assets/fonts/nunito-v12-latin-600.woff -------------------------------------------------------------------------------- /src/assets/fonts/nunito-v12-latin-600.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttntm/recept0r-ts/7d8d6c170d8fa49ee767a993d7cb2e65866fc6cf/src/assets/fonts/nunito-v12-latin-600.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/nunito-v12-latin-700.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttntm/recept0r-ts/7d8d6c170d8fa49ee767a993d7cb2e65866fc6cf/src/assets/fonts/nunito-v12-latin-700.woff -------------------------------------------------------------------------------- /src/assets/fonts/nunito-v12-latin-700.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttntm/recept0r-ts/7d8d6c170d8fa49ee767a993d7cb2e65866fc6cf/src/assets/fonts/nunito-v12-latin-700.woff2 -------------------------------------------------------------------------------- /src/assets/fonts/nunito-v12-latin-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttntm/recept0r-ts/7d8d6c170d8fa49ee767a993d7cb2e65866fc6cf/src/assets/fonts/nunito-v12-latin-regular.woff -------------------------------------------------------------------------------- /src/assets/fonts/nunito-v12-latin-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttntm/recept0r-ts/7d8d6c170d8fa49ee767a993d7cb2e65866fc6cf/src/assets/fonts/nunito-v12-latin-regular.woff2 -------------------------------------------------------------------------------- /src/components/Footer.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/LazyWrapper.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | -------------------------------------------------------------------------------- /src/components/Navbar.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | -------------------------------------------------------------------------------- /src/components/SearchBar.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 37 | 38 | -------------------------------------------------------------------------------- /src/components/button/ButtonCheckbox.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 33 | 34 | -------------------------------------------------------------------------------- /src/components/button/ButtonDefault.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/button/ButtonDelete.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | -------------------------------------------------------------------------------- /src/components/button/ButtonDuplicate.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 54 | -------------------------------------------------------------------------------- /src/components/button/ButtonFilter.vue: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /src/components/button/ButtonFilterIcon.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 32 | 33 | -------------------------------------------------------------------------------- /src/components/button/ButtonMenu.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/button/ButtonShare.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | -------------------------------------------------------------------------------- /src/components/button/ButtonSort.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /src/components/button/ButtonTop.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 46 | 47 | -------------------------------------------------------------------------------- /src/components/button/ButtonUser.vue: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /src/components/button/ButtonX.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/conditional/Auth.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 60 | 61 | -------------------------------------------------------------------------------- /src/components/conditional/AuthLogin.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | -------------------------------------------------------------------------------- /src/components/conditional/AuthSignup.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | -------------------------------------------------------------------------------- /src/components/conditional/LoadingMessage.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/conditional/MobileMenu.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | -------------------------------------------------------------------------------- /src/components/conditional/ToastMessage.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 34 | 35 | -------------------------------------------------------------------------------- /src/components/conditional/UserMenu.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 26 | 27 | -------------------------------------------------------------------------------- /src/components/home/HomeFilterMenu.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 104 | 105 | -------------------------------------------------------------------------------- /src/components/home/HomeRecipeCard.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 39 | 40 | -------------------------------------------------------------------------------- /src/components/icon/IconResolver.vue: -------------------------------------------------------------------------------- 1 | 94 | 95 | 98 | -------------------------------------------------------------------------------- /src/components/icon/calories.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/icon/duration.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/icon/filter/main.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/icon/filter/other.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/icon/grip-vertical.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/icon/happy.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/icon/hat.vue: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/icon/loading.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | -------------------------------------------------------------------------------- /src/components/icon/portions.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/icon/sad.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | -------------------------------------------------------------------------------- /src/components/input/InputSelect.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | -------------------------------------------------------------------------------- /src/components/input/InputText.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 31 | 32 | -------------------------------------------------------------------------------- /src/components/input/InputToggle.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 38 | 39 | -------------------------------------------------------------------------------- /src/components/recipe/RecipeDiet.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | -------------------------------------------------------------------------------- /src/components/recipe/RecipeImage.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | 106 | 107 | -------------------------------------------------------------------------------- /src/components/recipe/RecipeIngredients.vue: -------------------------------------------------------------------------------- 1 | 85 | 86 | 123 | 124 | 150 | -------------------------------------------------------------------------------- /src/components/recipe/readonly/RecipeDiet.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 27 | 28 | -------------------------------------------------------------------------------- /src/components/recipe/readonly/RecipeIngredients.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 47 | 48 | 58 | -------------------------------------------------------------------------------- /src/components/recipe/readonly/RecipeMeta.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 18 | 19 | -------------------------------------------------------------------------------- /src/components/user/UserRecipeCard.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 73 | 74 | -------------------------------------------------------------------------------- /src/components/user/UserRecipeSorting.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import router from './router' 3 | import { store, key } from './store' 4 | import detectTokens from './utils/useToken' 5 | 6 | import { disableBodyScroll, enableBodyScroll } from 'body-scroll-lock' 7 | import Unlazy from '@unlazy/vue' 8 | 9 | import App from './App.vue' 10 | 11 | import './index.css' 12 | 13 | const app = createApp(App) 14 | app.use(router) 15 | app.use(store, key) 16 | app.use(Unlazy) 17 | 18 | store.dispatch('user/initAuth') 19 | 20 | detectTokens() 21 | 22 | const clickBlurExclude: string[] = ['INPUT','SELECT','TEXTAREA'] 23 | let escHandler: any = null 24 | let handleClickBlur: Function | null = null 25 | let handleOutsideClick: any = null 26 | 27 | app.directive('click-blur', { 28 | beforeMount(el, binding, vnode) { 29 | handleClickBlur = (e: { currentTarget: HTMLElement }) => { 30 | if (clickBlurExclude.indexOf(e.currentTarget.nodeName) === -1) { 31 | e.currentTarget.blur() 32 | } 33 | } 34 | el.addEventListener('click', handleClickBlur, false) 35 | el.addEventListener('touchend', handleClickBlur, false) 36 | }, 37 | beforeUnmount(el) { 38 | el.removeEventListener('click', handleClickBlur) 39 | el.removeEventListener('touchend', handleClickBlur) 40 | } 41 | }) 42 | 43 | app.directive('click-outside', { 44 | beforeMount(el, binding, vnode) { 45 | handleOutsideClick = (e: Event & { target: HTMLElement }) => { 46 | if (!el.contains(e.target) && !e.target.classList.contains('click-outside-ignore')) { 47 | binding.value() 48 | } 49 | } 50 | document.addEventListener('click', handleOutsideClick, false) 51 | document.addEventListener('touchstart', handleOutsideClick, false) 52 | }, 53 | beforeUnmount() { 54 | document.removeEventListener('click', handleOutsideClick) 55 | document.removeEventListener('touchstart', handleOutsideClick) 56 | } 57 | }) 58 | 59 | app.directive('esc', { 60 | beforeMount(el, binding, vnode) { 61 | escHandler = (e: KeyboardEvent) => { 62 | if (e.key=='Escape' || e.key=='Esc') { 63 | binding.value() 64 | } 65 | } 66 | document.addEventListener('keydown', escHandler) 67 | }, 68 | beforeUnmount() { 69 | document.removeEventListener('keydown', escHandler) 70 | } 71 | }) 72 | 73 | app.directive('focus', { 74 | mounted(el) { 75 | el.focus() 76 | } 77 | }) 78 | 79 | app.directive('scroll-lock', { 80 | // there could be issues with iOS at some point: https://github.com/willmcpo/body-scroll-lock#allowtouchmove 81 | beforeMount(el, binding, vnode) { 82 | disableBodyScroll(el) // desirable, but causes body twitch: { reserveScrollBarGap: true } 83 | }, 84 | beforeUnmount(el) { 85 | enableBodyScroll(el) 86 | } 87 | }) 88 | 89 | app.mount('#app') 90 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router' 2 | import { store } from '@/store' 3 | 4 | import { showWindow } from '@/utils' 5 | 6 | declare module 'vue-router' { 7 | interface RouteMeta { 8 | authRequired?: boolean, 9 | menuPosition?: number, 10 | menuVisible?: boolean, 11 | mode?: string 12 | } 13 | } 14 | 15 | const routes: Array = [ 16 | { 17 | path: '/', 18 | name: 'Recipes', 19 | component: () => import('@/views/Home.vue'), 20 | meta: { 21 | menuPosition: 1, 22 | menuVisible: true 23 | } 24 | }, 25 | { 26 | path: '/create', 27 | name: 'Add Recipe', 28 | component: () => import('@/views/RecipeEditable.vue'), 29 | meta: { 30 | authRequired: true, 31 | menuPosition: 2, 32 | menuVisible: true, 33 | mode: 'create' 34 | } 35 | }, 36 | { 37 | path: '/edit-mode', 38 | name: 'Edit Mode', 39 | component: () => import('@/views/UserRecipes.vue'), 40 | meta: { 41 | authRequired: true, 42 | menuPosition: 3, 43 | menuVisible: true 44 | } 45 | }, 46 | { 47 | path: '/about', 48 | name: 'About', 49 | component: () => import('@/views/About.vue'), 50 | meta: { 51 | menuPosition: 4, 52 | menuVisible: true 53 | } 54 | }, 55 | { 56 | path: '/me', 57 | name: 'Profile', 58 | component: () => import('@/views/Profile.vue'), 59 | meta: { 60 | authRequired: true, 61 | menuVisible: false 62 | } 63 | }, 64 | { 65 | path: '/recipe/:slug/:id', 66 | name: 'Recipe', 67 | component: () => import('@/views/RecipeReadonly.vue'), 68 | meta: { 69 | menuVisible: false 70 | } 71 | }, 72 | { 73 | path: '/edit/:id', 74 | name: 'Edit Recipe', 75 | component: () => import('@/views/RecipeEditable.vue'), 76 | meta: { 77 | authRequired: true, 78 | menuVisible: false, 79 | mode: 'edit' 80 | } 81 | }, 82 | { 83 | path: '/signup', 84 | name: 'Signup', 85 | component: () => import('@/views/Signup.vue'), 86 | meta: { 87 | menuVisible: false 88 | }, 89 | beforeEnter: (to, from, next) => { 90 | return store.getters['user/loggedIn'] ? router.push({ name: 'Recipes' }) : next() 91 | } 92 | }, 93 | { 94 | path: '/:pathMatch(.*)', 95 | name: '404', 96 | redirect: { name: 'Recipes' }, 97 | meta: { 98 | menuVisible: false 99 | } 100 | } 101 | ] 102 | 103 | const router = createRouter({ 104 | history: createWebHistory(), 105 | routes: routes, 106 | scrollBehavior (to, from, savedPosition) { 107 | // always scroll to top 108 | return { top: 0 } 109 | } 110 | }) 111 | 112 | router.beforeEach((to, from, next) => { 113 | // close open windows 114 | showWindow(0) 115 | // global navigation guard for all routes that require user authentication 116 | if (!to.meta.authRequired) { 117 | return next() 118 | } 119 | 120 | if (to.meta.authRequired && store.getters['user/loggedIn']) { 121 | return next() 122 | } else { 123 | router.push({ name: 'Recipes' }) 124 | } 125 | }) 126 | 127 | router.afterEach((to, from, failure) => { 128 | document.title = to.name 129 | ? `${to.name?.toString()} - recept0r.com` 130 | : 'recept0r' 131 | }) 132 | 133 | export default router -------------------------------------------------------------------------------- /src/shim.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | declare module '*.vue' { 3 | import { defineComponent } from 'vue' 4 | 5 | const component: ReturnType 6 | export default component 7 | } 8 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { InjectionKey } from 'vue' 2 | import { createStore, useStore as baseUseStore, Store } from 'vuex' 3 | 4 | import createMultiTabState from 'vuex-multi-tab-state' 5 | import createPersistedState from 'vuex-persistedstate' 6 | import SecureLS from 'secure-ls' 7 | 8 | // needs the __"allowJs": true__ flag in tsconfig.json to work 9 | // TODO: properly type the Vuex modules... 10 | import app from './modules/app.js' 11 | import data from './modules/data.js' 12 | import user from './modules/user.js' 13 | 14 | var ls = new SecureLS({ isCompression: false }) 15 | 16 | export interface State {} 17 | 18 | export const key: InjectionKey> = Symbol() 19 | 20 | export const store = createStore({ 21 | modules: { 22 | app, 23 | data, 24 | user 25 | }, 26 | plugins: [ 27 | createMultiTabState({ 28 | key: 'rc-tabs', 29 | statesPaths: [ // name/s of the states to be synchronized with dot notation. If the param is not provided, the whole state of your app will be in sync. Defaults to [] 30 | 'app.debugInfo', 31 | 'data.allRecipes', 32 | 'data.filterActive', 33 | 'data.filterCache', 34 | 'data.filterData', 35 | 'data.lastUpdated', 36 | 'data.userRecipes', 37 | 'user.currentUser' 38 | ] 39 | }), 40 | createPersistedState({ 41 | key: 'rc', 42 | paths: ['user.GoTrueAuth'], 43 | storage: { 44 | getItem: (key) => ls.get(key), 45 | setItem: (key, value) => ls.set(key, value), 46 | removeItem: (key) => ls.remove(key) 47 | } 48 | }) 49 | ] 50 | }) 51 | 52 | // define your own `useStore` composition function 53 | export function useStore() { 54 | return baseUseStore(key) 55 | } 56 | -------------------------------------------------------------------------------- /src/store/modules/app.js: -------------------------------------------------------------------------------- 1 | export default { 2 | strict: false, 3 | namespaced: true, 4 | 5 | state() { 6 | return { 7 | cdnryUpreset: import.meta.env.VITE_APP_CDNRY_UPRESET, 8 | cdnryURL: import.meta.env.VITE_APP_CDNRY, 9 | debugInfo: null, 10 | identityURL: import.meta.env.VITE_APP_IDENTITY, 11 | functions: { 12 | read: import.meta.env.VITE_APP_READ, 13 | readAll: import.meta.env.VITE_APP_READALL 14 | }, 15 | toastMessage: null, 16 | windowOpen: 0 17 | } 18 | }, 19 | 20 | getters: { 21 | cdnryUpreset: state => state.cdnryUpreset, 22 | cdnryURL: state => state.cdnryURL, 23 | debugInfo: state => state.debugInfo, 24 | functions: state => state.functions, 25 | identityURL: state => state.identityURL, 26 | toastMessage: state => state.toastMessage, 27 | windowOpen: state => state.windowOpen 28 | }, 29 | 30 | mutations: { 31 | SET_DEBUG_INFO(state, value){ 32 | state.debugInfo = value 33 | }, 34 | SET_TOAST_MESSAGE(state, value) { 35 | state.toastMessage = value 36 | }, 37 | SET_WINDOW_OPEN(state, value) { 38 | state.windowOpen = value 39 | } 40 | }, 41 | 42 | actions: { 43 | initialize({ dispatch }) { 44 | // global state reset action triggering module actions to keep things separate 45 | dispatch('data/initializeData', null, { root: true }) 46 | dispatch('user/initializeUser', null, { root: true }) 47 | }, 48 | 49 | /** 50 | * @param {object} message - a message object as required by ToastMeassage.vue with 2 keys, "text" and "type" 51 | */ 52 | sendToastMessage({ commit }, message) { 53 | let timer 54 | 55 | clearTimeout(timer) 56 | 57 | commit('SET_TOAST_MESSAGE', message) 58 | 59 | timer = setTimeout(() => { 60 | commit('SET_TOAST_MESSAGE', null) 61 | }, 5000) 62 | }, 63 | 64 | /** 65 | * Set debug info based on how the 'Home' view handled the initial 'getAllRecipes()' function 66 | * @param {object} data 67 | */ 68 | setDebugInfo({ commit }, data) { 69 | commit('SET_DEBUG_INFO', data) 70 | }, 71 | 72 | /** 73 | * Toggle open modals and windows 74 | * @param {*} id WindowID: 1 = Mobile Nav || 2 = Login/Signup || 3 = Filter selection || 4 = recipe share menu || 5 = user button menu 75 | */ 76 | setWindowOpen({ commit }, id) { 77 | commit('SET_WINDOW_OPEN', id) 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import type { UserData } from 'gotrue-js' 2 | import type { Ref } from 'vue' 3 | 4 | export interface GenObj { 5 | [OKey: string]: any 6 | } 7 | 8 | export interface Credentials extends GenObj { 9 | name?: string 10 | email: string 11 | password: string 12 | } 13 | 14 | export interface DietMap extends GenObj { 15 | [DKey: string]: boolean 16 | } 17 | 18 | export type DebugInfo = { 19 | lastUpdate: Date | null 20 | updateNeeded: boolean 21 | forceUpdate: boolean 22 | } 23 | 24 | export type Headers = { 25 | Authorization?: string 26 | 'Content-Type': string 27 | } 28 | 29 | export interface FilterSelection extends GenObj { 30 | category: string[] 31 | diet: string[] 32 | } 33 | 34 | export interface IntersectionObserverOptions extends GenObj { 35 | immediate?: boolean 36 | root?: Ref 37 | rootMargin?: string 38 | threshold?: number | number[] 39 | } 40 | 41 | export interface IntersectionObserverReturn { 42 | isActive: Ref 43 | stop: () => void 44 | } 45 | 46 | export interface Recipe extends GenObj { 47 | slug: string 48 | owner: string 49 | title: string 50 | description: string 51 | image: string 52 | preparation?: string 53 | duration: string 54 | portions: string 55 | calories?: string 56 | diet: string | string[] 57 | category: string 58 | ingredients: string[] 59 | body: string 60 | status: 'draft' | 'published' 61 | } 62 | 63 | export type SortableEl = { 64 | id: number 65 | name: string 66 | } 67 | 68 | export type SortOption = { 69 | data: string 70 | type: string 71 | text: string 72 | tooltip: string 73 | } 74 | 75 | export type ToastMessage = { 76 | text: string 77 | type: 'error' | 'info' | 'success' 78 | } 79 | 80 | export type User = UserData 81 | 82 | /** 83 | * Tells TS about custom properties/methods on the Window object 84 | * See: https://www.cloudhadoop.com/typescript-add-property-window/ 85 | */ 86 | declare global { 87 | interface Window { 88 | smoothScroll: Function 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/utils/useAPI.ts: -------------------------------------------------------------------------------- 1 | import { store } from '@/store' 2 | import type { 3 | Headers, 4 | User 5 | } from '@/types' 6 | 7 | /** 8 | * Shared logic to handle API requests to the app's serverless back end functions 9 | */ 10 | export async function apiRequest(reqMethod: string, payload?: any, reqPath: string = '') { 11 | const url = String(import.meta.env.VITE_APP_API) 12 | const path = reqPath ? `${url}/${reqPath}` : url 13 | 14 | const body = payload ? JSON.stringify(payload) : undefined 15 | const headers = await getAuthHeaders() 16 | const method = reqMethod 17 | const reqData = { body, headers, method } 18 | 19 | let response 20 | 21 | try { 22 | const request = await fetch(path, reqData) 23 | response = request 24 | ? await request.json() 25 | : null 26 | } catch (err) { 27 | response = err 28 | } 29 | 30 | return response 31 | } 32 | 33 | /** 34 | * Set authorization headers based on GoTrue's current user session 35 | */ 36 | export async function getAuthHeaders() { 37 | const now = Date.now() 38 | const user: User = store.getters['user/currentUser'] 39 | 40 | let headers: Headers = { 41 | 'Content-Type': 'application/json' 42 | } 43 | 44 | // only get a new token if the old one has expired 45 | const token = user.token && user.token.expires_at < now 46 | ? await store.dispatch('user/refreshUserToken') 47 | : user.token.access_token 48 | 49 | if (token) { 50 | headers['Authorization'] = `Bearer ${token}` 51 | } 52 | 53 | return headers 54 | } 55 | -------------------------------------------------------------------------------- /src/utils/useCloudinary.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks a path for starting with 'https' and returns 'true' if that's the case 3 | */ 4 | export function isImgUploaded(path: string) { 5 | const checkImgSrc = RegExp(/^https:\/\//) 6 | return checkImgSrc.test(path) 7 | } 8 | 9 | /** 10 | * Uploads an image to Cloudinary 11 | * @param url Target URL for image upload 12 | * @param data FormData representation of the image coming from file reader 13 | */ 14 | export async function uploadImage(url: string, data: FormData) { 15 | const error = { 16 | message: 'Error uploading image.', 17 | data: null 18 | } 19 | 20 | try { 21 | const req = await fetch(url, { 22 | body: data, 23 | method: 'POST' 24 | }) 25 | 26 | const res = await req.json() 27 | 28 | return res 29 | ? { 30 | message: 'Image successfully uploaded.', 31 | data: res.secure_url 32 | } 33 | : error 34 | } catch (err) { 35 | return error 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/utils/useToken.js: -------------------------------------------------------------------------------- 1 | /* 2 | Extract and validate tokens in the URL if they are present. 3 | */ 4 | import { store } from '@/store' 5 | import router from '@/router' 6 | 7 | /** 8 | * Reads the URL hash attempts and tries to detect if there is confirmation tokens from an email signup or 9 | * invite. If present it will call the relevant process to attempt to authorize the token. 10 | */ 11 | function detectTokens() { 12 | const emailToken = detectEmailConfirmationToken() 13 | const inviteToken = detectInviteToken() 14 | const recoveryToken = detectRecoveryToken() 15 | 16 | if (emailToken) { 17 | console.log('Detected email confirmation token', emailToken) 18 | confirmEmailToken(emailToken) 19 | return 20 | } else if (inviteToken) { 21 | console.log('found invite token', inviteToken) 22 | confirmInviteToken(inviteToken) 23 | return 24 | } else if (recoveryToken) { 25 | console.log('found recovery token', recoveryToken) 26 | confirmRecoveryToken(recoveryToken) 27 | return 28 | } 29 | } 30 | 31 | /** 32 | * Checks URL hash for `confirmation_token=` then extracts the token which proceeds. 33 | */ 34 | function detectEmailConfirmationToken() { 35 | try { 36 | // split the hash where it detects `confirmation_token=`. The string which proceeds is the part which we want. 37 | const token = decodeURIComponent(document.location.hash).split( 38 | 'confirmation_token=' 39 | )[1] 40 | return token 41 | } catch (error) { 42 | console.error('Something went wrong when trying to extract email confirmation token', error) 43 | return null 44 | } 45 | } 46 | 47 | function detectInviteToken() { 48 | try { 49 | // split the hash where it detects `invite_token=`. The string which proceeds is the part which we want. 50 | const token = decodeURIComponent(document.location.hash).split( 51 | 'invite_token=' 52 | )[1] 53 | return token 54 | } catch (error) { 55 | console.error('Something went wrong when trying to extract invite token.', error) 56 | return null 57 | } 58 | } 59 | 60 | function detectRecoveryToken() { 61 | try { 62 | // split the hash where it detects `confirmation_token=`. The string which proceeds is the part which we want. 63 | const token = decodeURIComponent(document.location.hash).split( 64 | 'recovery_token=' 65 | )[1] 66 | return token 67 | } catch (error) { 68 | console.error('Something went wrong when trying to extract recovery token.', error) 69 | return null 70 | } 71 | } 72 | 73 | /** 74 | * @param {string} token - authentication token used to confirm a user who has created an account via email signup. 75 | */ 76 | function confirmEmailToken(token) { 77 | store.dispatch('user/attemptConfirmation', token) 78 | .then(resp => { 79 | store.dispatch('app/sendToastMessage', { text: `${resp.email} has been confirmed, please login`, type: 'success' }) 80 | }) 81 | .catch(error => { 82 | store.dispatch('app/sendToastMessage', { text: `Can't authorize your account right now. Please try again`, type: 'error' }) 83 | console.error(error, "Something's gone wrong confirming user") 84 | }) 85 | } 86 | 87 | function confirmInviteToken(token) { 88 | router.push(`/signup?t=${token}`) 89 | store.dispatch('app/sendToastMessage', { text: `Invite token found, please fill the form to complete your signup`, type: 'info' }) 90 | } 91 | 92 | function confirmRecoveryToken(recoveryToken) { 93 | store.dispatch('user/attemptPasswordRecovery', recoveryToken) 94 | .then(() => { 95 | router.push({ name: 'Profile' }) 96 | alert('Account has been recovered. Update your password now.') 97 | }) 98 | .catch(() => { 99 | alert(`Can't recover password`) 100 | }) 101 | } 102 | 103 | export default function() { 104 | detectTokens() 105 | } -------------------------------------------------------------------------------- /src/views/About.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | -------------------------------------------------------------------------------- /src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 72 | 73 | 100 | -------------------------------------------------------------------------------- /src/views/Profile.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | -------------------------------------------------------------------------------- /src/views/Signup.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 82 | 83 | -------------------------------------------------------------------------------- /src/views/UserRecipes.vue: -------------------------------------------------------------------------------- 1 | 68 | 69 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | // allows JS modules, i.e. Vuex store modules 4 | "allowJs": true, 5 | // Allow default import from modules with no default export set . This does not affect the output of the code , Just for type checking . 6 | "allowSyntheticDefaultImports": true, 7 | // Resolve the benchmark directory of non relative module names 8 | "baseUrl": ".", 9 | "esModuleInterop": true, 10 | // from tslib Import helper functions ( such as __extends, __rest etc. ) 11 | "importHelpers": true, 12 | // Specify which module system code to generate 13 | "module": "esnext", 14 | // Decide what to do with the module . 15 | "moduleResolution": "node", 16 | "outDir": "./dist", 17 | // Enable all strict type checking options . 18 | // Enable --strict It's equivalent to enabling --noImplicitAny, --noImplicitThis, --alwaysStrict, 19 | // --strictNullChecks and --strictFunctionTypes and --strictPropertyInitialization. 20 | "strict": true, 21 | // Generate corresponding .map file . 22 | "sourceMap": true, 23 | // Ignore all declaration files ( *.d.ts) Type check of . 24 | "skipLibCheck": true, 25 | // Appoint ECMAScript Target version 26 | "target": "esnext", 27 | // List of type declaration filenames to include 28 | "types": [ 29 | "vite/client" 30 | ], 31 | "isolatedModules": true, 32 | // Module name to based on baseUrl List of path mappings for . 33 | "paths": { 34 | "@/*": ["src/*"] 35 | }, 36 | // List of library files to be imported during compilation . 37 | "lib": ["ESNext", "DOM", "DOM.Iterable", "ScriptHost"] 38 | }, 39 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], 40 | "exclude": ["node_modules", "./dist/**/*"] 41 | } 42 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import path from 'path' 4 | 5 | export default defineConfig({ 6 | build: { 7 | cssCodeSplit: false, 8 | rollupOptions: { 9 | // Disabled Hashing as Netlify Does Hashing for us using Etag. 10 | output: { 11 | entryFileNames: `assets/[name].js`, 12 | chunkFileNames: `assets/[name].js`, 13 | assetFileNames: `assets/[name].[ext]` 14 | } 15 | } 16 | }, 17 | optimizeDeps: { 18 | include: [ 19 | 'gotrue-js', 20 | 'secure-ls', 21 | 'vue', 22 | 'vue-router', 23 | 'vuex', 24 | 'vuex-multi-tab-state', 25 | 'vuex-persistedstate', 26 | '@vueup/vue-quill', 27 | 'body-scroll-lock', 28 | 'vuedraggable' 29 | ] 30 | }, 31 | plugins: [ 32 | vue() 33 | ], 34 | resolve: { 35 | alias: { 36 | '@': path.resolve(__dirname, './src') 37 | }, 38 | // https://github.com/vuejs/vue-next/issues/2064#issuecomment-797365133 39 | dedupe: ['vue'] 40 | } 41 | }) 42 | --------------------------------------------------------------------------------