├── .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 |
2 |
3 |
4 |
5 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |

23 |

28 |
29 |
30 |
31 |
32 |
42 |
--------------------------------------------------------------------------------
/components/TextInput.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
28 |
29 |
30 | {{ error }}
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/pages/admin/more.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
21 |
22 |
23 |
24 |
25 |
26 |
60 |
--------------------------------------------------------------------------------
/pages/admin/preview.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
13 |
14 |
15 |
![]()
19 |
20 |
24 | @{{ userStore.allLowerCaseNoCaps(userStore.name) }}
25 |
26 |
27 |
31 |
32 | {{ userStore.bio }}
33 |
34 |
35 |
36 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
67 |
--------------------------------------------------------------------------------
/components/AddLink.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
Enter URL
6 |
16 |
17 |
18 |
57 |
58 |
59 |
60 |
85 |
--------------------------------------------------------------------------------
/pages/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
45 |
46 |
47 | Don't have an account?
48 |
52 | Sign up
53 |
54 |
55 |
56 |
57 |
58 |
59 |
89 |
--------------------------------------------------------------------------------
/pages/admin/index.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
10 |
25 |
26 |
34 |
35 |
36 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
78 |
--------------------------------------------------------------------------------
/components/AddLinkOverlay.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
18 |
19 |
Add New Link
20 |
21 |
22 |
Enter URL
23 |
24 |
60 |
61 |
62 |
63 |
96 |
--------------------------------------------------------------------------------
/app.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
90 |
--------------------------------------------------------------------------------
/components/MobileSectionDisplay.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
24 |

28 |
29 |
33 |
34 |
35 |
![]()
39 |
40 |
44 | @{{ userStore.allLowerCaseNoCaps(userStore.name) }}
45 |
46 |
47 |
51 |
52 | {{ userStore.bio }}
53 |
54 |
55 |
56 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
96 |
--------------------------------------------------------------------------------
/pages/register.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
7 |
8 |
62 |
63 |
64 | Already have an account?
65 |
69 | Log in
70 |
71 |
72 |
73 |
74 |
75 |
76 |
112 |
--------------------------------------------------------------------------------
/components/PreviewOverlay.vue:
--------------------------------------------------------------------------------
1 |
2 |
7 |
11 |
12 |
13 |
19 |
20 |
21 |
22 |
23 |
26 |
27 |
31 |
32 |
36 |
37 |
38 |
39 |
40 |
44 |
45 |
46 |
![]()
50 |
51 |
55 | @{{ userStore.allLowerCaseNoCaps(userStore.name) }}
56 |
57 |
58 |
62 |
63 | {{ userStore.bio }}
64 |
65 |
66 |
67 |
83 |
84 |
85 |
86 |
87 |
88 |
92 |
98 |
99 |
100 |
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 | [](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 |
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 |
78 |
79 |
84 |
85 |
90 |
91 |
96 |
97 |
102 |
103 |
104 |

105 |

106 |
107 |
108 |
--------------------------------------------------------------------------------
/components/UpdateLinkOverlay.vue:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
8 |
15 |
16 |
17 |
18 |
21 |
22 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
38 | {{ name }}
39 |
40 |
47 |
48 |
55 |
59 | {{ errors.name[0] }}
60 |
61 |
62 |
63 |
64 |
65 |
69 | {{ url }}
70 |
71 |
78 |
79 |
86 |
90 | {{ errors.url[0] }}
91 |
92 |
93 |
94 |
95 |
113 |
114 |
![]()
120 |
121 |
122 |
128 |
129 |
130 |
131 |
250 |
--------------------------------------------------------------------------------
/pages/admin/apperance.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
8 |
9 |
10 |
11 |
15 | Profile
16 |
17 |
18 |
19 |
20 |
![]()
24 |
25 |
26 |
44 |
45 |
46 |
47 |
48 |
55 |
56 |
57 |
81 |
82 | {{ bioLengthComputed }}/80
83 |
84 |
85 |
86 |
87 |
88 |
89 |
93 | Themes
94 |
95 |
96 |
97 |
98 |
99 |
107 |
111 |
115 |
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 |
124 |
{{ item.name }}
125 |
126 |
127 |
128 |
129 |
130 |
131 |
132 |
133 |
138 |
139 |
140 |
141 |
142 |
143 |
200 |
--------------------------------------------------------------------------------
/components/CropperModal.vue:
--------------------------------------------------------------------------------
1 |
2 |
179 |
180 |
181 |
--------------------------------------------------------------------------------
/layouts/AdminLayout.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
10 |
11 |
15 |
16 |
17 |
18 |
22 |
28 |
29 | {{ link.name }}
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
40 |
41 |
45 |
46 |
50 |
51 |
60 |
61 |
62 |
63 |
79 |
86 |
87 |
88 |
93 |
97 |
98 |
99 |
117 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
149 |
156 |
157 |
158 |
163 |
164 |
165 | {{ currentMobilePage() }}
166 |
167 |
168 |
169 |
170 |
174 |
175 |
178 |
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
192 |
193 |
194 |
232 |
233 |
234 |
235 |
236 |
237 |
324 |
--------------------------------------------------------------------------------
/components/LinkBox.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
14 |
15 |
16 |
24 | {{ link.name }}
25 |
26 |
33 |
34 |
35 |
36 |
43 |
44 |
45 |
46 |
47 |
48 |
55 |
56 |
64 | {{ link.url }}
65 |
66 |
74 |
75 |
76 |
77 |
78 |
79 |
93 |
94 |
95 |
107 |
108 |
109 |
110 |
111 |
112 |
162 |
163 |
212 |
213 |
219 |
220 |
221 |
222 |
379 |
--------------------------------------------------------------------------------