├── .npmrc ├── assets ├── css │ └── main.css └── images │ ├── side1.png │ ├── side2.png │ ├── mobile-case.png │ ├── linktree-logo.png │ └── linktree-logo-icon.png ├── public ├── favicon.ico ├── pwa-192x192.png └── pwa-512x512.png ├── tsconfig.json ├── .gitignore ├── plugins ├── lodash.js └── axios.js ├── middleware ├── isLoggedOut.js └── isLoggedIn.js ├── tailwind.config.js ├── package.json ├── nuxt.config.ts ├── layouts ├── AuthLayout.vue └── AdminLayout.vue ├── components ├── TextInput.vue ├── AddLink.vue ├── AddLinkOverlay.vue ├── MobileSectionDisplay.vue ├── PreviewOverlay.vue ├── UpdateLinkOverlay.vue ├── CropperModal.vue └── LinkBox.vue ├── pages ├── admin │ ├── more.vue │ ├── preview.vue │ ├── index.vue │ └── apperance.vue ├── index.vue └── register.vue ├── app.vue ├── stores └── user.js └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /assets/css/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/John-Weeks-Dev/linktree-clone/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /assets/images/side1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/John-Weeks-Dev/linktree-clone/HEAD/assets/images/side1.png -------------------------------------------------------------------------------- /assets/images/side2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/John-Weeks-Dev/linktree-clone/HEAD/assets/images/side2.png -------------------------------------------------------------------------------- /public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/John-Weeks-Dev/linktree-clone/HEAD/public/pwa-192x192.png -------------------------------------------------------------------------------- /public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/John-Weeks-Dev/linktree-clone/HEAD/public/pwa-512x512.png -------------------------------------------------------------------------------- /assets/images/mobile-case.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/John-Weeks-Dev/linktree-clone/HEAD/assets/images/mobile-case.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log* 3 | .nuxt 4 | .nitro 5 | .cache 6 | .output 7 | .env 8 | dist 9 | .DS_Store 10 | -------------------------------------------------------------------------------- /assets/images/linktree-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/John-Weeks-Dev/linktree-clone/HEAD/assets/images/linktree-logo.png -------------------------------------------------------------------------------- /assets/images/linktree-logo-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/John-Weeks-Dev/linktree-clone/HEAD/assets/images/linktree-logo-icon.png -------------------------------------------------------------------------------- /plugins/lodash.js: -------------------------------------------------------------------------------- 1 | import lodash from "lodash" 2 | 3 | export default defineNuxtPlugin((NuxtApp) => { 4 | return { 5 | provide: { 6 | $lodash: lodash, 7 | }, 8 | } 9 | }) -------------------------------------------------------------------------------- /middleware/isLoggedOut.js: -------------------------------------------------------------------------------- 1 | import { useUserStore } from '~~/stores/user' 2 | 3 | export default defineNuxtRouteMiddleware((to, from) => { 4 | const userStore = useUserStore() 5 | 6 | if (!userStore.id) { 7 | return navigateTo('/') 8 | } 9 | }) -------------------------------------------------------------------------------- /plugins/axios.js: -------------------------------------------------------------------------------- 1 | import axios from "axios" 2 | 3 | export default defineNuxtPlugin((NuxtApp) => { 4 | // axios.defaults.baseURL = 'http://localhost:8000' 5 | axios.defaults.baseURL = 'https://api.johntest.site' 6 | axios.defaults.withCredentials = true; 7 | 8 | return { 9 | provide: { 10 | axios: axios 11 | }, 12 | } 13 | }) -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./components/**/*.{js,vue,ts}", 5 | "./layouts/**/*.vue", 6 | "./pages/**/*.vue", 7 | "./plugins/**/*.{js,ts}", 8 | "./nuxt.config.{js,ts}", 9 | "./app.vue", 10 | ], 11 | theme: { 12 | extend: {}, 13 | }, 14 | plugins: [], 15 | } 16 | -------------------------------------------------------------------------------- /middleware/isLoggedIn.js: -------------------------------------------------------------------------------- 1 | import { useUserStore } from '~~/stores/user' 2 | 3 | export default defineNuxtRouteMiddleware((to, from) => { 4 | const userStore = useUserStore() 5 | 6 | if (to.fullPath === '/' && userStore.id) { 7 | return navigateTo('/admin') 8 | } 9 | 10 | if (to.fullPath === '/register' && userStore.id) { 11 | return navigateTo('/admin') 12 | } 13 | }) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "nuxt build", 5 | "dev": "nuxt dev", 6 | "generate": "nuxt generate", 7 | "preview": "nuxt preview", 8 | "postinstall": "nuxt prepare", 9 | "start": "node .output/server/index.mjs" 10 | }, 11 | "devDependencies": { 12 | "@pinia-plugin-persistedstate/nuxt": "^1.1.1", 13 | "@vite-pwa/nuxt": "^0.0.7", 14 | "autoprefixer": "^10.4.14", 15 | "nuxt": "^3.3.1", 16 | "nuxt-icon": "^0.3.3", 17 | "nuxt-lodash": "^2.4.1", 18 | "postcss": "^8.4.21", 19 | "tailwindcss": "^3.2.7" 20 | }, 21 | "dependencies": { 22 | "@pinia/nuxt": "^0.4.7", 23 | "axios": "^1.3.4", 24 | "lodash": "^4.17.21", 25 | "pinia": "^2.0.33", 26 | "vue-advanced-cropper": "^2.8.8" 27 | }, 28 | "engines": { 29 | "node": "18.x" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | pages: true, 4 | experimental: { 5 | payloadExtraction: false 6 | }, 7 | css: ['~/assets/css/main.css'], 8 | postcss: { 9 | plugins: { 10 | tailwindcss: {}, 11 | autoprefixer: {}, 12 | }, 13 | }, 14 | modules: [ 15 | "nuxt-icon", 16 | "nuxt-lodash", 17 | "@pinia/nuxt", 18 | "@pinia-plugin-persistedstate/nuxt", 19 | "@vite-pwa/nuxt", 20 | ], 21 | pwa: { 22 | manifest: { 23 | name: "Linktree Clone", 24 | short_name: "Linktree Clone", 25 | description: "This is a Linktree Clone", 26 | theme_color: "#32CD32", 27 | icons: [ 28 | { 29 | src: "pwa-192x192.png", 30 | sizes: "192x192", 31 | type: "image/png", 32 | }, 33 | { 34 | src: "pwa-512x512.png", 35 | sizes: "512x512", 36 | type: "image/png", 37 | }, 38 | ], 39 | }, 40 | devOptions: { 41 | enabled: true, 42 | type: "module", 43 | }, 44 | }, 45 | app: { 46 | head: { 47 | charset: 'utf-8', 48 | viewport: 'width=device-width, initial-scale=1, maximum-scale=1', 49 | } 50 | } 51 | }); 52 | -------------------------------------------------------------------------------- /layouts/AuthLayout.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 42 | -------------------------------------------------------------------------------- /components/TextInput.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | -------------------------------------------------------------------------------- /pages/admin/more.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 60 | -------------------------------------------------------------------------------- /pages/admin/preview.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 67 | -------------------------------------------------------------------------------- /components/AddLink.vue: -------------------------------------------------------------------------------- 1 | 59 | 60 | 85 | -------------------------------------------------------------------------------- /pages/index.vue: -------------------------------------------------------------------------------- 1 | 58 | 59 | 89 | -------------------------------------------------------------------------------- /pages/admin/index.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 78 | -------------------------------------------------------------------------------- /components/AddLinkOverlay.vue: -------------------------------------------------------------------------------- 1 | 62 | 63 | 96 | -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 90 | -------------------------------------------------------------------------------- /components/MobileSectionDisplay.vue: -------------------------------------------------------------------------------- 1 | 91 | 92 | 96 | -------------------------------------------------------------------------------- /pages/register.vue: -------------------------------------------------------------------------------- 1 | 75 | 76 | 112 | -------------------------------------------------------------------------------- /components/PreviewOverlay.vue: -------------------------------------------------------------------------------- 1 | 101 | 102 | 108 | -------------------------------------------------------------------------------- /stores/user.js: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | import axios from '~~/plugins/axios' 3 | 4 | const $axios = axios().provide.axios 5 | 6 | export const useUserStore = defineStore('user', { 7 | state: () => ({ 8 | id: '', 9 | theme_id: '', 10 | name: '', 11 | email: '', 12 | image: '', 13 | bio: '', 14 | theme: null, 15 | colors: null, 16 | allLinks: null, 17 | isMobile: false, 18 | updatedLinkId: 0, 19 | addLinkOverlay: false, 20 | isPreviewOverlay: false, 21 | }), 22 | actions: { 23 | hidePageOverflow(val, id) { 24 | if (val) { 25 | document.body.style.overflow = 'hidden' 26 | if (id) { 27 | document.getElementById(id).style.overflow = 'hidden' 28 | } 29 | return 30 | } 31 | document.body.style.overflow = 'visible' 32 | if (id) { 33 | document.getElementById(id).style.overflow = 'visible' 34 | } 35 | }, 36 | 37 | allLowerCaseNoCaps(str) { 38 | return str.split(' ').join('').toLowerCase() 39 | }, 40 | 41 | async hasSessionExpired() { 42 | await $axios.interceptors.response.use((response) => { 43 | // Call was successful, continue. 44 | return response; 45 | }, (error) => { 46 | switch (error.response.status) { 47 | case 401: // Not logged in 48 | case 419: // Session expired 49 | case 503: // Down for maintenance 50 | // Bounce the user to the login screen with a redirect back 51 | this.resetState() 52 | window.location.href = '/'; 53 | break; 54 | case 500: 55 | alert('Oops, something went wrong! The team has been notified.'); 56 | break; 57 | default: 58 | // Allow individual requests to handle other errors 59 | return Promise.reject(error); 60 | } 61 | }); 62 | }, 63 | 64 | async getTokens() { 65 | await $axios.get('/sanctum/csrf-cookie') 66 | }, 67 | 68 | async login(email, password) { 69 | await $axios.post('/login', { 70 | email: email, 71 | password: password 72 | }) 73 | }, 74 | 75 | async register(name, email, password, confirmPassword) { 76 | await $axios.post('/register', { 77 | name: name, 78 | email: email, 79 | password: password, 80 | password_confirmation: confirmPassword 81 | }) 82 | }, 83 | 84 | async getUser() { 85 | let res = await $axios.get('/api/users') 86 | 87 | this.$state.id = res.data.id 88 | this.$state.theme_id = res.data.theme_id 89 | this.$state.name = res.data.name 90 | this.$state.bio = res.data.bio 91 | this.$state.image = res.data.image 92 | 93 | this.getUserTheme() 94 | }, 95 | 96 | async updateUserImage(data) { 97 | await $axios.post('/api/user-image', data) 98 | }, 99 | 100 | async updateLinkImage(data) { 101 | await $axios.post(`/api/link-image`, data) 102 | }, 103 | 104 | async deleteLink(id) { 105 | await $axios.delete(`/api/links/${id}`) 106 | }, 107 | 108 | getUserTheme() { 109 | this.$state.colors.forEach(color => { 110 | if (this.$state.theme_id === color.id) { 111 | this.$state.theme = color 112 | } 113 | }) 114 | }, 115 | 116 | async updateUserDetails(name, bio) { 117 | await $axios.patch(`/api/users/${this.$state.id}`, { 118 | name: name, 119 | bio: bio 120 | }) 121 | }, 122 | 123 | async updateTheme(themeId) { 124 | let res = await $axios.patch('/api/themes', { 125 | theme_id: themeId, 126 | }) 127 | this.$state.theme_id = res.data.theme_id 128 | this.getUserTheme() 129 | }, 130 | 131 | async getAllLinks() { 132 | let res = await $axios.get('/api/links') 133 | this.$state.allLinks = res.data 134 | }, 135 | 136 | async addLink(name, url) { 137 | await $axios.post('/api/links', { 138 | name: name, 139 | url: url 140 | }) 141 | }, 142 | 143 | async updateLink(id, name, url) { 144 | await $axios.patch(`/api/links/${id}`, { 145 | name: name, 146 | url: url 147 | }) 148 | }, 149 | 150 | async logout() { 151 | await $axios.post('/logout') 152 | this.resetState() 153 | }, 154 | 155 | resetState() { 156 | this.$state.id = '' 157 | this.$state.name = '' 158 | this.$state.email = '' 159 | this.$state.image = '' 160 | this.$state.bio = '' 161 | this.$state.theme_id = '' 162 | this.$state.theme = null 163 | this.$state.colors = null 164 | this.$state.allLinks = null 165 | this.$state.isMobile = false 166 | this.$state.updatedLinkId = 0 167 | this.$state.addLinkOverlay = false 168 | this.$state.isPreviewOverlay = false 169 | }, 170 | }, 171 | persist: true 172 | }) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Linktree Clone / PWA (linktree-clone) 2 | 3 | ### Learn how to build this! 4 | 5 | If you'd like a step by step guide on how to build this just **CLICK THE IMAGE BELOW** 6 | 7 | [![GO TO JOHN WEEKS DEV TUTORIAL VIDEOS](https://user-images.githubusercontent.com/108229029/228964328-b0e75187-32de-4b29-8fd0-d1546237b1fd.png)](https://www.youtube.com/watch?v=NtsbjB8QD3Y) 8 | 9 | Come and check out my YOUTUBE channel for lots more tutorials -> https://www.youtube.com/@johnweeksdev 10 | 11 | **LIKE**, **SUBSCRIBE**, and **SMASH THE NOTIFICATION BELL**!!! 12 | 13 | ## NOTE 14 | 15 | ### For this Linktree Clone to work you'll need the API/Backend: 16 | 17 | Linktree Clone API: https://github.com/John-Weeks-Dev/linktree-clone-api 18 | 19 | ## App Setup (localhost) 20 | 21 | ``` 22 | git clone https://github.com/John-Weeks-Dev/linktree-clone.git 23 | 24 | npm i 25 | 26 | npm run dev 27 | ``` 28 | Inside Plugins/axios.js make sure the baseUrl is the same as your API. 29 | 30 | Screenshot 2023-03-15 at 00 14 21 31 | 32 | You should be good to go! 33 | 34 | ## Extra Info 35 | 36 | In the tutorial I show you what you need to edit in your Nginx config file. 37 | 38 | This example is in Laravel Forge: 39 | 40 | 1. In the frontend open the "Edit Nginx Configuration" 41 | 42 | 43 | 44 | 45 | 2. Update the location section to this. 46 | 47 | 48 | 49 | # Application Images 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | # PWA Images 67 | 68 | 69 | 70 | 71 | 72 | 73 |
74 | 75 | 76 | 77 |
78 | 79 |
80 | 81 | 82 | 83 |
84 | 85 |
86 | 87 | 88 | 89 |
90 | 91 |
92 | 93 | 94 | 95 |
96 | 97 |
98 | 99 | 100 | 101 |
102 | 103 |
104 | 105 | 106 |
107 | 108 | -------------------------------------------------------------------------------- /components/UpdateLinkOverlay.vue: -------------------------------------------------------------------------------- 1 | 130 | 131 | 250 | -------------------------------------------------------------------------------- /pages/admin/apperance.vue: -------------------------------------------------------------------------------- 1 | 142 | 143 | 200 | -------------------------------------------------------------------------------- /components/CropperModal.vue: -------------------------------------------------------------------------------- 1 | 180 | 181 | -------------------------------------------------------------------------------- /layouts/AdminLayout.vue: -------------------------------------------------------------------------------- 1 | 236 | 237 | 324 | -------------------------------------------------------------------------------- /components/LinkBox.vue: -------------------------------------------------------------------------------- 1 | 221 | 222 | 379 | --------------------------------------------------------------------------------