diff --git a/README.md b/README.md
index 524671e..ea79b34 100644
--- a/README.md
+++ b/README.md
@@ -197,10 +197,12 @@ This is the roadmap I will be following for the complete migration to v3:
⬆️ **Dashboard (tags):**
-- ⚙️ Add search links by tags in the dashboard.
-- ⚙️ Create a new tag.
-- ⚙️ Update a tag.
-- ⚙️ Delete a tag.
+- ✅ Add search links by tags in the dashboard.
+- 🔔 Create a new tag.
+- ✅ Delete a tag.
+- ⚙️ Update the tags of a link.
+
+🔔 Add option to change tag color.
⬆️ **Dashboard (settings):**
diff --git a/package.json b/package.json
index 8536345..51170f4 100644
--- a/package.json
+++ b/package.json
@@ -16,15 +16,15 @@
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev --name init",
"db:studio": "prisma studio",
- "db:push": "turso db shell slug < ./prisma/migrations/20240328133022_init/migration.sql",
+ "db:push": "turso db shell slug < ./prisma/migrations/20240331131037_init/migration.sql",
"db:pscale:dump": "pscale database dump databasename databasebranch",
- "db:turso:dump": "turso db shell databasename .dump > dump.sql"
+ "db:turso:dump": "turso db shell slug .dump > dump.sql"
},
"dependencies": {
"@auth/core": "0.28.1",
"@auth/prisma-adapter": "1.5.1",
"@hookform/resolvers": "3.3.4",
- "@libsql/client": "0.6.0",
+ "@libsql/client": "0.5.6",
"@prisma/adapter-libsql": "5.11.0",
"@prisma/client": "5.11.0",
"@radix-ui/react-collapsible": "1.0.3",
@@ -32,12 +32,14 @@
"@radix-ui/react-dropdown-menu": "2.0.6",
"@radix-ui/react-label": "2.0.2",
"@radix-ui/react-popover": "1.0.7",
+ "@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "1.0.2",
"@radix-ui/react-tabs": "1.0.4",
"@radix-ui/react-tooltip": "1.0.7",
"@t3-oss/env-nextjs": "0.9.2",
"bcryptjs": "2.4.3",
"boring-avatars": "1.10.1",
+ "cheerio": "1.0.0-rc.12",
"class-variance-authority": "0.7.0",
"clsx": "2.1.0",
"cmdk": "1.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d9736ca..1450e61 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -15,11 +15,11 @@ dependencies:
specifier: 3.3.4
version: 3.3.4(react-hook-form@7.51.2)
'@libsql/client':
- specifier: 0.6.0
- version: 0.6.0
+ specifier: 0.5.6
+ version: 0.5.6
'@prisma/adapter-libsql':
specifier: 5.11.0
- version: 5.11.0(@libsql/client@0.6.0)
+ version: 5.11.0(@libsql/client@0.5.6)
'@prisma/client':
specifier: 5.11.0
version: 5.11.0(prisma@5.11.0)
@@ -38,6 +38,9 @@ dependencies:
'@radix-ui/react-popover':
specifier: 1.0.7
version: 1.0.7(@types/react-dom@18.2.23)(@types/react@18.2.73)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-select':
+ specifier: ^2.0.0
+ version: 2.0.0(@types/react-dom@18.2.23)(@types/react@18.2.73)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-slot':
specifier: 1.0.2
version: 1.0.2(@types/react@18.2.73)(react@18.2.0)
@@ -56,6 +59,9 @@ dependencies:
boring-avatars:
specifier: 1.10.1
version: 1.10.1
+ cheerio:
+ specifier: 1.0.0-rc.12
+ version: 1.0.0-rc.12
class-variance-authority:
specifier: 0.7.0
version: 0.7.0
@@ -342,20 +348,21 @@ packages:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.4.15
- /@libsql/client@0.6.0:
- resolution: {integrity: sha512-qhQzTG/y2IEVbL3+9PULDvlQFWJ/RnjFXECr/Nc3nRngGiiMysDaOV5VUzYk7DulUX98EA4wi+z3FspKrUplUA==}
+ /@libsql/client@0.5.6:
+ resolution: {integrity: sha512-UBjmDoxz75Z2sHdP+ETCROpeLA/77VMesiff8R4UWK1rnaWbh6/YoCLDILMJL3Rh0udQeKxjL8MjXthqohax+g==}
dependencies:
- '@libsql/core': 0.6.0
- '@libsql/hrana-client': 0.6.0
+ '@libsql/core': 0.5.6
+ '@libsql/hrana-client': 0.5.6
js-base64: 3.7.7
libsql: 0.3.10
transitivePeerDependencies:
- bufferutil
+ - encoding
- utf-8-validate
dev: false
- /@libsql/core@0.6.0:
- resolution: {integrity: sha512-affAB8vSqQwqI9NBDJ5uJCVaHoOAS2pOpbv1kWConh1SBbmJBnHHd4KG73RAJ2sgd2+NbT9WA+XJBqxgp28YSw==}
+ /@libsql/core@0.5.6:
+ resolution: {integrity: sha512-3vicUAydq6jPth410n4AsHHm1n2psTwvkSf94nfJlSXutGSZsl0updn2N/mJBgqUHkbuFoWZtlMifF0SwBj1xQ==}
dependencies:
js-base64: 3.7.7
dev: false
@@ -376,20 +383,26 @@ packages:
dev: false
optional: true
- /@libsql/hrana-client@0.6.0:
- resolution: {integrity: sha512-k+fqzdjqg3IvWfKmVJK5StsbjeTcyNAXFelUbXbGNz3yH1gEVT9mZ6kmhsIXP30ZSyVV0AE1Gi25p82mxC9hwg==}
+ /@libsql/hrana-client@0.5.6:
+ resolution: {integrity: sha512-mjQoAmejZ1atG+M3YR2ZW+rg6ceBByH/S/h17ZoYZkqbWrvohFhXyz2LFxj++ARMoY9m6w3RJJIRdJdmnEUlFg==}
dependencies:
- '@libsql/isomorphic-fetch': 0.2.1
+ '@libsql/isomorphic-fetch': 0.1.12
'@libsql/isomorphic-ws': 0.1.5
js-base64: 3.7.7
node-fetch: 3.3.2
transitivePeerDependencies:
- bufferutil
+ - encoding
- utf-8-validate
dev: false
- /@libsql/isomorphic-fetch@0.2.1:
- resolution: {integrity: sha512-Sv07QP1Aw8A5OOrmKgRUBKe2fFhF2hpGJhtHe3d1aRnTESZCGkn//0zDycMKTGamVWb3oLYRroOsCV8Ukes9GA==}
+ /@libsql/isomorphic-fetch@0.1.12:
+ resolution: {integrity: sha512-MRo4UcmjAGAa3ac56LoD5OE13m2p0lu0VEtZC2NZMcogM/jc5fU9YtMQ3qbPjFJ+u2BBjFZgMPkQaLS1dlMhpg==}
+ dependencies:
+ '@types/node-fetch': 2.6.11
+ node-fetch: 2.7.0
+ transitivePeerDependencies:
+ - encoding
dev: false
/@libsql/isomorphic-ws@0.1.5:
@@ -565,12 +578,12 @@ packages:
requiresBuild: true
optional: true
- /@prisma/adapter-libsql@5.11.0(@libsql/client@0.6.0):
+ /@prisma/adapter-libsql@5.11.0(@libsql/client@0.5.6):
resolution: {integrity: sha512-Q2GxamPew8AviqO/6kQskqvuh4Y0bGj8hbMefjD2+3Q3geNfE/W6XbfH0tHLOK6gQgawoAONrRgbRfoJPzUvQA==}
peerDependencies:
'@libsql/client': ^0.3.5 || ^0.4.0 || ^0.5.0
dependencies:
- '@libsql/client': 0.6.0
+ '@libsql/client': 0.5.6
'@prisma/driver-adapter-utils': 5.11.0
async-mutex: 0.4.1
dev: false
@@ -621,6 +634,12 @@ packages:
dependencies:
'@prisma/debug': 5.11.0
+ /@radix-ui/number@1.0.1:
+ resolution: {integrity: sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==}
+ dependencies:
+ '@babel/runtime': 7.24.1
+ dev: false
+
/@radix-ui/primitive@1.0.1:
resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==}
dependencies:
@@ -1097,6 +1116,47 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: false
+ /@radix-ui/react-select@2.0.0(@types/react-dom@18.2.23)(@types/react@18.2.73)(react-dom@18.2.0)(react@18.2.0):
+ resolution: {integrity: sha512-RH5b7af4oHtkcHS7pG6Sgv5rk5Wxa7XI8W5gvB1N/yiuDGZxko1ynvOiVhFM7Cis2A8zxF9bTOUVbRDzPepe6w==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0
+ react-dom: ^16.8 || ^17.0 || ^18.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.24.1
+ '@radix-ui/number': 1.0.1
+ '@radix-ui/primitive': 1.0.1
+ '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.23)(@types/react@18.2.73)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.73)(react@18.2.0)
+ '@radix-ui/react-context': 1.0.1(@types/react@18.2.73)(react@18.2.0)
+ '@radix-ui/react-direction': 1.0.1(@types/react@18.2.73)(react@18.2.0)
+ '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.23)(@types/react@18.2.73)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.2.73)(react@18.2.0)
+ '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.2.23)(@types/react@18.2.73)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-id': 1.0.1(@types/react@18.2.73)(react@18.2.0)
+ '@radix-ui/react-popper': 1.1.3(@types/react-dom@18.2.23)(@types/react@18.2.73)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.23)(@types/react@18.2.73)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.23)(@types/react@18.2.73)(react-dom@18.2.0)(react@18.2.0)
+ '@radix-ui/react-slot': 1.0.2(@types/react@18.2.73)(react@18.2.0)
+ '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.73)(react@18.2.0)
+ '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.73)(react@18.2.0)
+ '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.73)(react@18.2.0)
+ '@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.73)(react@18.2.0)
+ '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.23)(@types/react@18.2.73)(react-dom@18.2.0)(react@18.2.0)
+ '@types/react': 18.2.73
+ '@types/react-dom': 18.2.23
+ aria-hidden: 1.2.4
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ react-remove-scroll: 2.5.5(@types/react@18.2.73)(react@18.2.0)
+ dev: false
+
/@radix-ui/react-slot@1.0.2(@types/react@18.2.73)(react@18.2.0):
resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==}
peerDependencies:
@@ -1230,6 +1290,20 @@ packages:
react: 18.2.0
dev: false
+ /@radix-ui/react-use-previous@1.0.1(@types/react@18.2.73)(react@18.2.0):
+ resolution: {integrity: sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.8 || ^17.0 || ^18.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ dependencies:
+ '@babel/runtime': 7.24.1
+ '@types/react': 18.2.73
+ react: 18.2.0
+ dev: false
+
/@radix-ui/react-use-rect@1.0.1(@types/react@18.2.73)(react@18.2.0):
resolution: {integrity: sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==}
peerDependencies:
@@ -1363,6 +1437,13 @@ packages:
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
dev: false
+ /@types/node-fetch@2.6.11:
+ resolution: {integrity: sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==}
+ dependencies:
+ '@types/node': 20.12.2
+ form-data: 4.0.0
+ dev: false
+
/@types/node@20.12.2:
resolution: {integrity: sha512-zQ0NYO87hyN6Xrclcqp7f8ZbXNbRfoGWNcMvHTPQp9UUrwI0mI7XBz+cu7/W6/VClYo2g63B0cjull/srU7LgQ==}
dependencies:
@@ -1768,6 +1849,10 @@ packages:
tslib: 2.6.2
dev: false
+ /asynckit@0.4.0:
+ resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
+ dev: false
+
/autoprefixer@10.4.19(postcss@8.4.38):
resolution: {integrity: sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==}
engines: {node: ^10 || ^12 || >=14}
@@ -1813,6 +1898,10 @@ packages:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
+ /boolbase@1.0.0:
+ resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
+ dev: false
+
/boring-avatars@1.10.1:
resolution: {integrity: sha512-WcgHDeLrazCR03CDPEvCchLsUecZAZvs4F6FnMiGlTEjyQQf15Q5TRl4EUaAQ1dacvhPq7lC9EOTWkCojQ6few==}
dev: false
@@ -1881,6 +1970,30 @@ packages:
ansi-styles: 4.3.0
supports-color: 7.2.0
+ /cheerio-select@2.1.0:
+ resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==}
+ dependencies:
+ boolbase: 1.0.0
+ css-select: 5.1.0
+ css-what: 6.1.0
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ domutils: 3.1.0
+ dev: false
+
+ /cheerio@1.0.0-rc.12:
+ resolution: {integrity: sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==}
+ engines: {node: '>= 6'}
+ dependencies:
+ cheerio-select: 2.1.0
+ dom-serializer: 2.0.0
+ domhandler: 5.0.3
+ domutils: 3.1.0
+ htmlparser2: 8.0.2
+ parse5: 7.1.2
+ parse5-htmlparser2-tree-adapter: 7.0.0
+ dev: false
+
/chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
@@ -1939,6 +2052,13 @@ packages:
/color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
+ /combined-stream@1.0.8:
+ resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
+ engines: {node: '>= 0.8'}
+ dependencies:
+ delayed-stream: 1.0.0
+ dev: false
+
/commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
@@ -1959,6 +2079,21 @@ packages:
shebang-command: 2.0.0
which: 2.0.2
+ /css-select@5.1.0:
+ resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==}
+ dependencies:
+ boolbase: 1.0.0
+ css-what: 6.1.0
+ domhandler: 5.0.3
+ domutils: 3.1.0
+ nth-check: 2.1.1
+ dev: false
+
+ /css-what@6.1.0:
+ resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==}
+ engines: {node: '>= 6'}
+ dev: false
+
/cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -2050,6 +2185,11 @@ packages:
object-keys: 1.1.1
dev: false
+ /delayed-stream@1.0.0:
+ resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
+ engines: {node: '>=0.4.0'}
+ dev: false
+
/dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
@@ -2089,6 +2229,33 @@ packages:
dependencies:
esutils: 2.0.3
+ /dom-serializer@2.0.0:
+ resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ entities: 4.5.0
+ dev: false
+
+ /domelementtype@2.3.0:
+ resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
+ dev: false
+
+ /domhandler@5.0.3:
+ resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
+ engines: {node: '>= 4'}
+ dependencies:
+ domelementtype: 2.3.0
+ dev: false
+
+ /domutils@3.1.0:
+ resolution: {integrity: sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==}
+ dependencies:
+ dom-serializer: 2.0.0
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ dev: false
+
/eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
@@ -2110,6 +2277,11 @@ packages:
tapable: 2.2.1
dev: false
+ /entities@4.5.0:
+ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+ engines: {node: '>=0.12'}
+ dev: false
+
/es-abstract@1.23.3:
resolution: {integrity: sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==}
engines: {node: '>= 0.4'}
@@ -2607,6 +2779,15 @@ packages:
cross-spawn: 7.0.3
signal-exit: 4.1.0
+ /form-data@4.0.0:
+ resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
+ engines: {node: '>= 6'}
+ dependencies:
+ asynckit: 0.4.0
+ combined-stream: 1.0.8
+ mime-types: 2.1.35
+ dev: false
+
/formdata-polyfill@4.0.10:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
engines: {node: '>=12.20.0'}
@@ -2795,6 +2976,15 @@ packages:
dependencies:
function-bind: 1.1.2
+ /htmlparser2@8.0.2:
+ resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ domutils: 3.1.0
+ entities: 4.5.0
+ dev: false
+
/ignore@5.3.1:
resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==}
engines: {node: '>= 4'}
@@ -3186,6 +3376,18 @@ packages:
braces: 3.0.2
picomatch: 2.3.1
+ /mime-db@1.52.0:
+ resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
+ engines: {node: '>= 0.6'}
+ dev: false
+
+ /mime-types@2.1.35:
+ resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
+ engines: {node: '>= 0.6'}
+ dependencies:
+ mime-db: 1.52.0
+ dev: false
+
/minimatch@3.1.2:
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
dependencies:
@@ -3308,6 +3510,18 @@ packages:
engines: {node: '>=10.5.0'}
dev: false
+ /node-fetch@2.7.0:
+ resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
+ engines: {node: 4.x || >=6.0.0}
+ peerDependencies:
+ encoding: ^0.1.0
+ peerDependenciesMeta:
+ encoding:
+ optional: true
+ dependencies:
+ whatwg-url: 5.0.0
+ dev: false
+
/node-fetch@3.3.2:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -3330,6 +3544,12 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
+ /nth-check@2.1.1:
+ resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
+ dependencies:
+ boolbase: 1.0.0
+ dev: false
+
/oauth4webapi@2.10.4:
resolution: {integrity: sha512-DSoj8QoChzOCQlJkRmYxAJCIpnXFW32R0Uq7avyghIeB6iJq0XAblOD7pcq3mx4WEBDwMuKr0Y1qveCBleG2Xw==}
dev: false
@@ -3441,6 +3661,19 @@ packages:
dependencies:
callsites: 3.1.0
+ /parse5-htmlparser2-tree-adapter@7.0.0:
+ resolution: {integrity: sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==}
+ dependencies:
+ domhandler: 5.0.3
+ parse5: 7.1.2
+ dev: false
+
+ /parse5@7.1.2:
+ resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==}
+ dependencies:
+ entities: 4.5.0
+ dev: false
+
/path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@@ -4147,6 +4380,10 @@ packages:
dependencies:
is-number: 7.0.0
+ /tr46@0.0.3:
+ resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
+ dev: false
+
/ts-api-utils@1.3.0(typescript@5.4.3):
resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==}
engines: {node: '>=16'}
@@ -4306,6 +4543,17 @@ packages:
engines: {node: '>= 8'}
dev: false
+ /webidl-conversions@3.0.1:
+ resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
+ dev: false
+
+ /whatwg-url@5.0.0:
+ resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
+ dependencies:
+ tr46: 0.0.3
+ webidl-conversions: 3.0.1
+ dev: false
+
/which-boxed-primitive@1.0.2:
resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==}
dependencies:
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 3fee9ee..e649fb5 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -9,35 +9,42 @@ datasource db {
}
model Links {
- id String @id @default(cuid())
+ id String @id @default(cuid())
url String
- slug String @unique
+ slug String @unique
description String?
- createdAt DateTime @default(now())
- createdBy User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
+ createdAt DateTime @default(now())
+ createdBy User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
creatorId String
- tag Tags? @relation(fields: [tagId], references: [id], onDelete: SetNull)
- tagId String?
- clicks Int @default(0)
+ clicks Int @default(0)
lastClicked DateTime?
+ tags LinkTags[]
@@index(slug)
@@index([creatorId])
- @@index([tagId])
}
model Tags {
- id String @id @default(cuid())
- name String
- description String?
- createdAt DateTime @default(now())
- createdBy User? @relation(fields: [creatorId], references: [id])
- creatorId String
- links Links[]
+ id String @id @default(cuid())
+ name String
+ color String?
+ createdAt DateTime @default(now())
+ createdBy User? @relation(fields: [creatorId], references: [id])
+ creatorId String
+ links LinkTags[]
@@index([creatorId])
}
+model LinkTags {
+ link Links @relation(fields: [linkId], references: [id], onDelete: Cascade)
+ linkId String
+ tag Tags @relation(fields: [tagId], references: [id], onDelete: Cascade)
+ tagId String
+
+ @@id([linkId, tagId])
+}
+
model Account {
id String @id @default(cuid())
userId String
diff --git a/src/app/api/url/route.ts b/src/app/api/url/route.ts
deleted file mode 100644
index ed4bc44..0000000
--- a/src/app/api/url/route.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-import { Prisma } from "@prisma/client";
-import { NextResponse } from "next/server";
-import { db } from "@/server/db";
-
-export const GET = async (req: Request) => {
- const url = new URL(req.url);
- const params = url.searchParams.get("slug");
- const newHeaders = new Headers(req.headers);
-
- // If no slug provided (500):
- if (!params || typeof params !== "string") {
- return NextResponse.json(
- { error: "🚧 Error: No slug provided." },
- { status: 500 },
- );
- }
-
- try {
- const getLinkFromServer = await db.links.findUnique({
- where: {
- slug: params,
- },
- });
-
- if (!getLinkFromServer) {
- return NextResponse.json(
- { error: "Error: Slug not found or invalid." },
- { status: 404 },
- );
- }
-
- await db.links.update({
- where: {
- id: getLinkFromServer.id,
- },
- data: {
- clicks: {
- increment: 1,
- },
- },
- });
-
- newHeaders.set("cache-control", "public, max-age=31536000, immutable");
-
- return NextResponse.json(getLinkFromServer, {
- headers: newHeaders,
- });
- } catch (error) {
- if (error instanceof Prisma.PrismaClientKnownRequestError) {
- console.log(`🚧 Error: ${error.message}`);
- return NextResponse.json({ message: error.message }, { status: 400 });
- }
- return NextResponse.json(
- { message: "Something went wrong." },
- { status: 500 },
- );
- }
-};
diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx
index 4b089fc..c32667c 100644
--- a/src/app/dashboard/layout.tsx
+++ b/src/app/dashboard/layout.tsx
@@ -1,10 +1,6 @@
import type { ReactNode } from "react";
-import { Button } from "@/ui/button";
-import { PlusIcon } from "lucide-react";
-
import DashboardRoutesComponent from "@/components/dashboard-routes";
-import { CreateLink } from "@/components/links/create-link";
import Footer from "@/components/layout/footer";
import { cn } from "@/utils";
@@ -21,16 +17,10 @@ const DashboardLayout = (props: DashboardLayoutProps) => {
-
-
-
-
+
{props.children}
diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx
index c2a2a2e..950a2a6 100644
--- a/src/app/dashboard/page.tsx
+++ b/src/app/dashboard/page.tsx
@@ -1,13 +1,14 @@
import type { Metadata } from "next";
-import { getLinksByUser } from "@/server/queries";
+import { getLinksAndTagsByUser } from "@/server/queries";
import CardLink from "@/components/links/card-link";
-import LinksLimit from "@/components/links/links-limit";
import SearchLinks from "@/components/links/search-link";
import { CreateLink } from "@/components/links/create-link";
import { Button } from "@/ui/button";
import { PackageOpenIcon, PlusIcon, SparklesIcon } from "lucide-react";
+import SearchTag from "@/components/tags/search-tags";
+import LinksLimit from "@/components/links/links-limit";
export const metadata: Metadata = {
title: "Dashboard",
@@ -18,25 +19,48 @@ const DashboardPage = async ({
}: {
searchParams?: {
search?: string;
+ tag?: string;
};
}) => {
- const data = await getLinksByUser();
- const query = searchParams?.search;
+ const data = await getLinksAndTagsByUser();
+ const searchLink = searchParams?.search;
+ const searchTag = searchParams?.tag;
if (!data?.links) {
return Error
;
}
const filteredLinks = data.links.filter((link) => {
- if (!query) return true;
- return link.slug.includes(query);
+ if (!searchLink && !searchTag) return true;
+
+ // Filter links by search slug
+ const matchSlug = !searchLink || link.slug.includes(searchLink);
+
+ // Filter links by search tag
+ const matchTag =
+ !searchTag || link.tags.some((tag) => tag.tagId === searchTag);
+
+ return matchSlug && matchTag;
});
return (
-
+
-
+
+
+
+
+
+
+
{filteredLinks
@@ -46,30 +70,39 @@ const DashboardPage = async ({
);
})
.map((link) => {
- return ;
+ return (
+
+ );
})}
{filteredLinks.length === 0 && (
- {query ? (
+ {searchLink ? (
) : (
)}
- {query ? (
+ {searchLink ? (
- No links found with {query}{" "}
- slug
+ No links found with{" "}
+ {searchLink} slug
) : (
-
Start creating your first link:
+
+ {searchTag ? "No links found with this tag" : "No links found"}
+
)}
-
+
diff --git a/src/components/dashboard-routes.tsx b/src/components/dashboard-routes.tsx
index 7792b69..22d5d29 100644
--- a/src/components/dashboard-routes.tsx
+++ b/src/components/dashboard-routes.tsx
@@ -22,13 +22,13 @@ const DashboardRoutesComponent = () => {
const pathname = usePathname();
return (
-
+
{DashboardRoutes.map((route) => (
{
+const CardLink = ({ linkInfo, linkTags, tagsInfo }: CardLinkProps) => {
+ const cardTagsInfo = tagsInfo.filter((tag) =>
+ linkTags.some((linkTag) => linkTag.tagId === tag.id),
+ );
+
return (
@@ -78,6 +85,8 @@ const CardLink = ({ linkInfo }: CardLinkProps) => {
}
link={linkInfo}
+ linkTags={cardTagsInfo}
+ allTags={tagsInfo}
/>
{
{linkInfo.url}
-
-
- {linkInfo.description}
-
-
-
- Info
-
+
+
+ {linkTags.length > 0 && (
+
+ {linkTags.map((tag) => {
+ const tagInfo = tagsInfo.find((t) => t.id === tag.tagId);
+ return (
+
+ {tagInfo?.name}
+
+ );
+ })}
+
+ )}
+
+ {linkInfo.description}
+
+
+
+ Info
+
+
{formatDate(linkInfo.createdAt)}
diff --git a/src/components/links/create-link.tsx b/src/components/links/create-link.tsx
index d2b3579..3e5d31b 100644
--- a/src/components/links/create-link.tsx
+++ b/src/components/links/create-link.tsx
@@ -1,6 +1,8 @@
"use client";
import type { z } from "zod";
+import type { Tags } from "@prisma/client";
+
import { CreateLinkSchema } from "@/server/schemas";
import { useState, type ReactNode } from "react";
import { useForm } from "react-hook-form";
@@ -31,10 +33,13 @@ import {
} from "@/ui/form";
import { Input, Textarea } from "@/ui/input";
import { LoaderIcon, RocketIcon, ShuffleIcon } from "lucide-react";
+import { insertTagToLink } from "@/server/actions/tags";
+import SelectTagsLink from "./select-tags-link";
interface CreateLinkProps {
children: ReactNode;
slug?: string;
+ tags: Tags[];
}
export function CreateLink(props: CreateLinkProps) {
@@ -42,6 +47,7 @@ export function CreateLink(props: CreateLinkProps) {
const [open, setOpen] = useState(false);
const [message, setMessage] = useState("");
const [isError, setError] = useState(false);
+ const [selectedTags, setSelectedTags] = useState([]);
// Main form:
const form = useForm>({
@@ -53,6 +59,24 @@ export function CreateLink(props: CreateLinkProps) {
},
});
+ // Add tags to the form:
+ const handleAddTags = (tagId: string) => {
+ if (selectedTags.includes(tagId)) {
+ setSelectedTags(selectedTags.filter((tag) => tag !== tagId));
+ return;
+ }
+
+ if (selectedTags.length >= 2) {
+ toast.error("You can't add more than 2 tags to a link.");
+ return;
+ }
+ setSelectedTags([...selectedTags, tagId]);
+ };
+
+ const handleDeleteTag = (tagId: string) => {
+ setSelectedTags(selectedTags.filter((tag) => tag !== tagId));
+ };
+
// Form Submit method:
const onSubmit = async (values: z.infer) => {
// Check if slug & url are equals to prevent infinite redirect =>
@@ -82,6 +106,14 @@ export function CreateLink(props: CreateLinkProps) {
return;
}
+ if (selectedTags.length > 0) {
+ await Promise.all(
+ selectedTags.map(async (tag) => {
+ await insertTagToLink(result.linkId!, tag);
+ }),
+ );
+ }
+
toast.success("Link created successfully", {
description: `Url: https://slug.vercel.app/${values.slug}`,
duration: 10000,
@@ -100,6 +132,7 @@ export function CreateLink(props: CreateLinkProps) {
}
};
+ // Generate confetti animation:
const generateConfetti = async () => {
const jsConfetti = new JSConfetti();
await jsConfetti.addConfetti({
@@ -109,6 +142,7 @@ export function CreateLink(props: CreateLinkProps) {
});
};
+ // Generate random slug:
const handleGenerateRandomSlug = (e: React.MouseEvent) => {
e.preventDefault();
const randomSlug = Math.random().toString(36).substring(7);
@@ -134,6 +168,7 @@ export function CreateLink(props: CreateLinkProps) {
@@ -187,6 +222,12 @@ export function CreateLink(props: CreateLinkProps) {
)}
/>
{isError && {message}}
+
diff --git a/src/components/links/delete-link.tsx b/src/components/links/delete-link.tsx
index 7fa5621..6eec57d 100644
--- a/src/components/links/delete-link.tsx
+++ b/src/components/links/delete-link.tsx
@@ -91,7 +91,7 @@ const DeleteLink = ({ link, trigger }: DeleteLinkProps) => {
confirm:
-
+
diff --git a/src/components/links/edit-link.tsx b/src/components/links/edit-link.tsx
index 4f019dc..0be06a2 100644
--- a/src/components/links/edit-link.tsx
+++ b/src/components/links/edit-link.tsx
@@ -1,6 +1,6 @@
"use client";
-import type { Links } from "@prisma/client";
+import type { Links, Tags } from "@prisma/client";
import { useState, type ReactNode } from "react";
import type { z } from "zod";
@@ -38,6 +38,8 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/ui/popover";
interface EditLinkProps {
trigger: ReactNode;
link: Links;
+ linkTags: Tags[];
+ allTags: Tags[];
}
const EditLink = (props: EditLinkProps) => {
@@ -71,6 +73,8 @@ const EditLink = (props: EditLinkProps) => {
try {
setLoading(true);
await updateLink(values);
+
+ // If not any changes in the tags, return:
toast.success("Link edited successfully.", {
description: `Url: https://slug.vercel.app/${values.slug}`,
duration: 10000,
diff --git a/src/components/links/links-limit.tsx b/src/components/links/links-limit.tsx
index f75c3f8..6309ed0 100644
--- a/src/components/links/links-limit.tsx
+++ b/src/components/links/links-limit.tsx
@@ -1,3 +1,4 @@
+import { buttonVariants } from "@/ui/button";
import {
Tooltip,
TooltipContent,
@@ -5,7 +6,7 @@ import {
TooltipTrigger,
} from "@/ui/tooltip";
import { cn } from "@/utils";
-import { PackageIcon, TriangleAlertIcon } from "lucide-react";
+import { CircleDashedIcon, TriangleAlertIcon } from "lucide-react";
interface LinksLimitProps {
userLinks: number;
@@ -19,7 +20,12 @@ const LinksLimit = ({ userLinks, maxLinks }: LinksLimitProps) => {
-
+
{
{max ? (
) : (
-
+
)}
{userLinks < 10 ? `0${userLinks}` : userLinks}
{"/"}
{maxLinks < 10 ? `0${maxLinks}` : maxLinks}
- {" links "}
diff --git a/src/components/links/select-tags-link.tsx b/src/components/links/select-tags-link.tsx
new file mode 100644
index 0000000..362b6e3
--- /dev/null
+++ b/src/components/links/select-tags-link.tsx
@@ -0,0 +1,61 @@
+import type { Tags } from "@prisma/client";
+
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/ui/select";
+import { XIcon } from "lucide-react";
+
+interface SelectTagsLinkProps {
+ className?: string;
+ tags: Tags[];
+ selectedTags: string[];
+ onSelectTag: (tag: string) => void;
+ onDeleteTag: (tag: string) => void;
+}
+
+const SelectTagsLink = (props: SelectTagsLinkProps) => {
+ return (
+
+
+ Add tags to your link:
+
+
+ {props.selectedTags.length > 0 && (
+
+ {props.selectedTags.map((tag) => (
+
+ {props.tags.find((t) => t.id === tag)?.name}
+
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default SelectTagsLink;
diff --git a/src/components/tags/create-tag.tsx b/src/components/tags/create-tag.tsx
new file mode 100644
index 0000000..3c2f46e
--- /dev/null
+++ b/src/components/tags/create-tag.tsx
@@ -0,0 +1,143 @@
+"use client";
+
+import type { z } from "zod";
+import type { Tags } from "@prisma/client";
+
+import { CreateTagSchema } from "@/server/schemas";
+import { useState, type ReactNode } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { zodResolver } from "@hookform/resolvers/zod";
+
+import { createTag } from "@/server/actions/tags";
+
+import Alert from "@/ui/alert";
+import { Button } from "@/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/ui/form";
+import { Input } from "@/ui/input";
+import { LoaderIcon, RocketIcon } from "lucide-react";
+
+interface CreateTagProps {
+ children: ReactNode;
+ tagsCreated: Tags[];
+}
+
+export function CreateTag(props: CreateTagProps) {
+ const [loading, setLoading] = useState
(false);
+ const [open, setOpen] = useState(false);
+ const [message, setMessage] = useState("");
+ const [isError, setError] = useState(false);
+
+ // Main form:
+ const form = useForm>({
+ resolver: zodResolver(CreateTagSchema),
+ defaultValues: {
+ name: "",
+ color: "#171717",
+ },
+ });
+
+ // Form Submit method:
+ const onSubmit = async (values: z.infer) => {
+ try {
+ setLoading(true);
+
+ if (props.tagsCreated.map((tag) => tag.name).includes(values.name)) {
+ toast.error("The tag is already exist. Write another name.");
+ return;
+ }
+ const result = await createTag(values);
+
+ if (!result) {
+ toast.error(
+ "An unexpected error has occurred. Please try again later.",
+ {
+ duration: 10000,
+ closeButton: true,
+ },
+ );
+ return;
+ }
+
+ toast.success("Tag created successfully", {
+ duration: 10000,
+ closeButton: true,
+ });
+
+ form.reset();
+ setOpen(false);
+ } catch (error) {
+ toast.error("An unexpected error has occurred. Please try again later.");
+ } finally {
+ setError(false);
+ setMessage("");
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/tags/delete-tag.tsx b/src/components/tags/delete-tag.tsx
new file mode 100644
index 0000000..e2a268c
--- /dev/null
+++ b/src/components/tags/delete-tag.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import type { Tags } from "@prisma/client";
+import { type ReactNode, useState } from "react";
+
+import { toast } from "sonner";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/ui/dialog";
+
+import { removeTag } from "@/server/actions/tags";
+import { Button } from "@/ui/button";
+import { LoaderIcon } from "lucide-react";
+
+interface DeleteTagProps {
+ tag: Tags;
+ trigger: ReactNode;
+}
+
+const DeleteTag = ({ trigger, tag }: DeleteTagProps) => {
+ const [open, setOpen] = useState(false);
+ const [loading, setLoading] = useState(false);
+
+ const handleDeleteTag = async () => {
+ try {
+ setLoading(true);
+ await removeTag(tag.id);
+ setOpen(false);
+ toast.success("Link deleted successfully.", {
+ description: `The tag ${tag.name} has been deleted.`,
+ });
+ } catch (error) {
+ toast.error(
+ "An error occurred while deleting the tag. Please try again.",
+ );
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default DeleteTag;
diff --git a/src/components/tags/search-tags.tsx b/src/components/tags/search-tags.tsx
new file mode 100644
index 0000000..10b00a6
--- /dev/null
+++ b/src/components/tags/search-tags.tsx
@@ -0,0 +1,128 @@
+"use client";
+
+import type { Tags } from "@prisma/client";
+import { useState } from "react";
+
+import { usePathname, useRouter, useSearchParams } from "next/navigation";
+import { Popover, PopoverContent, PopoverTrigger } from "@/ui/popover";
+import { CreateTag } from "./create-tag";
+import { Button } from "@/ui/button";
+import {
+ CheckIcon,
+ PlusIcon,
+ SearchXIcon,
+ TagIcon,
+ TagsIcon,
+ XIcon,
+} from "lucide-react";
+import DeleteTag from "./delete-tag";
+
+interface SearchTagProps {
+ tags: Tags[];
+ tagSelected: string;
+ tagName?: string;
+}
+
+const SearchTag = (props: SearchTagProps) => {
+ const [isOpened, setIsOpened] = useState(false);
+ const searchTagParams = useSearchParams();
+ const pathname = usePathname();
+ const router = useRouter();
+
+ const handleSearchTag = (value: string) => {
+ const params = new URLSearchParams(searchTagParams);
+ if (value) {
+ params.set("tag", value);
+ } else {
+ params.delete("tag");
+ }
+ router.replace(`${pathname}?${params.toString()}`);
+ };
+
+ const handleDeleteTag = () => {
+ const params = new URLSearchParams(searchTagParams);
+ params.delete("tag");
+ router.replace(`${pathname}?${params.toString()}`);
+ };
+
+ return (
+
+
+
+
+
+
+ My Tags ({props.tags.length})
+
+
+ {props.tags.length === 0 && (
+
+
+ No tags found
+
+ )}
+ {props.tags.map((tag) => {
+ return (
+
+
+
+ {tag.id === props.tagSelected && }
+
+
+
+ }
+ />
+
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default SearchTag;
diff --git a/src/middleware.ts b/src/middleware.ts
index 6bdfc41..a8debba 100644
--- a/src/middleware.ts
+++ b/src/middleware.ts
@@ -13,7 +13,6 @@ import {
} from "./routes";
import { urlFromServer } from "./server/middleware/redirect";
-import { notFound } from "next/navigation";
const { auth } = NextAuth(authConfig);
@@ -67,25 +66,18 @@ export default auth(async (req) => {
// ⚙️ Redirect using slug:
// If not public route and not protected route:
if (!isPublicRoute && !isProtectedRoute && !isCheckRoute) {
- try {
- const getDataApi = await urlFromServer(slugRoute!);
-
- if (getDataApi.redirect404) {
- return notFound();
- }
-
- if (getDataApi.error) {
- return NextResponse.json(
- { error: getDataApi.message },
- { status: 500 },
- );
- }
-
- if (getDataApi.url) {
- return NextResponse.redirect(new URL(getDataApi.url).toString());
- }
- } catch (error) {
- console.error("🚧 Error fetching slug: ", error);
+ const getDataApi = await urlFromServer(slugRoute!);
+
+ if (getDataApi.redirect404) {
+ console.log("🚧 Error - Redirect 404: ", slugRoute);
+ }
+
+ if (getDataApi.error) {
+ return NextResponse.json({ error: getDataApi.message }, { status: 500 });
+ }
+
+ if (getDataApi.url) {
+ return NextResponse.redirect(new URL(getDataApi.url).toString());
}
}
return;
diff --git a/src/server/actions/links.ts b/src/server/actions/links.ts
index 06570d9..e292100 100644
--- a/src/server/actions/links.ts
+++ b/src/server/actions/links.ts
@@ -59,6 +59,7 @@ export const checkIfSlugExist = async (slug: string) => {
interface createLinkResult {
limit?: boolean;
error?: string;
+ linkId?: string;
}
export const createLink = async (
@@ -88,7 +89,7 @@ export const createLink = async (
}
// Create new link:
- await db.links.create({
+ const result = await db.links.create({
data: {
...values,
creatorId: currentUser.user?.id,
@@ -98,7 +99,7 @@ export const createLink = async (
revalidatePath("/");
revalidatePath("/dashboard");
- return { limit: false };
+ return { limit: false, linkId: result.id };
};
/**
diff --git a/src/server/actions/tags.ts b/src/server/actions/tags.ts
new file mode 100644
index 0000000..acae13d
--- /dev/null
+++ b/src/server/actions/tags.ts
@@ -0,0 +1,85 @@
+"use server";
+
+import type { z } from "zod";
+import type { CreateTagSchema } from "@/server/schemas";
+
+import { auth } from "@/auth";
+import { db } from "@/server/db";
+import { revalidatePath } from "next/cache";
+
+/**
+ * Create a tag.
+ * Return an object.
+ * Authentication required.
+ * @type {string()}
+ */
+export const createTag = async (values: z.infer) => {
+ const currentUser = await auth();
+
+ if (!currentUser) {
+ console.error("Not authenticated.");
+ return null;
+ }
+
+ const result = await db.tags.create({
+ data: {
+ name: values.name,
+ color: values.color,
+ creatorId: currentUser.user?.id,
+ },
+ });
+
+ revalidatePath("/");
+ revalidatePath("/dashboard");
+
+ return result;
+};
+
+/**
+ * Insert a tag to a link.
+ * Authentication required.
+ * @type {string()}
+ */
+export const insertTagToLink = async (linkId: string, tagId: string) => {
+ const currentUser = await auth();
+
+ if (!currentUser) {
+ console.error("Not authenticated.");
+ return null;
+ }
+
+ await db.linkTags.create({
+ data: {
+ linkId,
+ tagId,
+ },
+ });
+
+ revalidatePath("/");
+
+ return;
+};
+
+/**
+ * Remove a tag.
+ * Authentication required.
+ * @type {string()}
+ */
+export const removeTag = async (tagId: string) => {
+ const currentUser = await auth();
+
+ if (!currentUser) {
+ console.error("Not authenticated.");
+ return null;
+ }
+
+ await db.tags.delete({
+ where: {
+ id: tagId,
+ },
+ });
+
+ revalidatePath("/");
+
+ return;
+};
\ No newline at end of file
diff --git a/src/server/middleware/redirect.ts b/src/server/middleware/redirect.ts
index 40c9728..eb1b620 100644
--- a/src/server/middleware/redirect.ts
+++ b/src/server/middleware/redirect.ts
@@ -35,6 +35,7 @@ export const urlFromServer = async (
clicks: {
increment: 1,
},
+ lastClicked: new Date(),
},
});
diff --git a/src/server/queries/index.ts b/src/server/queries/index.ts
index 3f686ce..889bd01 100644
--- a/src/server/queries/index.ts
+++ b/src/server/queries/index.ts
@@ -6,7 +6,7 @@ import { db } from "@/server/db";
* Get links with tags by user.
* Authentication required.
*/
-export const getLinksByUser = cache(async () => {
+export const getLinksAndTagsByUser = cache(async () => {
const currentUser = await auth();
if (!currentUser) {
@@ -14,14 +14,46 @@ export const getLinksByUser = cache(async () => {
return null;
}
- const result = await db.links.findMany({
+ const [linksData, tagsData] = await db.$transaction([
+ db.links.findMany({
+ where: {
+ creatorId: currentUser.user?.id,
+ },
+ include: {
+ tags: true,
+ },
+ }),
+ db.tags.findMany({
+ where: {
+ creatorId: currentUser.user?.id,
+ },
+ }),
+ ]);
+
+ return {
+ limit: currentUser.user?.limitLinks,
+ links: linksData,
+ tags: tagsData,
+ };
+});
+
+/**
+ * Get only tags by user.
+ * Authentication required.
+ */
+export const getTagsByUser = cache(async () => {
+ const currentUser = await auth();
+
+ if (!currentUser) {
+ console.error("Not authenticated.");
+ return null;
+ }
+
+ const tagsData = await db.tags.findMany({
where: {
creatorId: currentUser.user?.id,
},
});
- return {
- limit: currentUser.user?.limitLinks,
- links: result,
- };
+ return tagsData;
});
diff --git a/src/server/schemas/index.ts b/src/server/schemas/index.ts
index f724e52..9b9d3a7 100644
--- a/src/server/schemas/index.ts
+++ b/src/server/schemas/index.ts
@@ -62,6 +62,13 @@ export const getSingleLinkSchema = z.object({
linkId: z.number(),
});
+export const CreateTagSchema = z.object({
+ name: z.string().min(1, { message: "Tag name is required." }).max(15, {
+ message: "Tag name must be less than 15 characters.",
+ }),
+ color: z.string().min(1, { message: "Tag color is required." }),
+});
+
export const UpdateProfileSchema = z.object({
name: z.string().min(1, { message: "Name is required." }).max(40, {
message: "Name must be less than 40 characters.",
diff --git a/src/server/utils/getMetadata.ts b/src/server/utils/getMetadata.ts
new file mode 100644
index 0000000..9b8eb4f
--- /dev/null
+++ b/src/server/utils/getMetadata.ts
@@ -0,0 +1,53 @@
+import { load } from "cheerio";
+
+interface MetadataResponse {
+ title: string;
+ description: string;
+ siteUrl: string;
+ site_name: string;
+ image: string;
+ icon: string;
+ keywords: string;
+}
+
+export const getMetadata = async (url: string) => {
+ try {
+ const res = await fetch(url).then((result) => result.text());
+ const $ = load(res);
+
+ const title =
+ ($('meta[property="og:title"]').attr("content") ?? $("title").text()) ||
+ $('meta[name="title"]').attr("content");
+ const description =
+ $('meta[property="og:description"]').attr("content") ??
+ $('meta[name="description"]').attr("content");
+ const siteUrl = $('meta[property="og:url"]').attr("content");
+ const site_name = $('meta[property="og:site_name"]').attr("content");
+ const image =
+ $('meta[property="og:image"]').attr("content") ??
+ $('meta[property="og:image:url"]').attr("content");
+ let icon =
+ $('link[rel="icon"]').attr("href") ??
+ $('link[rel="shortcut icon"]').attr("href");
+ if (icon && !icon.includes("http")) {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
+ const urlFromParams = new URL(siteUrl ?? url);
+ icon = `${urlFromParams.origin}${icon}`;
+ }
+ const keywords =
+ $('meta[property="og:keywords"]').attr("content") ??
+ $('meta[name="keywords"]').attr("content");
+
+ return {
+ title,
+ description,
+ siteUrl,
+ site_name,
+ image,
+ icon,
+ keywords,
+ } as MetadataResponse;
+ } catch (error) {
+ console.error(error);
+ }
+};
diff --git a/src/ui/popover.tsx b/src/ui/popover.tsx
index 87e2728..c9034b8 100644
--- a/src/ui/popover.tsx
+++ b/src/ui/popover.tsx
@@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
className={cn(
- "z-50 w-72 rounded-md border border-neutral-200 bg-white p-4 text-neutral-950 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-50",
+ "z-50 rounded-md border border-neutral-200 bg-white p-2 text-neutral-950 shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-50",
className,
)}
{...props}
diff --git a/src/ui/select.tsx b/src/ui/select.tsx
new file mode 100644
index 0000000..7abb57b
--- /dev/null
+++ b/src/ui/select.tsx
@@ -0,0 +1,162 @@
+"use client";
+
+import * as React from "react";
+import * as SelectPrimitive from "@radix-ui/react-select";
+
+import { cn } from "@/utils";
+import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
+
+const Select = SelectPrimitive.Root;
+
+const SelectGroup = SelectPrimitive.Group;
+
+const SelectValue = SelectPrimitive.Value;
+
+const SelectTrigger = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+ span]:line-clamp-1",
+ className,
+ )}
+ {...props}
+ >
+ {children}
+
+
+
+
+));
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+const SelectScrollUpButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
+
+const SelectScrollDownButton = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+SelectScrollDownButton.displayName =
+ SelectPrimitive.ScrollDownButton.displayName;
+
+const SelectContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, position = "popper", ...props }, ref) => (
+
+
+
+
+ {children}
+
+
+
+
+));
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+const SelectLabel = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectLabel.displayName = SelectPrimitive.Label.displayName;
+
+const SelectItem = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+));
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+const SelectSeparator = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
+
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectLabel,
+ SelectItem,
+ SelectSeparator,
+ SelectScrollUpButton,
+ SelectScrollDownButton,
+};