├── .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 | [](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 |
28 | We're sorry but this application doesn't work properly without JavaScript enabled. Please enable it to continue.
29 |
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 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/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 |
15 |
28 |
--------------------------------------------------------------------------------
/src/components/LazyWrapper.vue:
--------------------------------------------------------------------------------
1 |
29 |
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/src/components/Navbar.vue:
--------------------------------------------------------------------------------
1 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
38 |
39 |
40 | Login
41 |
42 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/src/components/SearchBar.vue:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
21 |
Search
22 |
23 |
24 |
25 |
26 |
27 | ×
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/src/components/button/ButtonCheckbox.vue:
--------------------------------------------------------------------------------
1 |
17 |
18 |
19 |
20 |
28 |
29 | {{ text }}
30 |
31 |
32 |
33 |
34 |
--------------------------------------------------------------------------------
/src/components/button/ButtonDefault.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/components/button/ButtonDelete.vue:
--------------------------------------------------------------------------------
1 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
--------------------------------------------------------------------------------
/src/components/button/ButtonDuplicate.vue:
--------------------------------------------------------------------------------
1 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/src/components/button/ButtonFilter.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | {{ filterBtnText }}
19 |
20 |
--------------------------------------------------------------------------------
/src/components/button/ButtonFilterIcon.vue:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 |
28 |
29 | {{ current }}
30 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/src/components/button/ButtonMenu.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/src/components/button/ButtonShare.vue:
--------------------------------------------------------------------------------
1 |
27 |
28 |
29 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/src/components/button/ButtonSort.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/src/components/button/ButtonTop.vue:
--------------------------------------------------------------------------------
1 |
29 |
30 |
31 |
32 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/src/components/button/ButtonUser.vue:
--------------------------------------------------------------------------------
1 |
6 |
7 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/src/components/button/ButtonX.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/src/components/conditional/Auth.vue:
--------------------------------------------------------------------------------
1 |
23 |
24 |
25 |
26 |
58 |
59 |
60 |
61 |
--------------------------------------------------------------------------------
/src/components/conditional/AuthLogin.vue:
--------------------------------------------------------------------------------
1 |
43 |
44 |
45 |
56 |
57 |
58 |
59 |
--------------------------------------------------------------------------------
/src/components/conditional/AuthSignup.vue:
--------------------------------------------------------------------------------
1 |
33 |
34 |
35 |
50 |
51 |
52 |
53 |
--------------------------------------------------------------------------------
/src/components/conditional/LoadingMessage.vue:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
12 |
--------------------------------------------------------------------------------
/src/components/conditional/MobileMenu.vue:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/src/components/conditional/ToastMessage.vue:
--------------------------------------------------------------------------------
1 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | {{ toastMessage.text }}
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
--------------------------------------------------------------------------------
/src/components/conditional/UserMenu.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/src/components/home/HomeFilterMenu.vue:
--------------------------------------------------------------------------------
1 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
Filter Selection
72 |
73 |
74 |
Category
75 |
76 |
83 |
84 |
85 |
86 |
Diet
87 |
88 |
95 |
96 |
97 |
98 | Clear
99 | {{ confirmBtnTxt }}
100 |
101 |
102 |
103 |
104 |
105 |
--------------------------------------------------------------------------------
/src/components/home/HomeRecipeCard.vue:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
17 |
24 |
25 |
26 |
27 |
31 | {{ recipe.title }}
32 |
33 |
34 |
35 | {{ recipe.description }}
36 |
37 |
38 |
39 |
40 |
--------------------------------------------------------------------------------
/src/components/icon/IconResolver.vue:
--------------------------------------------------------------------------------
1 |
94 |
95 |
96 |
97 |
98 |
--------------------------------------------------------------------------------
/src/components/icon/calories.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/components/icon/duration.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/components/icon/filter/main.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/components/icon/filter/other.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/components/icon/grip-vertical.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/src/components/icon/happy.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/components/icon/hat.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/src/components/icon/loading.vue:
--------------------------------------------------------------------------------
1 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/components/icon/portions.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/components/icon/sad.vue:
--------------------------------------------------------------------------------
1 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/src/components/input/InputSelect.vue:
--------------------------------------------------------------------------------
1 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | {{ item }}
31 |
32 |
37 |
38 |
--------------------------------------------------------------------------------
/src/components/input/InputText.vue:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 |
17 |
18 | {{ name }}
19 |
20 |
29 |
30 |
31 |
32 |
--------------------------------------------------------------------------------
/src/components/input/InputToggle.vue:
--------------------------------------------------------------------------------
1 |
13 |
14 |
15 |
16 |
20 |
25 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/src/components/recipe/RecipeDiet.vue:
--------------------------------------------------------------------------------
1 |
51 |
52 |
53 |
54 |
Diet Selection
55 |
56 |
64 |
69 | ×
70 |
71 |
72 |
73 |
--------------------------------------------------------------------------------
/src/components/recipe/RecipeImage.vue:
--------------------------------------------------------------------------------
1 |
77 |
78 |
79 |
80 |
89 |
90 |
99 |
100 |
101 | Upload Image
102 | Remove Image
103 |
104 |
105 |
106 |
107 |
--------------------------------------------------------------------------------
/src/components/recipe/RecipeIngredients.vue:
--------------------------------------------------------------------------------
1 |
85 |
86 |
87 | Ingredients
88 |
89 |
103 |
104 |
105 |
106 |
107 |
108 |
116 |
117 |
118 |
119 |
120 | Add Ingredient
121 |
122 |
123 |
124 |
150 |
--------------------------------------------------------------------------------
/src/components/recipe/readonly/RecipeDiet.vue:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/src/components/recipe/readonly/RecipeIngredients.vue:
--------------------------------------------------------------------------------
1 |
24 |
25 |
26 | Ingredients
27 |
28 | Mode: {{ modeText }}
29 |
30 |
46 |
47 |
48 |
58 |
--------------------------------------------------------------------------------
/src/components/recipe/readonly/RecipeMeta.vue:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/src/components/user/UserRecipeCard.vue:
--------------------------------------------------------------------------------
1 |
16 |
17 |
18 |
19 |
20 |
26 |
35 |
36 |
37 | No Image
38 |
39 |
40 |
41 |
42 |
43 | Draft
44 |
45 |
46 | {{ recipe.title }}
47 |
48 |
49 |
{{ recipe.description }}
50 |
51 |
56 |
57 |
58 |
59 |
60 |
64 |
69 |
70 |
71 |
72 |
73 |
74 |
--------------------------------------------------------------------------------
/src/components/user/UserRecipeSorting.vue:
--------------------------------------------------------------------------------
1 |
54 |
55 |
56 |
57 |
Display:
58 |
68 | {{ item.text }}
69 |
70 |
71 |
--------------------------------------------------------------------------------
/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 |
2 |
3 | About This Website
4 |
5 | If you're into cooking, you might find some inspiration here 😁
6 |
7 | recept0r is a minimal open source recipes app built with vue.js. It's a serverless web application making use of Netlify functions to handle all backend functionality like database access and user authentication.
8 | It's designed to scale well while keeping operational costs at a bare minimum; all platforms and services used offer generous free tiers that are enough to host and operate the application you see here for zero runtime cost.
9 | At the moment, recept0r is a semi-public personal/family service. That means that even though public signup is in the code, it's currently disabled.
10 | Credits
11 |
12 |
13 |
14 |
15 |
16 |
17 | Design
18 | Sahar | website
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | Code
27 | Tom | website
28 |
29 |
30 |
31 | More Information
32 | This is FOSS, feel free to fork the project and deploy your own instance!
33 | Re-written from scratch in summer 2021, a year after its initial release; using Vite and Vue 3 with TypeScript now.
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/src/views/Home.vue:
--------------------------------------------------------------------------------
1 |
72 |
73 |
74 | All Recipes
75 |
76 | Loading recipes...
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 | Showing filtered recipes. Clear Filter
87 |
88 |
89 |
90 | No results for your search query :(
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
--------------------------------------------------------------------------------
/src/views/Profile.vue:
--------------------------------------------------------------------------------
1 |
66 |
67 |
68 |
88 |
--------------------------------------------------------------------------------
/src/views/Signup.vue:
--------------------------------------------------------------------------------
1 |
61 |
62 |
63 |
64 |
75 |
76 | Signing up for an account means that you explicitly agree with Netlify processing your email and IP address according to their data processing guidelines .
77 |
78 | We don't use analytics, cookies or tracking and we don't collect and/or store any personally identifiable information.
79 |
80 |
81 |
82 |
83 |
--------------------------------------------------------------------------------
/src/views/UserRecipes.vue:
--------------------------------------------------------------------------------
1 |
68 |
69 |
70 |
71 |
Recipes You Created
72 |
73 | Loading data...
74 |
75 |
76 |
It seems like you haven't created any recipes yet...
77 |
How about giving it a try?
78 |
Add Recipe
79 |
80 |
81 |
87 |
88 |
89 |
90 | No results for your search query :(
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------