added seed data; image uploads via server actions; infinite article grid
This commit is contained in:
parent
7135e34699
commit
37233db0ec
@ -7,7 +7,9 @@ import "./src/env.js";
|
|||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
experimental: {
|
experimental: {
|
||||||
reactCompiler: true,
|
serverActions: {
|
||||||
|
bodySizeLimit: "2mb",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
19
package.json
19
package.json
@ -21,13 +21,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/drizzle-adapter": "^1.7.2",
|
"@auth/drizzle-adapter": "^1.7.2",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
|
||||||
"@dnd-kit/modifiers": "^9.0.0",
|
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
|
||||||
"@hookform/resolvers": "^4.1.3",
|
"@hookform/resolvers": "^4.1.3",
|
||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||||
|
"@radix-ui/react-aspect-ratio": "^1.1.2",
|
||||||
"@radix-ui/react-avatar": "^1.1.3",
|
"@radix-ui/react-avatar": "^1.1.3",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
"@radix-ui/react-collapsible": "^1.1.3",
|
"@radix-ui/react-collapsible": "^1.1.3",
|
||||||
@ -43,17 +40,13 @@
|
|||||||
"@tanstack/react-query": "^5.50.0",
|
"@tanstack/react-query": "^5.50.0",
|
||||||
"@tanstack/react-table": "^8.21.2",
|
"@tanstack/react-table": "^8.21.2",
|
||||||
"@tiptap/core": "^2.11.5",
|
"@tiptap/core": "^2.11.5",
|
||||||
"@tiptap/extension-color": "^2.11.5",
|
"@tiptap/extension-heading": "^2.11.5",
|
||||||
"@tiptap/extension-image": "^2.11.5",
|
|
||||||
"@tiptap/extension-list-item": "^2.11.5",
|
|
||||||
"@tiptap/extension-text-style": "^2.11.5",
|
|
||||||
"@tiptap/pm": "^2.11.5",
|
|
||||||
"@tiptap/react": "^2.11.5",
|
"@tiptap/react": "^2.11.5",
|
||||||
"@tiptap/starter-kit": "^2.11.5",
|
|
||||||
"@trpc/client": "^11.0.0-rc.446",
|
"@trpc/client": "^11.0.0-rc.446",
|
||||||
"@trpc/react-query": "^11.0.0-rc.446",
|
"@trpc/react-query": "^11.0.0-rc.446",
|
||||||
"@trpc/server": "^11.0.0-rc.446",
|
"@trpc/server": "^11.0.0-rc.446",
|
||||||
"babel-plugin-react-compiler": "19.0.0-beta-40c6c23-20250301",
|
"babel-plugin-react-compiler": "19.0.0-beta-40c6c23-20250301",
|
||||||
|
"cheerio": "^1.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "1.0.0",
|
"cmdk": "1.0.0",
|
||||||
@ -63,11 +56,9 @@
|
|||||||
"next": "^15.0.1",
|
"next": "^15.0.1",
|
||||||
"next-auth": "5.0.0-beta.25",
|
"next-auth": "5.0.0-beta.25",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
|
"novel": "^1.0.2",
|
||||||
"postgres": "^3.4.4",
|
"postgres": "^3.4.4",
|
||||||
"prosemirror-state": "^1.4.3",
|
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-colorful": "^5.6.1",
|
|
||||||
"react-dnd": "^16.0.1",
|
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
"react-textarea-autosize": "^8.5.7",
|
"react-textarea-autosize": "^8.5.7",
|
||||||
@ -76,7 +67,7 @@
|
|||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
"use-debounce": "^10.0.4",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
1484
pnpm-lock.yaml
generated
1484
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
public/uploads/upload-1741879341639-pexels-rdne-8052216.jpg.png
Normal file
BIN
public/uploads/upload-1741879341639-pexels-rdne-8052216.jpg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
302
seed-data/fake-articles.json
Normal file
302
seed-data/fake-articles.json
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "Account Executive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Engineer II"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Data Coordinator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Senior Editor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Senior Quality Engineer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Statistician III"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Programmer I"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Office Assistant II"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "VP Marketing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Senior Quality Engineer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Business Systems Development Analyst"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Chemical Engineer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Director of Sales"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Chief Design Engineer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Editor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Speech Pathologist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Pharmacist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Operator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Human Resources Assistant III"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Computer Systems Analyst II"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Sales Associate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Desktop Support Technician"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Executive Secretary"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Quality Control Specialist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Research Associate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Software Consultant"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Staff Scientist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Senior Sales Associate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Business Systems Development Analyst"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "VP Sales"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Mechanical Systems Engineer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Information Systems Manager"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Internal Auditor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Product Engineer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Legal Assistant"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "GIS Technical Architect"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Software Consultant"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Paralegal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Nurse"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Biostatistician II"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Web Designer I"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Financial Analyst"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Administrative Officer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "VP Accounting"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Biostatistician IV"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Data Coordinator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Occupational Therapist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Web Developer IV"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Quality Control Specialist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "General Manager"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Assistant Manager"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Sales Associate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "VP Marketing"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Graphic Designer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Operator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Senior Financial Analyst"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Information Systems Manager"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Tax Accountant"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Research Assistant II"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Quality Engineer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Staff Scientist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Account Representative I"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Clinical Specialist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Web Developer II"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Desktop Support Technician"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Electrical Engineer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Registered Nurse"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Paralegal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Financial Advisor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Senior Cost Accountant"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Senior Financial Analyst"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Safety Technician III"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Recruiting Manager"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Engineer III"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Social Worker"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Assistant Manager"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Financial Analyst"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Health Coach II"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Database Administrator III"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Senior Editor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Research Nurse"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Graphic Designer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Quality Engineer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Media Manager II"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Payment Adjustment Coordinator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Desktop Support Technician"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Legal Assistant"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Research Associate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Operator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Speech Pathologist"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Senior Editor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Financial Analyst"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Professor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Registered Nurse"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Electrical Engineer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Actuary"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Nuclear Power Engineer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Social Worker"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Safety Technician IV"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Web Developer I"
|
||||||
|
}
|
||||||
|
]
|
||||||
302
seed-data/fake-categories.json
Normal file
302
seed-data/fake-categories.json
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "Green Sotol"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mexican Prairie Clover"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Stipulate Leaf-flower"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kawelu"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Spiked Crested Coralroot"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Texas Windmill Grass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mountain Alder"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Macdougal Verbena"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "European Aspen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Torrey's Willowherb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ailanthus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Shortstalk Stinkweed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Black Damar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Canelillo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Veatch's Island Broom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Lewton's Milkwort"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hungarian Milkvetch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Palmer Evening Primrose"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Smooth Chastetree"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jaeger's Joshua Tree"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Roughhairy Maiden Fern"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Florida Orchid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Belonia Lichen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bigfruit Evening Primrose"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fitch's Tarweed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Coastal Plain Dawnflower"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Clokey's Gilia"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Indian Jointvetch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Wreath Lichen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cumberland Xanthoparmelia Lichen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Spectacular Flatsedge"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Pride Of California"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Feverfew"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Comb Wash Buckwheat"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sweet Woodreed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Delicate Violet Orchid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Canadian Blacksnakeroot"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Wax Currant"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Western Mountain Ash"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rhodomyrtus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Johnston's Knotweed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kauai Bur Cucumber"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Cain's Reedgrass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "San Diego Pitchersage"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rock Goldenrod"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Itchgrass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Threadleaf Horsebrush"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Red Hills Vervain"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Louisiana Bluestar"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Utah Sweetvetch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Kauila"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sea Hibiscus"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Derris"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Florida Tasselflower"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Glossy Hawthorn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ahlner's Microcalicium Lichen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Aster"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Elegant Hawthorn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Pricklypear"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Parry's Sage"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Redberry Buckthorn"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Baden's Bluegrass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Utah Columbine"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Obscure Shield Lichen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Showy Orchid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Silverleafed Princess Flower"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Oahu Stenogyne"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Hammond's Claytonia"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Owyhee River Stickseed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Southwestern Cosmos"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Toothed Flatsedge"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Vegetable Fern"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rose"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Desert Wishbone-bush"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rocky Mountain Woodsia"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "East Indian Lemongrass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Coville's Erigeron"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Spiral Flag"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Nevada Milkvetch"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Douglas's Catchfly"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Silverleaf Phacelia"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Canadian Ricegrass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Barrier Range Wattle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Brooks' Alsophila"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Calder's Bladderpod"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Desert Brickellbush"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Echeveria"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Caruzo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "American Black Nightshade"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Whiteflower Goldenbush"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Littleleaf Milkwort"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fir Mistletoe"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Disc Lichen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Flagstaff Rockcress"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Golden Spiderflower"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Yellow Fumewort"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dot Lichen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ross' Avens"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sierra Bluecup"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sausage Tree"
|
||||||
|
}
|
||||||
|
]
|
||||||
@ -413,90 +413,5 @@
|
|||||||
"name": "Marie-josée",
|
"name": "Marie-josée",
|
||||||
"email": "salford2a@hubpages.com",
|
"email": "salford2a@hubpages.com",
|
||||||
"image": "https://robohash.org/nihilestillo.png?size=50x50&set=set1"
|
"image": "https://robohash.org/nihilestillo.png?size=50x50&set=set1"
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Maëlle",
|
|
||||||
"email": "lbaggs2b@deviantart.com",
|
|
||||||
"image": "https://robohash.org/blanditiisetvoluptate.png?size=50x50&set=set1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Publicité",
|
|
||||||
"email": "vpurdy2c@bravesites.com",
|
|
||||||
"image": "https://robohash.org/eumenimipsa.png?size=50x50&set=set1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Lén",
|
|
||||||
"email": "cstraun2d@youtube.com",
|
|
||||||
"image": "https://robohash.org/temporaquisofficia.png?size=50x50&set=set1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Maëline",
|
|
||||||
"email": "rcorney2e@blog.com",
|
|
||||||
"image": "https://robohash.org/nisimodimagnam.png?size=50x50&set=set1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Lucrèce",
|
|
||||||
"email": "florey2f@weather.com",
|
|
||||||
"image": "https://robohash.org/nequepariaturconsequatur.png?size=50x50&set=set1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Dorothée",
|
|
||||||
"email": "briddles2g@ucoz.ru",
|
|
||||||
"image": "https://robohash.org/voluptaserroriure.png?size=50x50&set=set1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Irène",
|
|
||||||
"email": "kscotsbrook2h@salon.com",
|
|
||||||
"image": "https://robohash.org/ullamutearum.png?size=50x50&set=set1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Håkan",
|
|
||||||
"email": "sshipp2i@cnbc.com",
|
|
||||||
"image": "https://robohash.org/etautillum.png?size=50x50&set=set1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Mylène",
|
|
||||||
"email": "bbanishevitz2j@biglobe.ne.jp",
|
|
||||||
"image": "https://robohash.org/consectetureosullam.png?size=50x50&set=set1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Styrbjörn",
|
|
||||||
"email": "mjeffries2k@wsj.com",
|
|
||||||
"image": "https://robohash.org/expeditaautemvoluptatum.png?size=50x50&set=set1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Sòng",
|
|
||||||
"email": "mpanks2l@tripadvisor.com",
|
|
||||||
"image": "https://robohash.org/eiussintnihil.png?size=50x50&set=set1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Faîtes",
|
|
||||||
"email": "nstewartson2m@themeforest.net",
|
|
||||||
"image": "https://robohash.org/errornumquamanimi.png?size=50x50&set=set1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Clémence",
|
|
||||||
"email": "bslany2n@naver.com",
|
|
||||||
"image": "https://robohash.org/laboriosamconsequaturdolore.png?size=50x50&set=set1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Maëlys",
|
|
||||||
"email": "dshubotham2o@who.int",
|
|
||||||
"image": "https://robohash.org/nobisnecessitatibusipsa.png?size=50x50&set=set1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Inès",
|
|
||||||
"email": "akeiling2p@ycombinator.com",
|
|
||||||
"image": "https://robohash.org/etvoluptatedeserunt.png?size=50x50&set=set1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Maëlla",
|
|
||||||
"email": "jfolonin2q@livejournal.com",
|
|
||||||
"image": "https://robohash.org/voluptatibusatqueut.png?size=50x50&set=set1"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Clémentine",
|
|
||||||
"email": "cstammirs2r@a8.net",
|
|
||||||
"image": "https://robohash.org/nihilquaesint.png?size=50x50&set=set1"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,10 +1,22 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { db } from "../src/server/db";
|
import { db, DBType } from "../src/server/db";
|
||||||
import { users } from "../src/server/db/schema";
|
import { articles, categories, users } from "../src/server/db/schema";
|
||||||
import fakeUsers from "./fake-users.json";
|
import fakeArticles from "./fake-articles.json";
|
||||||
|
import { generateSlug } from "@/lib/utils";
|
||||||
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
|
||||||
async function seed() {
|
async function seed() {
|
||||||
await db.insert(users).values(fakeUsers);
|
// await db.insert(users).values(fakeUsers);
|
||||||
|
// await db
|
||||||
|
// .insert(categories)
|
||||||
|
// .values(
|
||||||
|
// fakeCategories.map(({ name }) => ({ name, slug: generateSlug(name) })),
|
||||||
|
// );
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(articles)
|
||||||
|
.values(fakeArticles.map(({ title }) => ({ title, slug: createId() })));
|
||||||
}
|
}
|
||||||
|
|
||||||
seed()
|
seed()
|
||||||
|
|||||||
@ -1,18 +0,0 @@
|
|||||||
import { appRoutes } from "@/config";
|
|
||||||
import { Category } from "@/server/db/schema";
|
|
||||||
import Link from "next/link";
|
|
||||||
import React from "react";
|
|
||||||
|
|
||||||
function CategoryCard({
|
|
||||||
name,
|
|
||||||
slug,
|
|
||||||
createdAt,
|
|
||||||
}: Pick<Category, "name" | "slug" | "createdAt">) {
|
|
||||||
return (
|
|
||||||
<Link href={appRoutes.category(slug)}>
|
|
||||||
<div className="rounded-md border p-4">{name}</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CategoryCard;
|
|
||||||
79
src/app/(PAGES)/_components/main-page.tsx
Normal file
79
src/app/(PAGES)/_components/main-page.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import CategoriesGrid, {
|
||||||
|
CategoriesGridSkeleton,
|
||||||
|
} from "@/components/category/categories-grid";
|
||||||
|
import ArticleGrid, {
|
||||||
|
ArticleGridSkeleton,
|
||||||
|
} from "@/components/article/grid/article-grid";
|
||||||
|
import { Article, Category } from "@/server/db/schema";
|
||||||
|
import ArrowLink from "@/components/arrow-link";
|
||||||
|
import { appRoutes } from "@/config";
|
||||||
|
import GlobalSearchWidget from "@/components/global-search-widget";
|
||||||
|
import { api } from "@/trpc/react";
|
||||||
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 10;
|
||||||
|
|
||||||
|
function MainPage({
|
||||||
|
initialData,
|
||||||
|
}: {
|
||||||
|
initialData: { categories: Array<Category>; articles: Array<Article> };
|
||||||
|
}) {
|
||||||
|
// const [query, setQuery] = React.useState<string>("");
|
||||||
|
|
||||||
|
// // const {} = useInfiniteQuery();
|
||||||
|
|
||||||
|
// const { data: searchResults, isLoading } = api.app.searchContent.useQuery(
|
||||||
|
// {
|
||||||
|
// query,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// enabled: !!query,
|
||||||
|
// },
|
||||||
|
// );
|
||||||
|
|
||||||
|
// const data = query?.length ? searchResults : initialData;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
main page xD
|
||||||
|
{/* <GlobalSearchWidget
|
||||||
|
onDebouncedSearch={
|
||||||
|
(q) => setQuery(q)
|
||||||
|
// q.length ? setQuery(q) : setQuery(undefined)
|
||||||
|
}
|
||||||
|
className="sticky top-0"
|
||||||
|
/> */}
|
||||||
|
{/* <div className="w-full space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-medium">Kategorien</h2>
|
||||||
|
<ArrowLink href={appRoutes.allCategories}>Alle Kategorien</ArrowLink>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<CategoriesGridSkeleton />
|
||||||
|
) : data?.categories?.length ? (
|
||||||
|
<CategoriesGrid categories={data.categories} />
|
||||||
|
) : (
|
||||||
|
<p>Keine Kategorien gefunden</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-screen w-full space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-medium">Artikel</h2>
|
||||||
|
<ArrowLink href={appRoutes.allArticles}>Alle Artikel</ArrowLink>
|
||||||
|
</div>
|
||||||
|
{isLoading ? (
|
||||||
|
<ArticleGridSkeleton />
|
||||||
|
) : data?.articles?.length ? (
|
||||||
|
<ArticleGrid articles={data.articles as Article[]} />
|
||||||
|
) : (
|
||||||
|
<p>Keine Artikel gefunden</p>
|
||||||
|
)}
|
||||||
|
</div> */}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MainPage;
|
||||||
@ -1,9 +1,8 @@
|
|||||||
import React from 'react'
|
import React from "react";
|
||||||
|
import InfiniteArticlesGrid from "@/components/article/grid/infinite-article-grid";
|
||||||
|
|
||||||
function Page() {
|
function Page() {
|
||||||
return (
|
return <InfiniteArticlesGrid />;
|
||||||
<div>Alle Artikel Page</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Page
|
export default Page;
|
||||||
|
|||||||
@ -1,62 +0,0 @@
|
|||||||
import BreadNavigator from "@/components/bread-navigator";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { hasPermission, Role } from "@/lib/validation/permissions";
|
|
||||||
import { auth } from "@/server/auth";
|
|
||||||
import { api } from "@/trpc/server";
|
|
||||||
import { Edit } from "lucide-react";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import React from "react";
|
|
||||||
import ArticleCard from "../../../../components/article/article-card";
|
|
||||||
import { appRoutes } from "@/config";
|
|
||||||
|
|
||||||
async function Page({ params }: { params: Promise<{ name: string }> }) {
|
|
||||||
const { name } = await params;
|
|
||||||
const category = await api.category.get({ slug: name });
|
|
||||||
if (!category) return notFound();
|
|
||||||
const articles = await api.article.getAll({ categoryId: category.id });
|
|
||||||
const session = await auth();
|
|
||||||
const isEditor = session?.user
|
|
||||||
? hasPermission(session.user.role, Role.EDITOR)
|
|
||||||
: false;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<BreadNavigator
|
|
||||||
links={[
|
|
||||||
{ label: "Kategorie", href: appRoutes.allCategories },
|
|
||||||
{
|
|
||||||
label: name,
|
|
||||||
href: appRoutes.category(name),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isEditor && (
|
|
||||||
<Button>
|
|
||||||
<Edit className="size-4" />
|
|
||||||
<span>Bearbeiten</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h1 className="text-3xl font-bold capitalize">{name}</h1>
|
|
||||||
<p className="text-muted-foreground">keine Beschreibung</p>
|
|
||||||
|
|
||||||
<h3 className="text-2xl font-bold">Artikel</h3>
|
|
||||||
{articles.length ? (
|
|
||||||
<menu>
|
|
||||||
{articles.map((article) => (
|
|
||||||
<li key={article.slug}>
|
|
||||||
<ArticleCard {...article} />
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</menu>
|
|
||||||
) : (
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Noch keine Artikel in dieser Kategorie.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Page;
|
|
||||||
27
src/app/(PAGES)/kategorie/[slug]/bearbeiten/page.tsx
Normal file
27
src/app/(PAGES)/kategorie/[slug]/bearbeiten/page.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { CategoryPageProps } from "../page";
|
||||||
|
import { api } from "@/trpc/server";
|
||||||
|
import { auth } from "@/server/auth";
|
||||||
|
import { hasPermission, Role } from "@/lib/validation/permissions";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import CategoryForm from "@/components/category/category-form";
|
||||||
|
import ImageUploadButton from "@/components/image-upload-form";
|
||||||
|
|
||||||
|
async function Page({ params }: CategoryPageProps) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const session = await auth();
|
||||||
|
const category = await api.category.get({
|
||||||
|
slug,
|
||||||
|
});
|
||||||
|
const isEditor = session?.user
|
||||||
|
? hasPermission(session.user.role, Role.EDITOR)
|
||||||
|
: false;
|
||||||
|
if (!category || !isEditor) return notFound();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CategoryForm server_category={category} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Page;
|
||||||
86
src/app/(PAGES)/kategorie/[slug]/page.tsx
Normal file
86
src/app/(PAGES)/kategorie/[slug]/page.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import BreadNavigator from "@/components/bread-navigator";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { hasPermission, Role } from "@/lib/validation/permissions";
|
||||||
|
import { auth } from "@/server/auth";
|
||||||
|
import { api } from "@/trpc/server";
|
||||||
|
import { Edit } from "lucide-react";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import { appRoutes } from "@/config";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
||||||
|
import { CATEGORY_BANNER_ASPECT_RATIO } from "@/components/category/category-form";
|
||||||
|
import Image from "next/image";
|
||||||
|
import ArticleGrid from "@/components/article/grid/article-grid";
|
||||||
|
|
||||||
|
export type CategoryPageProps = { params: Promise<{ slug: string }> };
|
||||||
|
|
||||||
|
async function Page({ params }: CategoryPageProps) {
|
||||||
|
const { slug } = await params;
|
||||||
|
const category = await api.category.get({
|
||||||
|
slug,
|
||||||
|
with: {
|
||||||
|
articles: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) return notFound();
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
const isEditor = session?.user
|
||||||
|
? hasPermission(session.user.role, Role.EDITOR)
|
||||||
|
: false;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<BreadNavigator
|
||||||
|
className="w-full"
|
||||||
|
links={[
|
||||||
|
{ label: "Kategorie", href: appRoutes.allCategories },
|
||||||
|
{
|
||||||
|
label: category.name!,
|
||||||
|
href: appRoutes.category(category.name!),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isEditor && (
|
||||||
|
<Button asChild>
|
||||||
|
<Link href={appRoutes.editCategory(slug)}>
|
||||||
|
<Edit className="size-4" />
|
||||||
|
<span>Bearbeiten</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{category.image?.length ? (
|
||||||
|
<AspectRatio
|
||||||
|
ratio={CATEGORY_BANNER_ASPECT_RATIO}
|
||||||
|
className="w-full bg-muted"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={category.image}
|
||||||
|
alt="Kategorie-bild"
|
||||||
|
fill
|
||||||
|
className="h-full w-full rounded-md object-cover"
|
||||||
|
/>
|
||||||
|
</AspectRatio>
|
||||||
|
) : null}
|
||||||
|
<h1 className="text-3xl font-bold capitalize">{category.name}</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{category.description ?? "keine Beschreibung"}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{category?.articles?.length ? (
|
||||||
|
<ArticleGrid articles={category.articles} />
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Noch keine Artikel in dieser Kategorie.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Page;
|
||||||
@ -1,7 +1,15 @@
|
|||||||
|
import CategoriesGrid from "@/components/category/categories-grid";
|
||||||
|
import { api } from "@/trpc/server";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
function Page() {
|
async function Page() {
|
||||||
return <div>Alle Kategorien</div>;
|
const categories = await api.category.getAll();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="text-2xl font-bold">Kategorien</h1>
|
||||||
|
<CategoriesGrid categories={categories} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
import { WikiSidebar } from "@/components/layout/wiki-sidebar";
|
||||||
import Navbar from "@/components/layout/navbar";
|
import Navbar from "@/components/layout/navbar";
|
||||||
import { SidebarProvider } from "@/components/ui/sidebar";
|
import { SidebarProvider } from "@/components/ui/sidebar";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
@ -6,7 +6,7 @@ import React from "react";
|
|||||||
function Layout({ children }: { children: React.ReactNode }) {
|
function Layout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar />
|
<WikiSidebar />
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main className="space-y-4 p-4">{children}</main>
|
<main className="space-y-4 p-4">{children}</main>
|
||||||
|
|||||||
@ -1,43 +1,13 @@
|
|||||||
import { auth } from "@/server/auth";
|
|
||||||
import { api } from "@/trpc/server";
|
import { api } from "@/trpc/server";
|
||||||
import Link from "next/link";
|
import MainPage from "./_components/main-page";
|
||||||
import GlobalStats from "./_components/global-stats";
|
import { Article } from "@/server/db/schema";
|
||||||
import ArticleCard from "../../components/article/article-card";
|
|
||||||
import CategoryCard from "./_components/category/category-card";
|
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const session = await auth();
|
const articles = await api.article.getAll({ limit: 12 });
|
||||||
const articles = await api.article.getAllPreviews();
|
const categories = await api.category.getAll({ limit: 6 });
|
||||||
const categories = await api.category.getAll();
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="w-full space-y-1">
|
<MainPage initialData={{ articles: articles as Article[], categories }} />
|
||||||
<h2 className="text-2xl font-medium">Kategorien</h2>
|
|
||||||
<menu className="grid grid-cols-1 gap-2 lg:grid-cols-2 xl:grid-cols-3">
|
|
||||||
{categories.map((category) => (
|
|
||||||
<li key={category.slug}>
|
|
||||||
<CategoryCard {...category} />
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</menu>
|
|
||||||
</div>
|
|
||||||
{/* <h1 className="text-4xl font-bold">Anti Rechts Wiki</h1> */}
|
|
||||||
<div className="flex h-64 w-full items-center justify-center rounded-md bg-muted">
|
|
||||||
<span>Artikel Suche</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* <GlobalStats /> */}
|
|
||||||
|
|
||||||
<div className="w-full space-y-1">
|
|
||||||
<h2 className="text-2xl font-medium">Artikel</h2>
|
|
||||||
<menu className="grid grid-cols-1 gap-2 lg:grid-cols-2">
|
|
||||||
{articles.map((article) => (
|
|
||||||
<li key={article.slug}>
|
|
||||||
<ArticleCard {...article} />
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</menu>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
56
src/app/api/url-preview/route.ts
Normal file
56
src/app/api/url-preview/route.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import * as cheerio from "cheerio";
|
||||||
|
import { NextApiRequest } from "next";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET(req: NextApiRequest) {
|
||||||
|
if (req.method !== "GET") {
|
||||||
|
return NextResponse.json({ error: "Method not allowed" });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url!);
|
||||||
|
const url = searchParams.get("url");
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: "URL parameter is required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return NextResponse.json({
|
||||||
|
error: "Failed to fetch the URL",
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
|
||||||
|
// Extract metadata using cheerio
|
||||||
|
const title = $("title").text() || "No title found";
|
||||||
|
const description =
|
||||||
|
$('meta[name="description"]').attr("content") ||
|
||||||
|
$('meta[property="og:description"]').attr("content") ||
|
||||||
|
"No description available";
|
||||||
|
const image = $('meta[property="og:image"]').attr("content") || null;
|
||||||
|
|
||||||
|
console.log(title, description, image);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
url,
|
||||||
|
meta: { title, description, image },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching URL:", error);
|
||||||
|
return NextResponse.json({
|
||||||
|
error: "Failed to fetch URL metadata",
|
||||||
|
success: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/components/arrow-link.tsx
Normal file
28
src/components/arrow-link.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import React from "react";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { ArrowRight, ChevronRight } from "lucide-react";
|
||||||
|
|
||||||
|
function ArrowLink({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant={"link"}
|
||||||
|
size={"sm"}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Link href={href}>
|
||||||
|
<span>{children}</span>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ArrowLink;
|
||||||
@ -1,16 +1,49 @@
|
|||||||
import { appRoutes } from "@/config";
|
import { appConfig, appRoutes } from "@/config";
|
||||||
import { Article } from "@/server/db/schema";
|
import { Article } from "@/server/db/schema";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import Avatar from "../avatar";
|
||||||
|
import { Icons } from "../icons";
|
||||||
|
|
||||||
function ArticleCard({
|
function ArticleCard({
|
||||||
title,
|
title,
|
||||||
slug,
|
slug,
|
||||||
|
author,
|
||||||
createdAt,
|
createdAt,
|
||||||
}: Pick<Article, "title" | "slug" | "createdAt">) {
|
}: Pick<Article, "title" | "slug" | "createdAt" | "author">) {
|
||||||
|
const authorName = author?.name ?? `${appConfig.name} Team`;
|
||||||
return (
|
return (
|
||||||
<Link href={appRoutes.article(slug)}>
|
<Link href={appRoutes.article(slug)}>
|
||||||
<div className="rounded-md border p-4">{title}</div>
|
<Card className="group flex h-full flex-col justify-between">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardFooter className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
{author ? (
|
||||||
|
<Avatar className="size-6" src={author?.image} fb={authorName} />
|
||||||
|
) : (
|
||||||
|
<Icons.logo className="size-6 text-muted-foreground transition-colors duration-150 group-hover:text-foreground" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-muted-foreground">{authorName}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{createdAt.toLocaleDateString("de-DE", {
|
||||||
|
dateStyle: "long",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Article, Category } from "@/server/db/schema";
|
import { Article } from "@/server/db/schema";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
@ -19,13 +19,13 @@ import {
|
|||||||
import { articleSchema } from "@/lib/validation/zod/article";
|
import { articleSchema } from "@/lib/validation/zod/article";
|
||||||
import { cn, debounce } from "@/lib/utils";
|
import { cn, debounce } from "@/lib/utils";
|
||||||
import { updateArticle } from "@/server/actions/article";
|
import { updateArticle } from "@/server/actions/article";
|
||||||
import Editor from "../text-editor";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import CategorySelect from "@/components/category/category-select";
|
import CategorySelect from "@/components/category/category-select";
|
||||||
import { CheckCircle, XCircle } from "lucide-react";
|
import { CheckCircle, XCircle } from "lucide-react";
|
||||||
import PublishArticleAlertDialog from "./publish-article-alert-dialog";
|
import PublishArticleAlertDialog from "./publish-article-alert-dialog";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import Editor from "../editor";
|
||||||
|
|
||||||
export default ({ server_article }: { server_article: Article }) => {
|
export default ({ server_article }: { server_article: Article }) => {
|
||||||
const [loading, setLoading] = React.useState(false);
|
const [loading, setLoading] = React.useState(false);
|
||||||
@ -46,7 +46,10 @@ export default ({ server_article }: { server_article: Article }) => {
|
|||||||
console.log("Content before save", values.content);
|
console.log("Content before save", values.content);
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
await updateArticle(values, server_article.id);
|
await updateArticle(
|
||||||
|
{ ...values, content: JSON.stringify(values.content) },
|
||||||
|
server_article.id,
|
||||||
|
);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
form.reset(values);
|
form.reset(values);
|
||||||
}
|
}
|
||||||
@ -58,10 +61,14 @@ export default ({ server_article }: { server_article: Article }) => {
|
|||||||
[form],
|
[form],
|
||||||
);
|
);
|
||||||
const published = form.watch("published");
|
const published = form.watch("published");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
<form
|
||||||
<div>
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="flex w-full flex-col-reverse gap-4 space-y-4 xl:flex-row"
|
||||||
|
>
|
||||||
|
<div className="flex w-full max-w-3xl flex-col gap-2">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="title"
|
name="title"
|
||||||
@ -70,7 +77,7 @@ export default ({ server_article }: { server_article: Article }) => {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<TextareaAutosize
|
<TextareaAutosize
|
||||||
cols={1}
|
cols={1}
|
||||||
className="w-full resize-none text-4xl font-bold focus-visible:outline-none"
|
className="h-max w-full resize-none text-4xl font-bold focus-visible:outline-none"
|
||||||
value={field.value}
|
value={field.value}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
field.onChange(e);
|
field.onChange(e);
|
||||||
@ -82,8 +89,6 @@ export default ({ server_article }: { server_article: Article }) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className="flex w-full gap-4">
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="content"
|
name="content"
|
||||||
@ -91,19 +96,10 @@ export default ({ server_article }: { server_article: Article }) => {
|
|||||||
<FormItem className="w-full">
|
<FormItem className="w-full">
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Editor
|
<Editor
|
||||||
// content={field.value}
|
initialContent={field.value}
|
||||||
editorProviderProps={{
|
onContentChange={(content) => {
|
||||||
content: field.value,
|
field.onChange(content);
|
||||||
editorProps: { attributes: { class: "min-h-64" } },
|
debouncedSubmit();
|
||||||
onUpdate: (value) => {
|
|
||||||
const newContent = value.editor.getHTML();
|
|
||||||
console.log(
|
|
||||||
"Content :: form",
|
|
||||||
JSON.stringify(newContent),
|
|
||||||
);
|
|
||||||
field.onChange(newContent);
|
|
||||||
debouncedSubmit();
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@ -112,99 +108,97 @@ export default ({ server_article }: { server_article: Article }) => {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div
|
</div>
|
||||||
className={cn(
|
|
||||||
"sticky top-4 h-max w-full max-w-md",
|
|
||||||
// loading && "border-t-blue-600",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="relative w-full space-y-4 overflow-hidden rounded-md border-t-2 bg-muted p-4">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"saving absolute left-0 right-0 top-0 h-0.5 rounded-full bg-primary",
|
|
||||||
!loading && "hidden",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex w-full items-center justify-between">
|
<div
|
||||||
<div className="flex items-center gap-2">
|
className={cn(
|
||||||
<Badge
|
"top-4 h-max w-full max-w-xl xl:sticky xl:max-w-md",
|
||||||
variant={"outline"}
|
// loading && "border-t-blue-600",
|
||||||
className="flex items-center gap-1"
|
)}
|
||||||
>
|
>
|
||||||
{!form.formState.isDirty && !loading ? (
|
<div className="relative w-full space-y-4 overflow-hidden rounded-md border-t-2 bg-muted p-4">
|
||||||
<>
|
<div
|
||||||
<CheckCircle className="size-4 text-emerald-600" />
|
className={cn(
|
||||||
<span>Gespeichert</span>
|
"saving absolute left-0 right-0 top-0 h-0.5 rounded-full bg-primary",
|
||||||
</>
|
!loading && "hidden",
|
||||||
) : (
|
)}
|
||||||
<>
|
/>
|
||||||
<XCircle className="size-4 text-destructive" />
|
|
||||||
<span>Nicht Gespeichert</span>
|
<div className="flex w-full items-center justify-between">
|
||||||
</>
|
<div className="flex items-center gap-2">
|
||||||
)}
|
<Badge variant={"outline"} className="flex items-center gap-1">
|
||||||
</Badge>
|
{!form.formState.isDirty && !loading ? (
|
||||||
<span
|
<>
|
||||||
className={cn(
|
<CheckCircle className="size-4 text-emerald-600" />
|
||||||
"text-xs",
|
<span>Gespeichert</span>
|
||||||
published ? "text-emerald-700" : "text-muted-foreground",
|
</>
|
||||||
)}
|
) : (
|
||||||
>
|
<>
|
||||||
{published
|
<XCircle className="size-4 text-destructive" />
|
||||||
? "Veröffentlicht"
|
<span>Nicht Gespeichert</span>
|
||||||
: "Draft (nicht veröffentlicht)"}
|
</>
|
||||||
</span>
|
)}
|
||||||
</div>
|
</Badge>
|
||||||
<Link
|
<span
|
||||||
href={"/editoren-hilfe"}
|
className={cn(
|
||||||
target="_blank"
|
"text-xs",
|
||||||
className="size-max scale-90 p-0 text-xs text-muted-foreground"
|
published ? "text-emerald-700" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<span>? Hilfe</span>
|
{published
|
||||||
</Link>
|
? "Veröffentlicht"
|
||||||
|
: "Draft (nicht veröffentlicht)"}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<FormField
|
<Link
|
||||||
control={form.control}
|
href={"/editoren-hilfe"}
|
||||||
name="published"
|
target="_blank"
|
||||||
render={({ field }) => (
|
className="size-max scale-90 p-0 text-xs text-muted-foreground"
|
||||||
<FormItem className="rounded-md border bg-background px-4 py-2">
|
>
|
||||||
<FormControl>
|
<span>? Hilfe</span>
|
||||||
<div className="flex items-center gap-2">
|
</Link>
|
||||||
<Label>Veröffentlicht </Label>
|
</div>
|
||||||
<PublishArticleAlertDialog
|
<FormField
|
||||||
published={field.value}
|
control={form.control}
|
||||||
setPublished={(value) => {
|
name="published"
|
||||||
field.onChange(value);
|
render={({ field }) => (
|
||||||
form.handleSubmit(onSubmit)();
|
<FormItem className="rounded-md border bg-background px-4 py-2">
|
||||||
}}
|
<FormControl>
|
||||||
/>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<Label>Veröffentlicht </Label>
|
||||||
</FormControl>
|
<PublishArticleAlertDialog
|
||||||
|
published={field.value}
|
||||||
<FormMessage />
|
setPublished={(value) => {
|
||||||
</FormItem>
|
field.onChange(value);
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="categoryId"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="w-full">
|
|
||||||
<FormControl>
|
|
||||||
<CategorySelect
|
|
||||||
initialValue={field.value}
|
|
||||||
onSelect={(categoryId) => {
|
|
||||||
field.onChange(categoryId);
|
|
||||||
form.handleSubmit(onSubmit)();
|
form.handleSubmit(onSubmit)();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="categoryId"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="w-full">
|
||||||
|
<FormControl>
|
||||||
|
<CategorySelect
|
||||||
|
initialValue={field.value}
|
||||||
|
onSelect={(categoryId) => {
|
||||||
|
field.onChange(categoryId);
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
33
src/components/article/grid/article-grid.tsx
Normal file
33
src/components/article/grid/article-grid.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { Article } from "@/server/db/schema";
|
||||||
|
import React from "react";
|
||||||
|
import ArticleCard from "../article-card";
|
||||||
|
import { Skeleton } from "../../ui/skeleton";
|
||||||
|
|
||||||
|
export const ARTICLE_GRID_CLASS = "grid grid-cols-1 gap-4 lg:grid-cols-2";
|
||||||
|
|
||||||
|
function ArticleGrid({ articles }: { articles: Article[] }) {
|
||||||
|
return (
|
||||||
|
<menu className={ARTICLE_GRID_CLASS}>
|
||||||
|
{articles.map((article) => (
|
||||||
|
<li key={article.slug} className="h-full">
|
||||||
|
<ArticleCard {...article} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ArticleGrid;
|
||||||
|
|
||||||
|
export function ArticleGridSkeleton() {
|
||||||
|
const range = Array.from(new Array(6).keys());
|
||||||
|
return (
|
||||||
|
<ul className={ARTICLE_GRID_CLASS}>
|
||||||
|
{range.map((i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
src/components/article/grid/infinite-article-grid.tsx
Normal file
61
src/components/article/grid/infinite-article-grid.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { api } from "@/trpc/react";
|
||||||
|
import React from "react";
|
||||||
|
import { ARTICLE_GRID_CLASS } from "./article-grid";
|
||||||
|
import ArticleCard from "../article-card";
|
||||||
|
import { useInfiniteItemsObserver } from "@/lib/hooks/infinite-items-observer-hook";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
function InfiniteArticlesGrid() {
|
||||||
|
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } =
|
||||||
|
api.article.getByPage.useInfiniteQuery(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Calculate all visible items across all loaded pages
|
||||||
|
const allItems = React.useMemo(() => {
|
||||||
|
return data?.pages.flatMap((page) => page.items) || [];
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// Ref for bottom observation
|
||||||
|
const bottomObserverRef = React.useRef(null);
|
||||||
|
|
||||||
|
useInfiniteItemsObserver({
|
||||||
|
bottomObserverRef,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<menu className={`${ARTICLE_GRID_CLASS} overflow-auto`}>
|
||||||
|
{data?.pages?.length
|
||||||
|
? allItems.map((article, idx) => (
|
||||||
|
<li key={`article-${idx}`}>
|
||||||
|
<ArticleCard {...article} />
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
|
||||||
|
{/* Loading indicator */}
|
||||||
|
{(isLoading || isFetchingNextPage) &&
|
||||||
|
Array.from(new Array(isLoading ? 16 : 4).keys()).map((idx) => (
|
||||||
|
<li key={idx}>
|
||||||
|
<Skeleton className="size-full min-h-20" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Bottom observer element */}
|
||||||
|
{hasNextPage && (
|
||||||
|
<li ref={bottomObserverRef} className="col-span-full h-12" />
|
||||||
|
)}
|
||||||
|
</menu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InfiniteArticlesGrid;
|
||||||
@ -1,8 +1,7 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Editor from "../text-editor";
|
|
||||||
|
|
||||||
function RenderArticle({ content }: { content: string }) {
|
function RenderArticle({ content }: { content: string }) {
|
||||||
return <Editor readOnly editorProviderProps={{ content: content }} />;
|
return "render article: in work"; //<Editor readOnly editorProviderProps={{ content: content }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default RenderArticle;
|
export default RenderArticle;
|
||||||
|
|||||||
@ -10,13 +10,15 @@ import {
|
|||||||
|
|
||||||
function BreadNavigator({
|
function BreadNavigator({
|
||||||
links,
|
links,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
links: { label: string; href: string }[];
|
links: { label: string; href: string }[];
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const labelClass =
|
const labelClass =
|
||||||
"w-full max-w-52 overflow-hidden text-ellipsis whitespace-nowrap capitalize block";
|
"w-full max-w-52 overflow-hidden text-ellipsis whitespace-nowrap capitalize block";
|
||||||
return (
|
return (
|
||||||
<Breadcrumb>
|
<Breadcrumb className={className}>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
{links.map(({ label, href }, idx) => {
|
{links.map(({ label, href }, idx) => {
|
||||||
if (idx < links.length - 1)
|
if (idx < links.length - 1)
|
||||||
|
|||||||
33
src/components/category/categories-grid.tsx
Normal file
33
src/components/category/categories-grid.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import CategoryCard from "@/components/category/category-card";
|
||||||
|
import { Category } from "@/server/db/schema";
|
||||||
|
import React from "react";
|
||||||
|
import { Skeleton } from "../ui/skeleton";
|
||||||
|
|
||||||
|
const GRID_CLASS = "grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3";
|
||||||
|
|
||||||
|
function CategoriesGrid({ categories }: { categories: Category[] }) {
|
||||||
|
return (
|
||||||
|
<menu className={GRID_CLASS}>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<li key={category.id}>
|
||||||
|
<CategoryCard {...category} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CategoriesGrid;
|
||||||
|
|
||||||
|
export function CategoriesGridSkeleton() {
|
||||||
|
const range = Array.from(new Array(6).keys());
|
||||||
|
return (
|
||||||
|
<ul className={GRID_CLASS}>
|
||||||
|
{range.map((i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<Skeleton className="h-12 w-full" />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/components/category/category-card.tsx
Normal file
50
src/components/category/category-card.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { appRoutes } from "@/config";
|
||||||
|
import { Category } from "@/server/db/schema";
|
||||||
|
import Link from "next/link";
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { AspectRatio } from "../ui/aspect-ratio";
|
||||||
|
import { CATEGORY_BANNER_ASPECT_RATIO } from "./category-form";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { Icons } from "../icons";
|
||||||
|
|
||||||
|
function CategoryCard({
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
createdAt,
|
||||||
|
image,
|
||||||
|
}: Pick<Category, "name" | "slug" | "createdAt" | "image">) {
|
||||||
|
return (
|
||||||
|
<Link href={appRoutes.category(slug)}>
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
{/* <AspectRatio
|
||||||
|
className="flex items-center justify-center bg-muted text-muted-foreground"
|
||||||
|
ratio={CATEGORY_BANNER_ASPECT_RATIO}
|
||||||
|
>
|
||||||
|
{image?.length ? (
|
||||||
|
<Image
|
||||||
|
src={image}
|
||||||
|
alt="Kategorie-bild"
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Icons.logo />
|
||||||
|
)}
|
||||||
|
</AspectRatio> */}
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{name}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CategoryCard;
|
||||||
120
src/components/category/category-form.tsx
Normal file
120
src/components/category/category-form.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { categorySchema } from "@/lib/validation/zod/category";
|
||||||
|
import { Category } from "@/server/db/schema";
|
||||||
|
import { Textarea } from "../ui/textarea";
|
||||||
|
import { updateCategory } from "@/server/actions/category";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import ImageUploadButton from "../image-upload-form";
|
||||||
|
import Image from "next/image";
|
||||||
|
import { AspectRatio } from "../ui/aspect-ratio";
|
||||||
|
|
||||||
|
export const CATEGORY_BANNER_ASPECT_RATIO = 20 / 3;
|
||||||
|
|
||||||
|
function CategoryForm({ server_category }: { server_category: Category }) {
|
||||||
|
const form = useForm<z.infer<typeof categorySchema>>({
|
||||||
|
resolver: zodResolver(categorySchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: server_category?.name ?? "",
|
||||||
|
description: server_category?.description ?? "",
|
||||||
|
image: server_category?.image ?? "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Define a submit handler.
|
||||||
|
async function onSubmit(values: z.infer<typeof categorySchema>) {
|
||||||
|
const success = await updateCategory(values, server_category.id);
|
||||||
|
if (success) {
|
||||||
|
toast.success("Kategorie gespeichert.");
|
||||||
|
form.reset();
|
||||||
|
} else toast.error("Speichern fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name der Kategorie</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="Name der Kategorie" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Beschreibung der Kategorie</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Das ist eine kurze beschreibung"
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="image"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Kategorie Banner</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<ImageUploadButton
|
||||||
|
onUploaded={(url) => {
|
||||||
|
field.onChange(url);
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
|
||||||
|
{field.value?.length ? (
|
||||||
|
<AspectRatio
|
||||||
|
ratio={CATEGORY_BANNER_ASPECT_RATIO}
|
||||||
|
className="w-full max-w-lg bg-muted"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={field.value}
|
||||||
|
alt="Kategorie-bild"
|
||||||
|
fill
|
||||||
|
className="h-full w-full rounded-md object-cover"
|
||||||
|
/>
|
||||||
|
</AspectRatio>
|
||||||
|
) : null}
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" disabled={!form.formState.isDirty}>
|
||||||
|
Kategorie Speichern
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CategoryForm;
|
||||||
@ -36,6 +36,7 @@ export function DataTable<TData, TValue>({
|
|||||||
columns,
|
columns,
|
||||||
data,
|
data,
|
||||||
}: DataTableProps<TData, TValue>) {
|
}: DataTableProps<TData, TValue>) {
|
||||||
|
"use no memo";
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageSize: 25,
|
pageSize: 25,
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
@ -45,6 +46,7 @@ export function DataTable<TData, TValue>({
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>({});
|
const [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>({});
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
@ -65,6 +67,7 @@ export function DataTable<TData, TValue>({
|
|||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{JSON.stringify(sorting)}
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Email suchen..."
|
placeholder="Email suchen..."
|
||||||
|
|||||||
124
src/components/editor/extentions/index.tsx
Normal file
124
src/components/editor/extentions/index.tsx
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import {
|
||||||
|
CharacterCount,
|
||||||
|
Color,
|
||||||
|
CustomKeymap,
|
||||||
|
GlobalDragHandle,
|
||||||
|
HighlightExtension,
|
||||||
|
HorizontalRule,
|
||||||
|
Placeholder,
|
||||||
|
StarterKit,
|
||||||
|
TaskItem,
|
||||||
|
TaskList,
|
||||||
|
TextStyle,
|
||||||
|
TiptapImage,
|
||||||
|
TiptapLink,
|
||||||
|
TiptapUnderline,
|
||||||
|
UpdatedImage,
|
||||||
|
UploadImagesPlugin,
|
||||||
|
Youtube,
|
||||||
|
} from "novel";
|
||||||
|
import LinkPreview from "./link-preview";
|
||||||
|
import { Heading } from "@tiptap/extension-heading";
|
||||||
|
import { cx } from "class-variance-authority";
|
||||||
|
import { slashCommand } from "./slash-commands";
|
||||||
|
|
||||||
|
const placeholder = Placeholder;
|
||||||
|
const tiptapLink = TiptapLink.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: cx(
|
||||||
|
"text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tiptapImage = TiptapImage.extend({
|
||||||
|
addProseMirrorPlugins() {
|
||||||
|
return [
|
||||||
|
UploadImagesPlugin({
|
||||||
|
imageClass: cx("opacity-40 rounded-lg border border-stone-200"),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
}).configure({
|
||||||
|
allowBase64: true,
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: cx("rounded-lg border border-muted"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedImage = UpdatedImage.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: cx("rounded-lg border border-muted"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskList = TaskList.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: cx("not-prose pl-2 "),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const taskItem = TaskItem.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: cx("flex gap-2 items-start my-4"),
|
||||||
|
},
|
||||||
|
nested: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const horizontalRule = HorizontalRule.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: cx("mt-4 mb-6 border-t border-muted-foreground"),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const starterKit = StarterKit.configure({
|
||||||
|
dropcursor: {
|
||||||
|
color: "#DBEAFE",
|
||||||
|
width: 4,
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
levels: [2, 3, 4, 5, 6],
|
||||||
|
},
|
||||||
|
gapcursor: false,
|
||||||
|
horizontalRule: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const youtube = Youtube.configure({
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: cx("rounded-lg border border-muted"),
|
||||||
|
},
|
||||||
|
inline: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const linkPreview = LinkPreview.configure({
|
||||||
|
async fetchMetadata(url) {
|
||||||
|
const response = await fetch(`/api/url-preview?url=${url}`);
|
||||||
|
const metadata = await response.json();
|
||||||
|
console.log("metadata", metadata);
|
||||||
|
|
||||||
|
return metadata?.meta;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const characterCount = CharacterCount.configure();
|
||||||
|
|
||||||
|
export const defaultExtensions = [
|
||||||
|
starterKit,
|
||||||
|
placeholder,
|
||||||
|
tiptapLink,
|
||||||
|
tiptapImage,
|
||||||
|
updatedImage,
|
||||||
|
taskList,
|
||||||
|
taskItem,
|
||||||
|
horizontalRule,
|
||||||
|
youtube,
|
||||||
|
characterCount,
|
||||||
|
TiptapUnderline,
|
||||||
|
HighlightExtension,
|
||||||
|
TextStyle,
|
||||||
|
Color,
|
||||||
|
CustomKeymap,
|
||||||
|
GlobalDragHandle,
|
||||||
|
slashCommand,
|
||||||
|
linkPreview,
|
||||||
|
];
|
||||||
140
src/components/editor/extentions/link-preview/index.tsx
Normal file
140
src/components/editor/extentions/link-preview/index.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import { mergeAttributes, Node } from "@tiptap/core";
|
||||||
|
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||||
|
import { LinkPreviewComponent } from "./link-preview-component";
|
||||||
|
|
||||||
|
export interface LinkPreviewOptions {
|
||||||
|
HTMLAttributes: {
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
fetchMetadata?: (
|
||||||
|
url: string,
|
||||||
|
) => Promise<{ title?: string; description?: string; cover?: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "@tiptap/core" {
|
||||||
|
interface Commands<ReturnType> {
|
||||||
|
linkPreview: {
|
||||||
|
/**
|
||||||
|
* Add an linkPreview
|
||||||
|
*/
|
||||||
|
|
||||||
|
createLinkPreview: () => ReturnType;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Node.create<LinkPreviewOptions>({
|
||||||
|
name: "linkPreview",
|
||||||
|
|
||||||
|
group: "block",
|
||||||
|
|
||||||
|
atom: true,
|
||||||
|
|
||||||
|
// content: "block+",
|
||||||
|
|
||||||
|
addOptions() {
|
||||||
|
return {
|
||||||
|
HTMLAttributes: {
|
||||||
|
class: "link-preview-wrapper",
|
||||||
|
},
|
||||||
|
fetchMetadata: async (url) => {
|
||||||
|
// Default: Simple metadata fetch (Override this via `configure()`)
|
||||||
|
return {
|
||||||
|
title: "Placeholder Title",
|
||||||
|
description:
|
||||||
|
"You need to provide a custom `fetchMetadata` function You need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` functionYou need to provide a custom `fetchMetadata` function",
|
||||||
|
cover: "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
addAttributes() {
|
||||||
|
return {
|
||||||
|
href: {
|
||||||
|
default: null,
|
||||||
|
parseHTML(element) {
|
||||||
|
return element.getAttribute("href");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
default: null,
|
||||||
|
parseHTML(element) {
|
||||||
|
return element?.firstChild?.childNodes?.[0]?.childNodes?.[0]
|
||||||
|
?.nodeValue;
|
||||||
|
},
|
||||||
|
renderHTML() {
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
default: null,
|
||||||
|
parseHTML(element) {
|
||||||
|
return element?.firstChild?.childNodes?.[1]?.childNodes?.[0]
|
||||||
|
?.nodeValue;
|
||||||
|
},
|
||||||
|
renderHTML() {
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
default: null,
|
||||||
|
parseHTML(element) {
|
||||||
|
// @ts-ignore
|
||||||
|
return element.childNodes[1].src;
|
||||||
|
},
|
||||||
|
renderHTML() {
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
parseHTML() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
tag: `div[data-type="${this.name}"]`,
|
||||||
|
priority: 1000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
renderHTML({ HTMLAttributes }) {
|
||||||
|
return ["div", mergeAttributes({ "data-type": this.name }, HTMLAttributes)];
|
||||||
|
},
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(LinkPreviewComponent);
|
||||||
|
},
|
||||||
|
addPasteRules() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
find: /(https?:\/\/[^\s]+)/g,
|
||||||
|
handler: ({ match, state, range }) => {
|
||||||
|
const url = match[0];
|
||||||
|
|
||||||
|
// Replace the pasted URL with the link preview node
|
||||||
|
state.tr.replaceWith(
|
||||||
|
range.from,
|
||||||
|
range.to,
|
||||||
|
this.type.create({ href: url }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
addCommands() {
|
||||||
|
return {
|
||||||
|
createLinkPreview:
|
||||||
|
() =>
|
||||||
|
({ chain }) => {
|
||||||
|
return chain()
|
||||||
|
.insertContent({
|
||||||
|
type: "linkPreview",
|
||||||
|
attrs: {},
|
||||||
|
})
|
||||||
|
.focus()
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,136 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ArrowRight } from "lucide-react";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
export type LinkPreviewData = {
|
||||||
|
href: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
image: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const InputLink = ({ onSubmit }: { onSubmit: (link: string) => void }) => {
|
||||||
|
const [link, setLink] = React.useState("");
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault(); // Prevent page reload
|
||||||
|
if (link.trim()) {
|
||||||
|
onSubmit(link); // Pass link to parent function
|
||||||
|
setLink(""); // Clear input field after submission
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
React.useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="flex gap-2 " autoFocus>
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
autoFocus
|
||||||
|
type="url"
|
||||||
|
placeholder="Enter a link"
|
||||||
|
value={link}
|
||||||
|
onChange={(e) => setLink(e.target.value)}
|
||||||
|
className="flex-1 focus-visible:ring-transparent border-0"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant={"ghost"}
|
||||||
|
size={"icon"}
|
||||||
|
className="text-muted-foreground"
|
||||||
|
>
|
||||||
|
<ArrowRight />
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Preview = ({
|
||||||
|
href,
|
||||||
|
title,
|
||||||
|
description: _description,
|
||||||
|
image,
|
||||||
|
}: LinkPreviewData) => {
|
||||||
|
const description =
|
||||||
|
_description?.length > 100
|
||||||
|
? `${_description?.slice(0, 150)}...`
|
||||||
|
: _description;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a href={href} target="_blank">
|
||||||
|
<div className=" flex gap-4 flex-col-reverse md:flex-row">
|
||||||
|
<div className="w-full space-y-2">
|
||||||
|
<h2
|
||||||
|
className="text-xl"
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: "1.5rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm mt-2">{description}</p>
|
||||||
|
<span className="text-xs text-muted-foreground ">{href}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{image?.length ? (
|
||||||
|
<img
|
||||||
|
src={image}
|
||||||
|
alt={title}
|
||||||
|
className="w-full max-w-40 rounded-md object-cover "
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="size-20 rounded-md bg-muted" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LinkPreviewComponent: React.FC<NodeViewProps> = ({
|
||||||
|
node,
|
||||||
|
updateAttributes,
|
||||||
|
extension,
|
||||||
|
}) => {
|
||||||
|
const [preview, setPreview] = React.useState<LinkPreviewData | undefined>(
|
||||||
|
(node.attrs?.href?.length && (node.attrs as LinkPreviewData)) ?? undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper as="div" className="p-4 rounded-md bg-background border ">
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-8 w-full rounded" />
|
||||||
|
) : preview ? (
|
||||||
|
<Preview {...preview} />
|
||||||
|
) : (
|
||||||
|
<InputLink
|
||||||
|
onSubmit={async (url: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const metadata = await extension.options.fetchMetadata(url);
|
||||||
|
const newAttrs = {
|
||||||
|
href: url,
|
||||||
|
title: metadata?.title,
|
||||||
|
description: metadata?.description,
|
||||||
|
image: metadata?.image,
|
||||||
|
};
|
||||||
|
updateAttributes(newAttrs);
|
||||||
|
setPreview(newAttrs);
|
||||||
|
setLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching metadata:", error);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
195
src/components/editor/extentions/slash-commands/index.tsx
Normal file
195
src/components/editor/extentions/slash-commands/index.tsx
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
import {
|
||||||
|
CheckSquare,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
Heading4,
|
||||||
|
ImageIcon,
|
||||||
|
Link2Icon,
|
||||||
|
List,
|
||||||
|
ListOrdered,
|
||||||
|
Text,
|
||||||
|
TextQuote,
|
||||||
|
Youtube,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { Command, renderItems, createSuggestionItems } from "novel";
|
||||||
|
import { selectionItems } from "../../selector/selection-items";
|
||||||
|
|
||||||
|
// const items = selectionItems.filter((item) => !item.inline);
|
||||||
|
// const defaultSuggestionItems = items.map((item) => (
|
||||||
|
// {
|
||||||
|
// title: item.name,
|
||||||
|
// description: item.name,
|
||||||
|
// searchTerms: [item.name],
|
||||||
|
// icon: item.icon,
|
||||||
|
// command: ({ editor, range }) => {
|
||||||
|
// editor.chain().focus().deleteRange(range).toggleNode(item.name).run();
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
// ));
|
||||||
|
|
||||||
|
export const suggestionItems = createSuggestionItems([
|
||||||
|
{
|
||||||
|
title: "Text",
|
||||||
|
description: "Just start typing with plain text.",
|
||||||
|
searchTerms: ["p", "paragraph"],
|
||||||
|
icon: <Text size={18} />,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.toggleNode("paragraph", "paragraph")
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "To-do List",
|
||||||
|
description: "Track tasks with a to-do list.",
|
||||||
|
searchTerms: ["todo", "task", "list", "check", "checkbox"],
|
||||||
|
icon: <CheckSquare size={18} />,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleTaskList().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Heading 2",
|
||||||
|
description: "Medium section heading.",
|
||||||
|
searchTerms: ["subtitle", "medium"],
|
||||||
|
icon: <Heading2 size={18} />,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setNode("heading", { level: 2 })
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Heading 3",
|
||||||
|
description: "Small section heading.",
|
||||||
|
searchTerms: ["subtitle", "small"],
|
||||||
|
icon: <Heading3 size={18} />,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setNode("heading", { level: 3 })
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Heading 4",
|
||||||
|
description: "Big section heading.",
|
||||||
|
searchTerms: ["title", "big", "large"],
|
||||||
|
icon: <Heading4 size={18} />,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setNode("heading", { level: 4 })
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Bullet List",
|
||||||
|
description: "Create a simple bullet list.",
|
||||||
|
searchTerms: ["unordered", "point"],
|
||||||
|
icon: <List size={18} />,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleBulletList().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Numbered List",
|
||||||
|
description: "Create a list with numbering.",
|
||||||
|
searchTerms: ["ordered"],
|
||||||
|
icon: <ListOrdered size={18} />,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor.chain().focus().deleteRange(range).toggleOrderedList().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Quote",
|
||||||
|
description: "Capture a quote.",
|
||||||
|
searchTerms: ["blockquote"],
|
||||||
|
icon: <TextQuote size={18} />,
|
||||||
|
command: ({ editor, range }) =>
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.toggleNode("paragraph", "paragraph")
|
||||||
|
.toggleBlockquote()
|
||||||
|
.run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Link Preview",
|
||||||
|
description: "Embed a Link Preview.",
|
||||||
|
searchTerms: ["link"],
|
||||||
|
icon: <Link2Icon size={18} />,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor.chain().focus().deleteRange(range).createLinkPreview().run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
title: "Image",
|
||||||
|
description: "Upload an image from your computer.",
|
||||||
|
searchTerms: ["photo", "picture", "media"],
|
||||||
|
icon: <ImageIcon size={18} />,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
editor.chain().focus().deleteRange(range).run();
|
||||||
|
// upload image
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.accept = "image/*";
|
||||||
|
input.onchange = async () => {
|
||||||
|
if (input.files?.length) {
|
||||||
|
const file = input.files[0];
|
||||||
|
const pos = editor.view.state.selection.from;
|
||||||
|
// uploadFn(file, editor.view, pos);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Youtube",
|
||||||
|
description: "Embed a Youtube video.",
|
||||||
|
searchTerms: ["video", "youtube", "embed"],
|
||||||
|
icon: <Youtube size={18} />,
|
||||||
|
command: ({ editor, range }) => {
|
||||||
|
const videoLink = prompt("Please enter Youtube Video Link");
|
||||||
|
//From https://regexr.com/3dj5t
|
||||||
|
const ytregex = new RegExp(
|
||||||
|
/^((?:https?:)?\/\/)?((?:www|m)\.)?((?:youtube\.com|youtu.be))(\/(?:[\w\-]+\?v=|embed\/|v\/)?)([\w\-]+)(\S+)?$/,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (ytregex.test(String(videoLink))) {
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange(range)
|
||||||
|
.setYoutubeVideo({
|
||||||
|
src: String(videoLink),
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
} else {
|
||||||
|
if (videoLink !== null) {
|
||||||
|
alert("Please enter a correct Youtube Video Link");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const slashCommand = Command.configure({
|
||||||
|
suggestion: {
|
||||||
|
items: () => suggestionItems,
|
||||||
|
render: renderItems,
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
import {
|
||||||
|
EditorCommand,
|
||||||
|
EditorCommandEmpty,
|
||||||
|
EditorCommandItem,
|
||||||
|
EditorCommandList,
|
||||||
|
} from "novel";
|
||||||
|
import { suggestionItems } from ".";
|
||||||
|
|
||||||
|
export function SlashCommandComponent() {
|
||||||
|
return (
|
||||||
|
<EditorCommand className="z-50 h-auto max-h-[330px] w-72 overflow-y-auto rounded-md border border-muted bg-background px-1 py-2 shadow-md transition-all">
|
||||||
|
<EditorCommandEmpty className="px-2 text-muted-foreground">
|
||||||
|
No results
|
||||||
|
</EditorCommandEmpty>
|
||||||
|
<EditorCommandList>
|
||||||
|
{suggestionItems.map((item) => (
|
||||||
|
<EditorCommandItem
|
||||||
|
value={item.title}
|
||||||
|
onCommand={(val) => item?.command?.(val)}
|
||||||
|
className={`flex w-full items-center space-x-2 rounded-md px-2 py-1 text-left text-sm hover:bg-accent aria-selected:bg-accent `}
|
||||||
|
key={item.title}
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-md border border-muted bg-background">
|
||||||
|
{item.icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{item.title}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{item.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</EditorCommandItem>
|
||||||
|
))}
|
||||||
|
</EditorCommandList>
|
||||||
|
</EditorCommand>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/components/editor/index.tsx
Normal file
43
src/components/editor/index.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
import "./styles.css";
|
||||||
|
import {
|
||||||
|
EditorContent,
|
||||||
|
EditorRoot,
|
||||||
|
handleCommandNavigation,
|
||||||
|
JSONContent,
|
||||||
|
} from "novel";
|
||||||
|
import { defaultExtensions } from "./extentions";
|
||||||
|
import { SlashCommandComponent } from "./extentions/slash-commands/slash-command-component";
|
||||||
|
import BubbleMenu from "./menu/bubble-menu";
|
||||||
|
import { MenuBar } from "./menu/menu-bar";
|
||||||
|
|
||||||
|
const Editor = ({
|
||||||
|
onContentChange,
|
||||||
|
initialContent,
|
||||||
|
}: {
|
||||||
|
initialContent: JSONContent | null;
|
||||||
|
onContentChange: (content: JSONContent) => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<EditorRoot>
|
||||||
|
<EditorContent
|
||||||
|
slotBefore={<MenuBar />}
|
||||||
|
extensions={defaultExtensions}
|
||||||
|
editorProps={{
|
||||||
|
handleDOMEvents: {
|
||||||
|
keydown: (_view, event) => handleCommandNavigation(event),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
initialContent={initialContent ?? { type: "doc" }}
|
||||||
|
onUpdate={({ editor }) => {
|
||||||
|
onContentChange(editor.getJSON());
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SlashCommandComponent />
|
||||||
|
|
||||||
|
<BubbleMenu />
|
||||||
|
</EditorContent>
|
||||||
|
</EditorRoot>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default Editor;
|
||||||
43
src/components/editor/menu/bubble-menu.tsx
Normal file
43
src/components/editor/menu/bubble-menu.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
import { EditorBubble, useEditor } from "novel";
|
||||||
|
import { Fragment, useState } from "react";
|
||||||
|
import { NodeSelector } from "../selector/node-selector";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { LinkSelector } from "../selector/link-selector";
|
||||||
|
import { TextButtons } from "../selector/text-buttont";
|
||||||
|
import { ColorSelector } from "../selector/color-selector";
|
||||||
|
|
||||||
|
const BubbleMenu = () => {
|
||||||
|
const { editor } = useEditor();
|
||||||
|
const [openNode, setOpenNode] = useState(false);
|
||||||
|
const [openColor, setOpenColor] = useState(false);
|
||||||
|
const [openLink, setOpenLink] = useState(false);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditorBubble
|
||||||
|
tippyOptions={{
|
||||||
|
placement: open ? "bottom-start" : "top",
|
||||||
|
onHidden: () => {
|
||||||
|
setOpen(false);
|
||||||
|
editor?.chain().unsetHighlight().run();
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className="flex w-fit max-w-[90vw] overflow-hidden rounded-md border border-muted bg-background shadow-xl"
|
||||||
|
>
|
||||||
|
{!open && (
|
||||||
|
<Fragment>
|
||||||
|
<NodeSelector open={openNode} onOpenChange={setOpenNode} />
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
<LinkSelector open={openLink} onOpenChange={setOpenLink} />
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
<TextButtons />
|
||||||
|
<Separator orientation="vertical" />
|
||||||
|
<ColorSelector open={openColor} onOpenChange={setOpenColor} />
|
||||||
|
</Fragment>
|
||||||
|
)}
|
||||||
|
</EditorBubble>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BubbleMenu;
|
||||||
49
src/components/editor/menu/menu-bar.tsx
Normal file
49
src/components/editor/menu/menu-bar.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useEditor } from "novel";
|
||||||
|
import { RedoIcon, UndoIcon } from "lucide-react";
|
||||||
|
import { selectionItems } from "../selector/selection-items";
|
||||||
|
|
||||||
|
export const MenuBar = () => {
|
||||||
|
const { editor } = useEditor();
|
||||||
|
|
||||||
|
if (!editor) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sticky top-0 z-50 flex justify-between gap-1 bg-background py-4">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{selectionItems.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.name}
|
||||||
|
size={"icon"}
|
||||||
|
variant={"outline"}
|
||||||
|
onClick={() => item.command(editor)}
|
||||||
|
disabled={item?.canRun ? item.canRun(editor) : false}
|
||||||
|
className={item.isActive(editor) ? "border-foreground/75" : ""}
|
||||||
|
>
|
||||||
|
<item.icon />
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant={"outline"}
|
||||||
|
size={"icon"}
|
||||||
|
onClick={() => editor.chain().focus().undo().run()}
|
||||||
|
disabled={!editor.can().chain().focus().undo().run()}
|
||||||
|
>
|
||||||
|
<UndoIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={"outline"}
|
||||||
|
size={"icon"}
|
||||||
|
onClick={() => editor.chain().focus().redo().run()}
|
||||||
|
disabled={!editor.can().chain().focus().redo().run()}
|
||||||
|
>
|
||||||
|
<RedoIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
196
src/components/editor/selector/color-selector.tsx
Normal file
196
src/components/editor/selector/color-selector.tsx
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
import { Check, ChevronDown } from "lucide-react";
|
||||||
|
import { EditorBubbleItem, useEditor } from "novel";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
export interface BubbleColorMenuItem {
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEXT_COLORS: BubbleColorMenuItem[] = [
|
||||||
|
{
|
||||||
|
name: "Default",
|
||||||
|
color: "var(--novel-black)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Purple",
|
||||||
|
color: "#9333EA",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Red",
|
||||||
|
color: "#E00000",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Yellow",
|
||||||
|
color: "#EAB308",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blue",
|
||||||
|
color: "#2563EB",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Green",
|
||||||
|
color: "#008A00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Orange",
|
||||||
|
color: "#FFA500",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Pink",
|
||||||
|
color: "#BA4081",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Gray",
|
||||||
|
color: "#A8A29E",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const HIGHLIGHT_COLORS: BubbleColorMenuItem[] = [
|
||||||
|
{
|
||||||
|
name: "Default",
|
||||||
|
color: "var(--novel-highlight-default)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Purple",
|
||||||
|
color: "var(--novel-highlight-purple)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Red",
|
||||||
|
color: "var(--novel-highlight-red)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Yellow",
|
||||||
|
color: "var(--novel-highlight-yellow)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blue",
|
||||||
|
color: "var(--novel-highlight-blue)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Green",
|
||||||
|
color: "var(--novel-highlight-green)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Orange",
|
||||||
|
color: "var(--novel-highlight-orange)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Pink",
|
||||||
|
color: "var(--novel-highlight-pink)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Gray",
|
||||||
|
color: "var(--novel-highlight-gray)",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ColorSelectorProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ColorSelector = ({ open, onOpenChange }: ColorSelectorProps) => {
|
||||||
|
const { editor } = useEditor();
|
||||||
|
|
||||||
|
if (!editor) return null;
|
||||||
|
const activeColorItem = TEXT_COLORS.find(({ color }) =>
|
||||||
|
editor.isActive("textStyle", { color })
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeHighlightItem = HIGHLIGHT_COLORS.find(({ color }) =>
|
||||||
|
editor.isActive("highlight", { color })
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button size="sm" className="gap-2 rounded-none" variant="ghost">
|
||||||
|
<span
|
||||||
|
className="rounded-sm px-1"
|
||||||
|
style={{
|
||||||
|
color: activeColorItem?.color,
|
||||||
|
backgroundColor: activeHighlightItem?.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
A
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
sideOffset={5}
|
||||||
|
className="my-1 flex max-h-80 w-48 flex-col overflow-hidden overflow-y-auto rounded border p-1 shadow-xl "
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<div className="my-1 px-2 text-sm font-semibold text-muted-foreground">
|
||||||
|
Color
|
||||||
|
</div>
|
||||||
|
{TEXT_COLORS.map(({ name, color }) => (
|
||||||
|
<EditorBubbleItem
|
||||||
|
key={name}
|
||||||
|
onSelect={() => {
|
||||||
|
editor.commands.unsetColor();
|
||||||
|
name !== "Default" &&
|
||||||
|
editor
|
||||||
|
.chain()
|
||||||
|
.focus()
|
||||||
|
.setColor(color || "")
|
||||||
|
.run();
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
className="flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="rounded-sm border px-2 py-px font-medium"
|
||||||
|
style={{ color }}
|
||||||
|
>
|
||||||
|
A
|
||||||
|
</div>
|
||||||
|
<span>{name}</span>
|
||||||
|
</div>
|
||||||
|
</EditorBubbleItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="my-1 px-2 text-sm font-semibold text-muted-foreground">
|
||||||
|
Background
|
||||||
|
</div>
|
||||||
|
{HIGHLIGHT_COLORS.map(({ name, color }) => (
|
||||||
|
<EditorBubbleItem
|
||||||
|
key={name}
|
||||||
|
onSelect={() => {
|
||||||
|
editor.commands.unsetHighlight();
|
||||||
|
name !== "Default" &&
|
||||||
|
editor.chain().focus().setHighlight({ color }).run();
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
className="flex cursor-pointer items-center justify-between px-2 py-1 text-sm hover:bg-accent"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="rounded-sm border px-2 py-px font-medium"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
>
|
||||||
|
A
|
||||||
|
</div>
|
||||||
|
<span>{name}</span>
|
||||||
|
</div>
|
||||||
|
{editor.isActive("highlight", { color }) && (
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</EditorBubbleItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
106
src/components/editor/selector/link-selector.tsx
Normal file
106
src/components/editor/selector/link-selector.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { PopoverContent } from "@/components/ui/popover";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Popover, PopoverTrigger } from "@radix-ui/react-popover";
|
||||||
|
import { Check, Trash } from "lucide-react";
|
||||||
|
import { useEditor } from "novel";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export function isValidUrl(url: string) {
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
return true;
|
||||||
|
} catch (_e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function getUrlFromString(str: string) {
|
||||||
|
if (isValidUrl(str)) return str;
|
||||||
|
try {
|
||||||
|
if (str.includes(".") && !str.includes(" ")) {
|
||||||
|
return new URL(`https://${str}`).toString();
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interface LinkSelectorProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LinkSelector = ({ open, onOpenChange }: LinkSelectorProps) => {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const { editor } = useEditor();
|
||||||
|
|
||||||
|
// Autofocus on input by default
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
});
|
||||||
|
if (!editor) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="gap-2 rounded-none border-none"
|
||||||
|
>
|
||||||
|
<p className="text-base">↗</p>
|
||||||
|
<p
|
||||||
|
className={cn("underline decoration-stone-400 underline-offset-4", {
|
||||||
|
"text-blue-500": editor.isActive("link"),
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Link
|
||||||
|
</p>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-60 p-0" sideOffset={10}>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
const target = e.currentTarget as HTMLFormElement;
|
||||||
|
e.preventDefault();
|
||||||
|
const input = target[0] as HTMLInputElement;
|
||||||
|
const url = getUrlFromString(input.value);
|
||||||
|
if (url) {
|
||||||
|
editor.chain().focus().setLink({ href: url }).run();
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex p-1"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder="Paste a link"
|
||||||
|
className="flex-1 bg-background p-1 text-sm outline-none"
|
||||||
|
defaultValue={editor.getAttributes("link").href || ""}
|
||||||
|
/>
|
||||||
|
{editor.getAttributes("link").href ? (
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
className="flex h-8 items-center rounded-sm p-1 text-red-600 transition-all hover:bg-red-100 dark:hover:bg-red-800"
|
||||||
|
onClick={() => {
|
||||||
|
editor.chain().focus().unsetLink().run();
|
||||||
|
if (inputRef?.current) {
|
||||||
|
inputRef.current.value = "";
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="icon" className="h-8">
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
56
src/components/editor/selector/node-selector.tsx
Normal file
56
src/components/editor/selector/node-selector.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { Check, ChevronDown } from "lucide-react";
|
||||||
|
import { EditorBubbleItem, useEditor } from "novel";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Popover } from "@radix-ui/react-popover";
|
||||||
|
import { selectionItems } from "./selection-items";
|
||||||
|
|
||||||
|
interface NodeSelectorProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
const items = selectionItems.filter((item) => !item.inline);
|
||||||
|
|
||||||
|
export const NodeSelector = ({ open, onOpenChange }: NodeSelectorProps) => {
|
||||||
|
const { editor } = useEditor();
|
||||||
|
if (!editor) return null;
|
||||||
|
const activeList = items.filter((item) => item.isActive(editor));
|
||||||
|
const activeItem = activeList.pop() ?? {
|
||||||
|
name: "Multiple",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover modal={true} open={open} onOpenChange={onOpenChange}>
|
||||||
|
<PopoverTrigger
|
||||||
|
asChild
|
||||||
|
className="gap-2 rounded-none border-none hover:bg-accent focus:ring-0"
|
||||||
|
>
|
||||||
|
<Button size="sm" variant="ghost" className="gap-2">
|
||||||
|
<span className="whitespace-nowrap text-sm">{activeItem.name}</span>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent sideOffset={5} align="start" className="w-48 p-1">
|
||||||
|
{items.map((item) => (
|
||||||
|
<EditorBubbleItem
|
||||||
|
key={item.name}
|
||||||
|
onSelect={(editor) => {
|
||||||
|
item.command(editor);
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
className="flex cursor-pointer items-center justify-between rounded-sm px-2 py-1 text-sm hover:bg-accent"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="rounded-sm border p-1">
|
||||||
|
<item.icon className="h-3 w-3" />
|
||||||
|
</div>
|
||||||
|
<span>{item.name}</span>
|
||||||
|
</div>
|
||||||
|
{activeItem.name === item.name && <Check className="h-4 w-4" />}
|
||||||
|
</EditorBubbleItem>
|
||||||
|
))}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
140
src/components/editor/selector/selection-items.tsx
Normal file
140
src/components/editor/selector/selection-items.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
import {
|
||||||
|
BoldIcon,
|
||||||
|
ItalicIcon,
|
||||||
|
LucideIcon,
|
||||||
|
StrikethroughIcon,
|
||||||
|
UnderlineIcon,
|
||||||
|
CheckSquare,
|
||||||
|
Heading2,
|
||||||
|
Heading3,
|
||||||
|
Heading4,
|
||||||
|
ListOrdered,
|
||||||
|
TextIcon,
|
||||||
|
TextQuote,
|
||||||
|
SeparatorHorizontalIcon,
|
||||||
|
Heading5,
|
||||||
|
Heading6,
|
||||||
|
QuoteIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { EditorInstance } from "novel";
|
||||||
|
|
||||||
|
export type SelectorItem = {
|
||||||
|
name: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
inline?: boolean;
|
||||||
|
command: (editor: EditorInstance) => void;
|
||||||
|
isActive: (editor: EditorInstance) => boolean;
|
||||||
|
canRun?: (editor: EditorInstance) => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectionItems: SelectorItem[] = [
|
||||||
|
{
|
||||||
|
name: "bold",
|
||||||
|
isActive: (editor) => editor.isActive("bold"),
|
||||||
|
command: (editor) => editor.chain().focus().toggleBold().run(),
|
||||||
|
icon: BoldIcon,
|
||||||
|
inline: true,
|
||||||
|
canRun: (editor) => !editor.can().chain().focus().toggleBold().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "italic",
|
||||||
|
isActive: (editor) => editor.isActive("italic"),
|
||||||
|
command: (editor) => editor.chain().focus().toggleItalic().run(),
|
||||||
|
icon: ItalicIcon,
|
||||||
|
inline: true,
|
||||||
|
canRun: (editor) => !editor.can().chain().focus().toggleItalic().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "underline",
|
||||||
|
isActive: (editor) => editor.isActive("underline"),
|
||||||
|
command: (editor) => editor.chain().focus().toggleUnderline().run(),
|
||||||
|
icon: UnderlineIcon,
|
||||||
|
inline: true,
|
||||||
|
canRun: (editor) => !editor.can().chain().focus().toggleUnderline().run(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "strike",
|
||||||
|
isActive: (editor) => editor.isActive("strike"),
|
||||||
|
command: (editor) => editor.chain().focus().toggleStrike().run(),
|
||||||
|
icon: StrikethroughIcon,
|
||||||
|
inline: true,
|
||||||
|
canRun: (editor) => !editor.can().chain().focus().toggleStrike().run(),
|
||||||
|
},
|
||||||
|
|
||||||
|
// blocks
|
||||||
|
{
|
||||||
|
name: "Text",
|
||||||
|
icon: TextIcon,
|
||||||
|
command: (editor) => editor.chain().focus().clearNodes().run(),
|
||||||
|
isActive: (editor) => editor.isActive("paragraph"), //node?.type?.name === "paragraph",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Überschrift",
|
||||||
|
icon: Heading2,
|
||||||
|
command: (editor) =>
|
||||||
|
editor.chain().focus().clearNodes().toggleHeading({ level: 2 }).run(),
|
||||||
|
isActive: (editor) => editor.isActive("heading", { level: 2 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Überschrift 3",
|
||||||
|
icon: Heading3,
|
||||||
|
command: (editor) =>
|
||||||
|
editor.chain().focus().clearNodes().toggleHeading({ level: 3 }).run(),
|
||||||
|
isActive: (editor) => editor.isActive("heading", { level: 3 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Überschrift 4",
|
||||||
|
icon: Heading4,
|
||||||
|
command: (editor) =>
|
||||||
|
editor.chain().focus().clearNodes().toggleHeading({ level: 4 }).run(),
|
||||||
|
isActive: (editor) => editor.isActive("heading", { level: 4 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Überschrift 5",
|
||||||
|
icon: Heading5,
|
||||||
|
command: (editor) =>
|
||||||
|
editor.chain().focus().clearNodes().toggleHeading({ level: 5 }).run(),
|
||||||
|
isActive: (editor) => editor.isActive("heading", { level: 5 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Überschrift 6",
|
||||||
|
icon: Heading6,
|
||||||
|
command: (editor) =>
|
||||||
|
editor.chain().focus().clearNodes().toggleHeading({ level: 6 }).run(),
|
||||||
|
isActive: (editor) => editor.isActive("heading", { level: 6 }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "To-do List",
|
||||||
|
icon: CheckSquare,
|
||||||
|
command: (editor) =>
|
||||||
|
editor.chain().focus().clearNodes().toggleTaskList().run(),
|
||||||
|
isActive: (editor) => editor.isActive("taskItem"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Bullet List",
|
||||||
|
icon: ListOrdered,
|
||||||
|
command: (editor) =>
|
||||||
|
editor.chain().focus().clearNodes().toggleBulletList().run(),
|
||||||
|
isActive: (editor) => editor.isActive("bulletList"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Numbered List",
|
||||||
|
icon: ListOrdered,
|
||||||
|
command: (editor) =>
|
||||||
|
editor.chain().focus().clearNodes().toggleOrderedList().run(),
|
||||||
|
isActive: (editor) => editor.isActive("orderedList"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Quote",
|
||||||
|
icon: QuoteIcon,
|
||||||
|
command: (editor) =>
|
||||||
|
editor.chain().focus().clearNodes().toggleBlockquote().run(),
|
||||||
|
isActive: (editor) => editor.isActive("blockquote"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Seperator",
|
||||||
|
icon: SeparatorHorizontalIcon,
|
||||||
|
command: (editor) => editor.chain().focus().setHorizontalRule().run(),
|
||||||
|
isActive: (editor) => editor.isActive("horizontalRule"),
|
||||||
|
},
|
||||||
|
];
|
||||||
41
src/components/editor/selector/text-buttont.tsx
Normal file
41
src/components/editor/selector/text-buttont.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { EditorBubbleItem, useEditor } from "novel";
|
||||||
|
import { selectionItems } from "./selection-items";
|
||||||
|
|
||||||
|
const items = selectionItems.filter((item) => item.inline);
|
||||||
|
|
||||||
|
export const TextButtons = () => {
|
||||||
|
const { editor } = useEditor();
|
||||||
|
if (!editor) return null;
|
||||||
|
editor.on("selectionUpdate", () => {
|
||||||
|
editor.view.dispatch(editor.state.tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex">
|
||||||
|
{items.map((item) => (
|
||||||
|
<EditorBubbleItem
|
||||||
|
key={item.name}
|
||||||
|
onSelect={(editor) => {
|
||||||
|
item.command(editor);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="rounded-none"
|
||||||
|
variant="ghost"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<item.icon
|
||||||
|
className={cn(
|
||||||
|
"h-4 w-4",
|
||||||
|
item.isActive(editor) && "text-blue-500",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</EditorBubbleItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
175
src/components/editor/styles.css
Normal file
175
src/components/editor/styles.css
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
/* Basic editor styles */
|
||||||
|
.tiptap > *:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror {
|
||||||
|
min-height: 100vh;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ul li p,
|
||||||
|
.tiptap ol li p {
|
||||||
|
margin: 0.75rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ul,
|
||||||
|
.tiptap ol {
|
||||||
|
margin-left: 1rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ul {
|
||||||
|
list-style-type: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap ol {
|
||||||
|
list-style-type: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Heading styles */
|
||||||
|
.tiptap h1,
|
||||||
|
.tiptap h2,
|
||||||
|
.tiptap h3,
|
||||||
|
.tiptap h4,
|
||||||
|
.tiptap h5,
|
||||||
|
.tiptap h6 {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
line-height: 1.1;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap h1,
|
||||||
|
.tiptap h2 {
|
||||||
|
margin: 1rem 0;
|
||||||
|
font-size: 2.25rem; /* Equivalent to text-4xl */
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap h3 {
|
||||||
|
font-size: 1.875rem; /* Equivalent to text-3xl */
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap h4 {
|
||||||
|
font-size: 1.5rem; /* Equivalent to text-2xl */
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap h5 {
|
||||||
|
font-size: 1.25rem; /* Equivalent to text-xl */
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap h6 {
|
||||||
|
font-size: 1.125rem; /* Equivalent to text-lg */
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap blockquote {
|
||||||
|
@apply m-4 border-l-4 border-border pl-4;
|
||||||
|
/* border-left: 1rem solid var(--primary);
|
||||||
|
padding-left: 1rem; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap hr {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: #5abbf7;
|
||||||
|
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-bar button {
|
||||||
|
transition-duration: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-bar .is-active {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task List */
|
||||||
|
ul[data-type="taskList"] li > label input[type="checkbox"] {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
|
||||||
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 1.2em;
|
||||||
|
height: 1.2em;
|
||||||
|
position: relative;
|
||||||
|
top: 5px;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
margin-right: 0.3rem;
|
||||||
|
display: grid;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
place-content: center;
|
||||||
|
}
|
||||||
|
/* ul[data-type="taskList"] li > label input[type="checkbox"]:hover {
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
ul[data-type="taskList"] li > label input[type="checkbox"]:active {
|
||||||
|
background-color: #000;
|
||||||
|
} */
|
||||||
|
ul[data-type="taskList"] li > label input[type="checkbox"]::before {
|
||||||
|
content: "";
|
||||||
|
width: 0.65em;
|
||||||
|
height: 0.65em;
|
||||||
|
transform: scale(0);
|
||||||
|
transition: 120ms transform ease-in-out;
|
||||||
|
box-shadow: inset 1em 1em;
|
||||||
|
transform-origin: center;
|
||||||
|
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
|
||||||
|
}
|
||||||
|
ul[data-type="taskList"] li > label input[type="checkbox"]:checked::before {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
ul[data-type="taskList"] li[data-checked="true"] > div > p {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
text-decoration: line-through;
|
||||||
|
text-decoration-thickness: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror:not(.dragging) .ProseMirror-selectednode {
|
||||||
|
outline: none !important;
|
||||||
|
background-color: var(--novel-highlight-blue);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
.drag-handle {
|
||||||
|
position: fixed;
|
||||||
|
opacity: 1;
|
||||||
|
transition: opacity ease-in 0.2s;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(0, 0, 0, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E");
|
||||||
|
background-size: calc(0.5em + 0.375rem) calc(0.5em + 0.375rem);
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
width: 1.2rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
z-index: 50;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
.drag-handle:hover {
|
||||||
|
background-color: var(--novel-stone-100);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
.drag-handle:active {
|
||||||
|
background-color: var(--novel-stone-200);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
.drag-handle.hide {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
@media screen and (max-width: 600px) {
|
||||||
|
.drag-handle {
|
||||||
|
display: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dark .drag-handle {
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 10' style='fill: rgba(255, 255, 255, 0.5)'%3E%3Cpath d='M3,2 C2.44771525,2 2,1.55228475 2,1 C2,0.44771525 2.44771525,0 3,0 C3.55228475,0 4,0.44771525 4,1 C4,1.55228475 3.55228475,2 3,2 Z M3,6 C2.44771525,6 2,5.55228475 2,5 C2,4.44771525 2.44771525,4 3,4 C3.55228475,4 4,4.44771525 4,5 C4,5.55228475 3.55228475,6 3,6 Z M3,10 C2.44771525,10 2,9.55228475 2,9 C2,8.44771525 2.44771525,8 3,8 C3.55228475,8 4,8.44771525 4,9 C4,9.55228475 3.55228475,10 3,10 Z M7,2 C6.44771525,2 6,1.55228475 6,1 C6,0.44771525 6.44771525,0 7,0 C7.55228475,0 8,0.44771525 8,1 C8,1.55228475 7.55228475,2 7,2 Z M7,6 C6.44771525,6 6,5.55228475 6,5 C6,4.44771525 6.44771525,4 7,4 C7.55228475,4 8,4.44771525 8,5 C8,5.55228475 7.55228475,6 7,6 Z M7,10 C6.44771525,10 6,9.55228475 6,9 C6,8.44771525 6.44771525,8 7,8 C7.55228475,8 8,8.44771525 8,9 C8,9.55228475 7.55228475,10 7,10 Z'%3E%3C/path%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
43
src/components/global-search-widget.tsx
Normal file
43
src/components/global-search-widget.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
import { cn, debounce } from "@/lib/utils";
|
||||||
|
import React from "react";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
|
||||||
|
function GlobalSearchWidget({
|
||||||
|
className,
|
||||||
|
onDebouncedSearch,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
onDebouncedSearch: (query: string) => void;
|
||||||
|
}) {
|
||||||
|
const [query, setQuery] = React.useState("");
|
||||||
|
|
||||||
|
const debouncedSearch = React.useMemo(
|
||||||
|
() =>
|
||||||
|
debounce((q: string) => {
|
||||||
|
onDebouncedSearch(q);
|
||||||
|
}, 300),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-20 w-full items-center justify-center rounded-md bg-background p-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
className="shadow-none"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.currentTarget.value);
|
||||||
|
debouncedSearch(e.currentTarget.value);
|
||||||
|
}}
|
||||||
|
placeholder="Suche Artikel oder Kategorien..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GlobalSearchWidget;
|
||||||
25
src/components/icons.tsx
Normal file
25
src/components/icons.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { LucideProps } from "lucide-react";
|
||||||
|
|
||||||
|
export const Icons = {
|
||||||
|
logo(props: LucideProps) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width="70"
|
||||||
|
height="40"
|
||||||
|
viewBox="0 0 70 40"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M37.2551 1.61586C38.1803 0.653384 39.4368 0.112671 40.7452 0.112671C46.6318 0.112671 52.1793 0.112674 57.6424 0.112685C68.6302 0.112708 74.1324 13.9329 66.3629 22.0156L49.4389 39.6217C48.662 40.43 47.3335 39.8575 47.3335 38.7144V23.2076L49.2893 21.1729C50.8432 19.5564 49.7427 16.7923 47.5451 16.7923H22.6667L37.2551 1.61586Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M32.7449 38.3842C31.8198 39.3467 30.5633 39.8874 29.2549 39.8874C23.3683 39.8874 17.8208 39.8874 12.3577 39.8874C1.36983 39.8873 -4.13236 26.0672 3.63721 17.9844L20.5612 0.378369C21.3381 -0.429908 22.6666 0.142547 22.6666 1.28562L22.6667 16.7923L20.7108 18.8271C19.1569 20.4437 20.2574 23.2077 22.455 23.2077L47.3335 23.2076L32.7449 38.3842Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
66
src/components/image-upload-form.tsx
Normal file
66
src/components/image-upload-form.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { uploadFile } from "@/server/actions/image";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
import { formatFileSize } from "@/lib/utils";
|
||||||
|
|
||||||
|
export default function ImageUploadButton({
|
||||||
|
onUploaded,
|
||||||
|
}: {
|
||||||
|
onUploaded: (url: string) => void;
|
||||||
|
}) {
|
||||||
|
const [image, setImage] = useState<File | undefined>(undefined);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size > 1024 * 1024 * 2) return toast.error("Datei ist zu groß.");
|
||||||
|
if (file.type.startsWith("image/")) {
|
||||||
|
return toast.error("Nur Bilder können hochgeladen werden.");
|
||||||
|
}
|
||||||
|
setImage(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!image) return;
|
||||||
|
setLoading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", image); // Append file with a field name
|
||||||
|
try {
|
||||||
|
const url = await uploadFile(formData);
|
||||||
|
if (typeof url !== "string") toast.error("Etwas ist fehlgeschlagen");
|
||||||
|
else {
|
||||||
|
onUploaded(url);
|
||||||
|
toast.success("Upload erfolgreich");
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Fehler aufgetreten: " + e);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-lg items-center gap-4">
|
||||||
|
<Button
|
||||||
|
disabled={loading || !image}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleUpload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? "laden..." : "Bild Hochladen"}
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
id="file"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
{image && <p className="w-full">{formatFileSize(image.size)}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -11,7 +11,7 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import CreateArticleDialog from "@/components/article/create-article-dialog";
|
import CreateArticleDialog from "@/components/article/create-article-dialog";
|
||||||
import CreateCategoryDialog from "@/app/(PAGES)/_components/category/create-category-dialog";
|
import CreateCategoryDialog from "@/components/category/create-category-dialog";
|
||||||
import { appRoutes } from "@/config";
|
import { appRoutes } from "@/config";
|
||||||
|
|
||||||
async function Navbar() {
|
async function Navbar() {
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
SidebarMenuSub,
|
SidebarMenuSub,
|
||||||
SidebarMenuSubButton,
|
|
||||||
SidebarMenuSubItem,
|
SidebarMenuSubItem,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@ -26,7 +25,7 @@ import {
|
|||||||
import { appRoutes } from "@/config";
|
import { appRoutes } from "@/config";
|
||||||
import SidebarLink from "./sidebar-link";
|
import SidebarLink from "./sidebar-link";
|
||||||
|
|
||||||
export async function AppSidebar({
|
export async function WikiSidebar({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Sidebar>) {
|
}: React.ComponentProps<typeof Sidebar>) {
|
||||||
const sidebarContent = await api.app.getSidebarContent();
|
const sidebarContent = await api.app.getSidebarContent();
|
||||||
@ -49,7 +48,9 @@ export async function AppSidebar({
|
|||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<SidebarMenuButton>
|
<SidebarMenuButton>
|
||||||
{category.name}{" "}
|
<Link href={appRoutes.category(category.slug)}>
|
||||||
|
<span>{category.name}</span>
|
||||||
|
</Link>
|
||||||
<Plus className="ml-auto group-data-[state=open]/collapsible:hidden" />
|
<Plus className="ml-auto group-data-[state=open]/collapsible:hidden" />
|
||||||
<Minus className="ml-auto group-data-[state=closed]/collapsible:hidden" />
|
<Minus className="ml-auto group-data-[state=closed]/collapsible:hidden" />
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
@ -1,92 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
|
|
||||||
import { useDrag, useDrop } from "react-dnd";
|
|
||||||
|
|
||||||
const DRAG_TYPE = "TIPTAP_BLOCK";
|
|
||||||
|
|
||||||
export const DraggableBlockView = (props) => {
|
|
||||||
const { node, getPos, editor } = props;
|
|
||||||
const [showDragHandle, setShowDragHandle] = useState(false);
|
|
||||||
|
|
||||||
// Get node position and ID
|
|
||||||
const nodePos = getPos();
|
|
||||||
const nodeId = `${node.type.name}-${nodePos}`;
|
|
||||||
|
|
||||||
// Set up drag
|
|
||||||
const [{ isDragging }, drag, dragPreview] = useDrag({
|
|
||||||
type: DRAG_TYPE,
|
|
||||||
item: { id: nodeId, pos: nodePos, type: node.type.name },
|
|
||||||
collect: (monitor) => ({
|
|
||||||
isDragging: monitor.isDragging(),
|
|
||||||
}),
|
|
||||||
begin: () => {
|
|
||||||
// Select the block on drag start
|
|
||||||
editor.commands.setNodeSelection(nodePos);
|
|
||||||
return { id: nodeId, pos: nodePos, type: node.type.name };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set up drop
|
|
||||||
const [{ isOver, canDrop }, drop] = useDrop({
|
|
||||||
accept: DRAG_TYPE,
|
|
||||||
drop: (item) => {
|
|
||||||
if (item.pos !== nodePos) {
|
|
||||||
// Move the dragged node to this position
|
|
||||||
const tr = editor.state.tr;
|
|
||||||
const sourcePos = item.pos;
|
|
||||||
const targetPos = nodePos;
|
|
||||||
|
|
||||||
// Logic to move nodes in the document
|
|
||||||
// This is the complex part that requires careful handling of ProseMirror positions
|
|
||||||
const sourceNode = tr.doc.nodeAt(sourcePos);
|
|
||||||
if (sourceNode) {
|
|
||||||
tr.delete(sourcePos, sourcePos + sourceNode.nodeSize);
|
|
||||||
const newTargetPos =
|
|
||||||
targetPos > sourcePos ? targetPos - sourceNode.nodeSize : targetPos;
|
|
||||||
tr.insert(newTargetPos, sourceNode);
|
|
||||||
editor.view.dispatch(tr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
collect: (monitor) => ({
|
|
||||||
isOver: monitor.isOver(),
|
|
||||||
canDrop: monitor.canDrop(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reference for the drag preview (the whole block)
|
|
||||||
const dragRef = useRef(null);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NodeViewWrapper
|
|
||||||
ref={drop}
|
|
||||||
data-drag-handle={nodeId}
|
|
||||||
style={{
|
|
||||||
position: "relative",
|
|
||||||
opacity: isDragging ? 0.5 : 1,
|
|
||||||
backgroundColor: isOver ? "#f0f9ff" : "transparent",
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setShowDragHandle(true)}
|
|
||||||
onMouseLeave={() => setShowDragHandle(false)}
|
|
||||||
>
|
|
||||||
{showDragHandle && (
|
|
||||||
<div
|
|
||||||
ref={drag}
|
|
||||||
className="drag-handle"
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: "-24px",
|
|
||||||
top: "8px",
|
|
||||||
cursor: "grab",
|
|
||||||
color: "#aaa",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
⋮⋮
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div ref={dragPreview}>
|
|
||||||
<NodeViewContent />
|
|
||||||
</div>
|
|
||||||
</NodeViewWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
import { Color } from "@tiptap/extension-color";
|
|
||||||
import ListItem from "@tiptap/extension-list-item";
|
|
||||||
import TextStyle from "@tiptap/extension-text-style";
|
|
||||||
import StarterKit from "@tiptap/starter-kit";
|
|
||||||
import Image from "@tiptap/extension-image";
|
|
||||||
import GlobalDragHandle from "tiptap-extension-global-drag-handle";
|
|
||||||
import { Extension } from "@tiptap/core";
|
|
||||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
|
||||||
import { DraggableBlockView } from "./block-drag";
|
|
||||||
|
|
||||||
const DraggableBlocks = Extension.create({
|
|
||||||
name: "draggableBlocks",
|
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
|
|
||||||
// Apply this nodeview to all block nodes
|
|
||||||
addNodeView() {
|
|
||||||
return ReactNodeViewRenderer(DraggableBlockView);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const extensions = [
|
|
||||||
Color.configure({ types: [TextStyle.name, ListItem.name] }),
|
|
||||||
/* @ts-ignore */
|
|
||||||
TextStyle.configure({ types: [ListItem.name] }),
|
|
||||||
StarterKit.configure({
|
|
||||||
code: false,
|
|
||||||
codeBlock: false,
|
|
||||||
heading: {
|
|
||||||
levels: [1, 2, 3, 4, 5, 6],
|
|
||||||
},
|
|
||||||
bulletList: {
|
|
||||||
keepMarks: true,
|
|
||||||
keepAttributes: false, // TODO : Making this as `false` becase marks are not preserved when I try to preserve attrs, awaiting a bit of help
|
|
||||||
},
|
|
||||||
orderedList: {
|
|
||||||
keepMarks: true,
|
|
||||||
keepAttributes: false, // TODO : Making this as `false` becase marks are not preserved when I try to preserve attrs, awaiting a bit of help
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
GlobalDragHandle,
|
|
||||||
Image,
|
|
||||||
DraggableBlocks.configure({
|
|
||||||
types: [
|
|
||||||
"paragraph",
|
|
||||||
"heading",
|
|
||||||
"blockquote",
|
|
||||||
"codeBlock",
|
|
||||||
"bulletList",
|
|
||||||
"orderedList",
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
];
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import "./styles.css";
|
|
||||||
import React from "react";
|
|
||||||
import { EditorProvider, EditorProviderProps } from "@tiptap/react";
|
|
||||||
import { MenuBar } from "./menu-bar";
|
|
||||||
import { extensions } from "./extentions";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
function Editor({
|
|
||||||
editorProviderProps,
|
|
||||||
readOnly,
|
|
||||||
}: {
|
|
||||||
editorProviderProps: EditorProviderProps;
|
|
||||||
readOnly?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"rounded-md p-2",
|
|
||||||
!readOnly && "bg-gradient-to-b from-muted to-background",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<EditorProvider
|
|
||||||
immediatelyRender={false}
|
|
||||||
extensions={extensions}
|
|
||||||
slotBefore={!readOnly && <MenuBar />}
|
|
||||||
{...editorProviderProps}
|
|
||||||
editable={!readOnly}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{JSON.stringify(editorProviderProps.content)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
export default Editor;
|
|
||||||
@ -1,212 +0,0 @@
|
|||||||
import ColorPickerPopover from "@/components/color-picker/color-picker-popover";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { RGBAToHexA } from "@/lib/utils";
|
|
||||||
import { useCurrentEditor } from "@tiptap/react";
|
|
||||||
import {
|
|
||||||
BoldIcon,
|
|
||||||
CodeIcon,
|
|
||||||
CodeSquareIcon,
|
|
||||||
Heading2Icon,
|
|
||||||
Heading3Icon,
|
|
||||||
Heading4Icon,
|
|
||||||
Heading5Icon,
|
|
||||||
Heading6Icon,
|
|
||||||
ItalicIcon,
|
|
||||||
ListIcon,
|
|
||||||
ListOrderedIcon,
|
|
||||||
QuoteIcon,
|
|
||||||
RedoIcon,
|
|
||||||
SeparatorHorizontalIcon,
|
|
||||||
StrikethroughIcon,
|
|
||||||
TextIcon,
|
|
||||||
TypeIcon,
|
|
||||||
UndoIcon,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
export const MenuBar = () => {
|
|
||||||
const { editor } = useCurrentEditor();
|
|
||||||
|
|
||||||
if (!editor) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="menu-bar mb-4 flex items-center justify-between space-y-2">
|
|
||||||
{/* <div className="flex w-full items-center justify-between">
|
|
||||||
|
|
||||||
</div> */}
|
|
||||||
<div className="Button-group flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
size={"icon"}
|
|
||||||
variant={"outline"}
|
|
||||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
|
||||||
disabled={!editor.can().chain().focus().toggleBold().run()}
|
|
||||||
className={editor.isActive("bold") ? "is-active" : ""}
|
|
||||||
>
|
|
||||||
<BoldIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size={"icon"}
|
|
||||||
variant={"outline"}
|
|
||||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
|
||||||
disabled={!editor.can().chain().focus().toggleItalic().run()}
|
|
||||||
className={
|
|
||||||
editor.isActive("italic") ? "bg-foreground text-background" : ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ItalicIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size={"icon"}
|
|
||||||
variant={"outline"}
|
|
||||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
|
||||||
disabled={!editor.can().chain().focus().toggleStrike().run()}
|
|
||||||
className={editor.isActive("strike") ? "is-active" : ""}
|
|
||||||
>
|
|
||||||
<StrikethroughIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
{/* <Button
|
|
||||||
size={"icon"}
|
|
||||||
variant={"outline"}
|
|
||||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
|
||||||
disabled={!editor.can().chain().focus().toggleCode().run()}
|
|
||||||
className={editor.isActive("code") ? "is-active" : ""}
|
|
||||||
>
|
|
||||||
<CodeIcon className="size-4" />
|
|
||||||
</Button> */}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={"outline"}
|
|
||||||
size={"icon"}
|
|
||||||
onClick={() => editor.chain().focus().setParagraph().run()}
|
|
||||||
className={editor.isActive("paragraph") ? "is-active" : ""}
|
|
||||||
>
|
|
||||||
<TypeIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={"outline"}
|
|
||||||
size={"icon"}
|
|
||||||
onClick={() =>
|
|
||||||
editor.chain().focus().toggleHeading({ level: 2 }).run()
|
|
||||||
}
|
|
||||||
className={
|
|
||||||
editor.isActive("heading", { level: 2 }) ? "is-active" : ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Heading2Icon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={"outline"}
|
|
||||||
size={"icon"}
|
|
||||||
onClick={() =>
|
|
||||||
editor.chain().focus().toggleHeading({ level: 3 }).run()
|
|
||||||
}
|
|
||||||
className={
|
|
||||||
editor.isActive("heading", { level: 3 }) ? "is-active" : ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Heading3Icon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={"outline"}
|
|
||||||
size={"icon"}
|
|
||||||
onClick={() =>
|
|
||||||
editor.chain().focus().toggleHeading({ level: 4 }).run()
|
|
||||||
}
|
|
||||||
className={
|
|
||||||
editor.isActive("heading", { level: 4 }) ? "is-active" : ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Heading4Icon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={"outline"}
|
|
||||||
size={"icon"}
|
|
||||||
onClick={() =>
|
|
||||||
editor.chain().focus().toggleHeading({ level: 5 }).run()
|
|
||||||
}
|
|
||||||
className={
|
|
||||||
editor.isActive("heading", { level: 5 }) ? "is-active" : ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Heading5Icon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={"outline"}
|
|
||||||
size={"icon"}
|
|
||||||
onClick={() =>
|
|
||||||
editor.chain().focus().toggleHeading({ level: 6 }).run()
|
|
||||||
}
|
|
||||||
className={
|
|
||||||
editor.isActive("heading", { level: 6 }) ? "is-active" : ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Heading6Icon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={"outline"}
|
|
||||||
size={"icon"}
|
|
||||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
|
||||||
className={editor.isActive("bulletList") ? "is-active" : ""}
|
|
||||||
>
|
|
||||||
<ListIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={"outline"}
|
|
||||||
size={"icon"}
|
|
||||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
|
||||||
className={editor.isActive("orderedList") ? "is-active" : ""}
|
|
||||||
>
|
|
||||||
<ListOrderedIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
{/* <Button
|
|
||||||
variant={"outline"}
|
|
||||||
size={"icon"}
|
|
||||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
|
||||||
className={editor.isActive("codeBlock") ? "is-active" : ""}
|
|
||||||
>
|
|
||||||
<CodeSquareIcon className="size-4" />
|
|
||||||
</Button> */}
|
|
||||||
<Button
|
|
||||||
variant={"outline"}
|
|
||||||
size={"icon"}
|
|
||||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
|
||||||
className={editor.isActive("blockquote") ? "is-active" : ""}
|
|
||||||
>
|
|
||||||
<QuoteIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={"outline"}
|
|
||||||
size={"icon"}
|
|
||||||
onClick={() => editor.chain().focus().setHorizontalRule().run()}
|
|
||||||
>
|
|
||||||
<SeparatorHorizontalIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<ColorPickerPopover
|
|
||||||
// initialColor={editor.getAttributes("textStyle").color}
|
|
||||||
onInput={(color) => editor.chain().focus().setColor(color).run()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button
|
|
||||||
variant={"outline"}
|
|
||||||
size={"icon"}
|
|
||||||
onClick={() => editor.chain().focus().undo().run()}
|
|
||||||
disabled={!editor.can().chain().focus().undo().run()}
|
|
||||||
>
|
|
||||||
<UndoIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={"outline"}
|
|
||||||
size={"icon"}
|
|
||||||
onClick={() => editor.chain().focus().redo().run()}
|
|
||||||
disabled={!editor.can().chain().focus().redo().run()}
|
|
||||||
>
|
|
||||||
<RedoIcon className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
|
|
||||||
import { useDraggable } from "@dnd-kit/core";
|
|
||||||
|
|
||||||
const DraggableBlock = ({ node }: any) => {
|
|
||||||
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
|
||||||
id: node.attrs.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NodeViewWrapper
|
|
||||||
ref={setNodeRef}
|
|
||||||
style={{
|
|
||||||
transform: transform
|
|
||||||
? `translate(${transform.x}px, ${transform.y}px)`
|
|
||||||
: "none",
|
|
||||||
}}
|
|
||||||
className="relative cursor-grab border bg-white p-2 shadow-md"
|
|
||||||
>
|
|
||||||
{/* Drag Handle */}
|
|
||||||
<div
|
|
||||||
{...listeners}
|
|
||||||
{...attributes}
|
|
||||||
className="absolute left-0 top-0 cursor-grab bg-gray-300 p-1"
|
|
||||||
>
|
|
||||||
⠿
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<NodeViewContent as="div" />
|
|
||||||
</NodeViewWrapper>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DraggableBlock;
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
/* Basic editor styles */
|
|
||||||
.tiptap > *:first-child {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
.ProseMirror:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* List styles */
|
|
||||||
/* .tiptap ul,
|
|
||||||
.tiptap ol {
|
|
||||||
padding: 0 1rem;
|
|
||||||
margin: 1.25rem 1rem 1.25rem 0.4rem;
|
|
||||||
} */
|
|
||||||
|
|
||||||
.tiptap ul li p,
|
|
||||||
.tiptap ol li p {
|
|
||||||
@apply my-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tiptap ul,
|
|
||||||
ol {
|
|
||||||
@apply ml-4 p-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tiptap ul {
|
|
||||||
@apply list-disc;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tiptap ol {
|
|
||||||
@apply list-decimal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Heading styles */
|
|
||||||
.tiptap h1,
|
|
||||||
.tiptap h2,
|
|
||||||
.tiptap h3,
|
|
||||||
.tiptap h4,
|
|
||||||
.tiptap h5,
|
|
||||||
.tiptap h6 {
|
|
||||||
@apply my-2;
|
|
||||||
line-height: 1.1;
|
|
||||||
text-wrap: pretty;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tiptap h1,
|
|
||||||
.tiptap h2 {
|
|
||||||
@apply my-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tiptap h2 {
|
|
||||||
@apply text-4xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tiptap h3 {
|
|
||||||
@apply text-3xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tiptap h4 {
|
|
||||||
@apply text-2xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tiptap h5 {
|
|
||||||
@apply text-xl;
|
|
||||||
}
|
|
||||||
.tiptap h6 {
|
|
||||||
@apply text-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Code and preformatted text styles */
|
|
||||||
/* .tiptap code {
|
|
||||||
@apply bg-foreground p-1 text-background;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tiptap pre {
|
|
||||||
background: var(--black);
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
color: var(--white);
|
|
||||||
font-family: "JetBrainsMono", monospace;
|
|
||||||
margin: 1.5rem 0;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tiptap pre code {
|
|
||||||
background: none;
|
|
||||||
color: inherit;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 0;
|
|
||||||
} */
|
|
||||||
|
|
||||||
/* Blockquote styles */
|
|
||||||
.tiptap blockquote {
|
|
||||||
@apply m-6 border-l-2 pl-4;
|
|
||||||
/* border-left: 3px solid var(--gray-3);
|
|
||||||
margin: 1.5rem 0;
|
|
||||||
padding-left: 1rem; */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Horizontal rule styles */
|
|
||||||
.tiptap hr {
|
|
||||||
@apply my-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
::selection {
|
|
||||||
@apply rounded-md bg-muted-foreground/25;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-bar button {
|
|
||||||
@apply duration-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.menu-bar .is-active {
|
|
||||||
@apply border-primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag-handle {
|
|
||||||
@apply bg-red-500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-drag-handle {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #aaa;
|
|
||||||
background: white;
|
|
||||||
border-radius: 4px;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
.block-drag-handle:hover {
|
|
||||||
color: #333;
|
|
||||||
background: #f1f1f1;
|
|
||||||
}
|
|
||||||
7
src/components/ui/aspect-ratio.tsx
Normal file
7
src/components/ui/aspect-ratio.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||||
|
|
||||||
|
const AspectRatio = AspectRatioPrimitive.Root
|
||||||
|
|
||||||
|
export { AspectRatio }
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Card = React.forwardRef<
|
const Card = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@ -9,13 +9,13 @@ const Card = React.forwardRef<
|
|||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded-xl border bg-card text-card-foreground shadow",
|
"rounded-lg border bg-card text-card-foreground shadow-none",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
Card.displayName = "Card"
|
Card.displayName = "Card";
|
||||||
|
|
||||||
const CardHeader = React.forwardRef<
|
const CardHeader = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@ -26,8 +26,8 @@ const CardHeader = React.forwardRef<
|
|||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CardHeader.displayName = "CardHeader"
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
const CardTitle = React.forwardRef<
|
const CardTitle = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@ -38,8 +38,8 @@ const CardTitle = React.forwardRef<
|
|||||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CardTitle.displayName = "CardTitle"
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
const CardDescription = React.forwardRef<
|
const CardDescription = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@ -50,16 +50,16 @@ const CardDescription = React.forwardRef<
|
|||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CardDescription.displayName = "CardDescription"
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
const CardContent = React.forwardRef<
|
const CardContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.HTMLAttributes<HTMLDivElement>
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
))
|
));
|
||||||
CardContent.displayName = "CardContent"
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
const CardFooter = React.forwardRef<
|
const CardFooter = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
@ -70,7 +70,14 @@ const CardFooter = React.forwardRef<
|
|||||||
className={cn("flex items-center p-6 pt-0", className)}
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
))
|
));
|
||||||
CardFooter.displayName = "CardFooter"
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
};
|
||||||
|
|||||||
22
src/components/ui/textarea.tsx
Normal file
22
src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
React.ComponentProps<"textarea">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
44
src/lib/hooks/infinite-items-observer-hook.tsx
Normal file
44
src/lib/hooks/infinite-items-observer-hook.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export function useInfiniteItemsObserver({
|
||||||
|
bottomObserverRef,
|
||||||
|
fetchNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
isFetchingNextPage,
|
||||||
|
}: {
|
||||||
|
bottomObserverRef: React.RefObject<HTMLLIElement>;
|
||||||
|
fetchNextPage: () => void;
|
||||||
|
hasNextPage: boolean;
|
||||||
|
isFetchingNextPage: boolean;
|
||||||
|
}) {
|
||||||
|
const bottomObserver = React.useCallback(
|
||||||
|
(entries: IntersectionObserverEntry[]) => {
|
||||||
|
const [entry] = entries;
|
||||||
|
if (entry?.isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[fetchNextPage, hasNextPage, isFetchingNextPage],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Setup bottom observer
|
||||||
|
React.useEffect(() => {
|
||||||
|
const bottomObserverElement = bottomObserverRef.current;
|
||||||
|
|
||||||
|
// Bottom observer
|
||||||
|
const observerBottom = new IntersectionObserver(bottomObserver, {
|
||||||
|
root: null,
|
||||||
|
rootMargin: "100px",
|
||||||
|
threshold: 0.1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bottomObserverElement) observerBottom.observe(bottomObserverElement);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (bottomObserverElement)
|
||||||
|
observerBottom.unobserve(bottomObserverElement);
|
||||||
|
};
|
||||||
|
}, [bottomObserver, bottomObserverRef]);
|
||||||
|
}
|
||||||
@ -26,3 +26,9 @@ export function debounce<T extends (...args: any[]) => void>(
|
|||||||
timeoutId = setTimeout(() => func(...args), delay);
|
timeoutId = setTimeout(() => func(...args), delay);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const formatFileSize = (size: number) => {
|
||||||
|
if (size < 1024) return `${size} B`;
|
||||||
|
if (size < 1024 * 1024) return `${(size / 1024).toFixed(2)} KB`;
|
||||||
|
return `${(size / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
};
|
||||||
|
|||||||
@ -2,4 +2,6 @@ import { z } from "zod";
|
|||||||
|
|
||||||
export const categorySchema = z.object({
|
export const categorySchema = z.object({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
|
description: z.string().optional(),
|
||||||
|
image: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
createArticleSchema,
|
createArticleSchema,
|
||||||
} from "@/lib/validation/zod/article";
|
} from "@/lib/validation/zod/article";
|
||||||
import { api } from "@/trpc/server";
|
import { api } from "@/trpc/server";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@ -16,16 +17,15 @@ export async function createArticle(
|
|||||||
article,
|
article,
|
||||||
});
|
});
|
||||||
if (!result[0]?.slug?.length) return false;
|
if (!result[0]?.slug?.length) return false;
|
||||||
|
revalidatePath("/");
|
||||||
return redirect(appRoutes.editArticle(result[0].slug));
|
return redirect(appRoutes.editArticle(result[0].slug));
|
||||||
}
|
}
|
||||||
export async function updateArticle(
|
export async function updateArticle(
|
||||||
article: z.infer<typeof articleSchema>,
|
article: z.infer<typeof articleSchema>,
|
||||||
articleId: string,
|
articleId: string,
|
||||||
) {
|
) {
|
||||||
console.log("Content :: action", JSON.stringify(article.content));
|
|
||||||
|
|
||||||
const result = await api.article.update({
|
const result = await api.article.update({
|
||||||
article,
|
article: { ...article, content: JSON.parse(article.content) },
|
||||||
articleId,
|
articleId,
|
||||||
});
|
});
|
||||||
// if (!result[0]?.id?.length) return false;
|
// if (!result[0]?.id?.length) return false;
|
||||||
|
|||||||
@ -16,12 +16,19 @@ export async function updateCategory(
|
|||||||
category: z.infer<typeof categorySchema>,
|
category: z.infer<typeof categorySchema>,
|
||||||
categoryId: string,
|
categoryId: string,
|
||||||
) {
|
) {
|
||||||
const result = await api.category.update({
|
try {
|
||||||
category,
|
const result = await api.category.update({
|
||||||
categoryId,
|
category,
|
||||||
});
|
categoryId,
|
||||||
// if (!result[0]?.id?.length) return false;
|
});
|
||||||
// return revalidatePath(`/artikel/${result[0].id}/edit`);
|
|
||||||
|
if (!result[0]?.id?.length) return false;
|
||||||
|
revalidatePath(`/artikel/${result[0].id}/edit`);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export async function deleteCategory(categoryId: string) {
|
export async function deleteCategory(categoryId: string) {
|
||||||
const result = await api.category.delete({
|
const result = await api.category.delete({
|
||||||
|
|||||||
17
src/server/actions/image.ts
Normal file
17
src/server/actions/image.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
"use server";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
|
||||||
|
export async function uploadFile(formData: FormData) {
|
||||||
|
const file = formData.get("file") as File;
|
||||||
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
|
const buffer = new Uint8Array(arrayBuffer);
|
||||||
|
const filename = `upload-${Date.now()}-${file.name}.png`;
|
||||||
|
try {
|
||||||
|
await fs.writeFile(`./public/uploads/${filename}`, buffer);
|
||||||
|
revalidatePath("/");
|
||||||
|
return `/uploads/${filename}`;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,29 @@
|
|||||||
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||||
|
import { desc, ilike, like } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
articles as articlesTable,
|
||||||
|
categories as categoriesTable,
|
||||||
|
lower,
|
||||||
|
} from "@/server/db/schema";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
|
searchContent: publicProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
query: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const articles = await ctx.db.query.articles.findMany({
|
||||||
|
where: ilike(articlesTable.title, "%" + input.query + "%"),
|
||||||
|
});
|
||||||
|
const categories = await ctx.db.query.categories.findMany({
|
||||||
|
where: like(categoriesTable.name, "%" + input.query + "%"),
|
||||||
|
});
|
||||||
|
return { articles, categories };
|
||||||
|
}),
|
||||||
|
|
||||||
getSidebarContent: publicProcedure.query(async ({ ctx }) => {
|
getSidebarContent: publicProcedure.query(async ({ ctx }) => {
|
||||||
return await ctx.db.query.categories.findMany({
|
return await ctx.db.query.categories.findMany({
|
||||||
with: {
|
with: {
|
||||||
|
|||||||
@ -5,17 +5,25 @@ import {
|
|||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
} from "@/server/api/trpc";
|
} from "@/server/api/trpc";
|
||||||
import { articles } from "@/server/db/schema";
|
import { Article, articles } from "@/server/db/schema";
|
||||||
import {
|
import {
|
||||||
articleSchema,
|
articleSchema,
|
||||||
createArticleSchema,
|
createArticleSchema,
|
||||||
} from "@/lib/validation/zod/article";
|
} from "@/lib/validation/zod/article";
|
||||||
import { count, eq } from "drizzle-orm";
|
import { and, count, eq, gt, like, sql } from "drizzle-orm";
|
||||||
import { hasPermission, Role } from "@/lib/validation/permissions";
|
import { hasPermission, Role } from "@/lib/validation/permissions";
|
||||||
import { generateSlug } from "@/lib/utils";
|
import { generateSlug } from "@/lib/utils";
|
||||||
|
|
||||||
export const articleRouter = createTRPCRouter({
|
export const articleRouter = createTRPCRouter({
|
||||||
// queries
|
// queries
|
||||||
|
search: publicProcedure
|
||||||
|
.input(z.object({ query: z.string() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return (await ctx.db.query.articles.findMany({
|
||||||
|
where: like(articles.title, "%" + input.query + "%"),
|
||||||
|
with: { category: true },
|
||||||
|
})) as Article[];
|
||||||
|
}),
|
||||||
get: publicProcedure
|
get: publicProcedure
|
||||||
.input(z.object({ slug: z.string() }))
|
.input(z.object({ slug: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
@ -24,23 +32,57 @@ export const articleRouter = createTRPCRouter({
|
|||||||
with: { category: true },
|
with: { category: true },
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
getByPage: publicProcedure
|
||||||
getAll: publicProcedure
|
.input(
|
||||||
.input(z.object({ categoryId: z.string() }).optional())
|
z.object({
|
||||||
|
categoryId: z.string().optional(),
|
||||||
|
limit: z.number().optional(),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return await ctx.db.query.articles.findMany({
|
const { categoryId, cursor } = input!;
|
||||||
where: input?.categoryId
|
const limit = input?.limit ?? 50;
|
||||||
? eq(articles.categoryId, input.categoryId)
|
const cursorArg = cursor ? gt(articles.slug, cursor) : undefined;
|
||||||
: undefined,
|
const categoryArg = categoryId
|
||||||
|
? eq(articles.categoryId, categoryId)
|
||||||
|
: undefined;
|
||||||
|
const items = await ctx.db.query.articles.findMany({
|
||||||
|
where: and(cursorArg, categoryArg),
|
||||||
|
limit: limit + 1,
|
||||||
|
orderBy: articles.slug,
|
||||||
|
columns: {
|
||||||
|
title: true,
|
||||||
|
slug: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
let nextCursor: typeof cursor | undefined = undefined;
|
||||||
|
if (items.length > limit) {
|
||||||
|
const nextItem = items.pop();
|
||||||
|
nextCursor = nextItem!.slug;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
nextCursor,
|
||||||
|
previousCursor: cursor,
|
||||||
|
};
|
||||||
}),
|
}),
|
||||||
getAllPreviews: publicProcedure
|
getAll: publicProcedure
|
||||||
.input(z.object({ categoryId: z.string() }).optional())
|
.input(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
categoryId: z.string().optional(),
|
||||||
|
limit: z.number().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return await ctx.db.query.articles.findMany({
|
return await ctx.db.query.articles.findMany({
|
||||||
where: input?.categoryId
|
where: input?.categoryId
|
||||||
? eq(articles.categoryId, input.categoryId)
|
? eq(articles.categoryId, input.categoryId)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
limit: input?.limit,
|
||||||
columns: {
|
columns: {
|
||||||
title: true,
|
title: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
@ -48,6 +90,7 @@ export const articleRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getCount: publicProcedure
|
getCount: publicProcedure
|
||||||
.input(z.object({ categoryId: z.string() }).optional())
|
.input(z.object({ categoryId: z.string() }).optional())
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
@ -76,6 +119,12 @@ export const articleRouter = createTRPCRouter({
|
|||||||
.values({ ...input.article, slug })
|
.values({ ...input.article, slug })
|
||||||
.returning({
|
.returning({
|
||||||
slug: articles.slug,
|
slug: articles.slug,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: articles.slug,
|
||||||
|
set: {
|
||||||
|
slug: sql`${slug} || '-' || (SELECT COUNT(*) FROM ${articles} WHERE slug LIKE ${slug + "-%"})`,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
update: protectedProcedure
|
update: protectedProcedure
|
||||||
|
|||||||
@ -4,25 +4,43 @@ import {
|
|||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
} from "@/server/api/trpc";
|
} from "@/server/api/trpc";
|
||||||
import { categories } from "@/server/db/schema";
|
import { categories, Category } from "@/server/db/schema";
|
||||||
|
|
||||||
import { count, eq } from "drizzle-orm";
|
import { count, eq, like } from "drizzle-orm";
|
||||||
import { hasPermission, Role } from "@/lib/validation/permissions";
|
import { hasPermission, Role } from "@/lib/validation/permissions";
|
||||||
import { categorySchema } from "@/lib/validation/zod/category";
|
import { categorySchema } from "@/lib/validation/zod/category";
|
||||||
import { generateSlug } from "@/lib/utils";
|
import { generateSlug } from "@/lib/utils";
|
||||||
|
|
||||||
export const categoryRouter = createTRPCRouter({
|
export const categoryRouter = createTRPCRouter({
|
||||||
get: publicProcedure
|
search: publicProcedure
|
||||||
.input(z.object({ slug: z.string() }))
|
.input(z.object({ query: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return await ctx.db.query.categories.findFirst({
|
return await ctx.db.query.categories.findMany({
|
||||||
where: eq(categories.slug, input.slug),
|
where: like(categories.name, "%" + input.query + "%"),
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
get: publicProcedure
|
||||||
|
.input(z.object({ slug: z.string(), with: z.any() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return (await ctx.db.query.categories.findFirst({
|
||||||
|
where: eq(categories.slug, input.slug),
|
||||||
|
with: input?.with,
|
||||||
|
})) as Category;
|
||||||
|
}),
|
||||||
|
|
||||||
getAll: publicProcedure.query(async ({ ctx }) => {
|
getAll: publicProcedure
|
||||||
return await ctx.db.query.categories.findMany();
|
.input(
|
||||||
}),
|
z
|
||||||
|
.object({
|
||||||
|
limit: z.number().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return await ctx.db.query.categories.findMany({
|
||||||
|
limit: input?.limit,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
|
||||||
getCount: publicProcedure.query(async ({ ctx }) => {
|
getCount: publicProcedure.query(async ({ ctx }) => {
|
||||||
return (await ctx.db.select({ count: count() }).from(categories))[0]?.count;
|
return (await ctx.db.select({ count: count() }).from(categories))[0]?.count;
|
||||||
|
|||||||
@ -16,3 +16,5 @@ const conn = globalForDb.conn ?? postgres(env.DATABASE_URL);
|
|||||||
if (env.NODE_ENV !== "production") globalForDb.conn = conn;
|
if (env.NODE_ENV !== "production") globalForDb.conn = conn;
|
||||||
|
|
||||||
export const db = drizzle(conn, { schema });
|
export const db = drizzle(conn, { schema });
|
||||||
|
|
||||||
|
export type DBType = typeof db;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { createId } from "@paralleldrive/cuid2";
|
import { createId } from "@paralleldrive/cuid2";
|
||||||
import { relations, sql } from "drizzle-orm";
|
import { relations, SQL, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
|
AnyPgColumn,
|
||||||
boolean,
|
boolean,
|
||||||
index,
|
index,
|
||||||
integer,
|
integer,
|
||||||
@ -11,15 +12,14 @@ import {
|
|||||||
timestamp,
|
timestamp,
|
||||||
varchar,
|
varchar,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
import { User } from "next-auth";
|
||||||
import { type AdapterAccount } from "next-auth/adapters";
|
import { type AdapterAccount } from "next-auth/adapters";
|
||||||
import { type JSONContent } from "@tiptap/react";
|
import { JSONContent } from "novel";
|
||||||
|
|
||||||
|
export function lower(value: AnyPgColumn): SQL {
|
||||||
|
return sql`lower(${value})`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
|
||||||
* database instance for multiple projects.
|
|
||||||
*
|
|
||||||
* @see https://orm.drizzle.team/docs/goodies#multi-project-schema
|
|
||||||
*/
|
|
||||||
export const createTable = pgTableCreator((name) => `wiki-antifa_${name}`);
|
export const createTable = pgTableCreator((name) => `wiki-antifa_${name}`);
|
||||||
|
|
||||||
export const articles = createTable(
|
export const articles = createTable(
|
||||||
@ -32,7 +32,7 @@ export const articles = createTable(
|
|||||||
title: varchar("title", { length: 256 }),
|
title: varchar("title", { length: 256 }),
|
||||||
slug: varchar("slug", { length: 256 }).unique().notNull(),
|
slug: varchar("slug", { length: 256 }).unique().notNull(),
|
||||||
authorId: varchar("author_id", { length: 255 }),
|
authorId: varchar("author_id", { length: 255 }),
|
||||||
content: text("content"),
|
content: jsonb("content").$type<JSONContent>(),
|
||||||
categoryId: varchar("category_id", { length: 255 }),
|
categoryId: varchar("category_id", { length: 255 }),
|
||||||
published: boolean("published").default(false),
|
published: boolean("published").default(false),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
@ -46,12 +46,19 @@ export const articles = createTable(
|
|||||||
articleTitleIndex: index("article_title_idx").on(example.title),
|
articleTitleIndex: index("article_title_idx").on(example.title),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
export type Article = typeof articles.$inferSelect;
|
export type Article = typeof articles.$inferSelect & {
|
||||||
|
author?: User;
|
||||||
|
category?: Category;
|
||||||
|
};
|
||||||
export const articleRelations = relations(articles, ({ one }) => ({
|
export const articleRelations = relations(articles, ({ one }) => ({
|
||||||
category: one(categories, {
|
category: one(categories, {
|
||||||
fields: [articles.categoryId],
|
fields: [articles.categoryId],
|
||||||
references: [categories.id],
|
references: [categories.id],
|
||||||
}),
|
}),
|
||||||
|
author: one(users, {
|
||||||
|
fields: [articles.authorId],
|
||||||
|
references: [users.id],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const categories = createTable(
|
export const categories = createTable(
|
||||||
@ -62,8 +69,10 @@ export const categories = createTable(
|
|||||||
.$defaultFn(() => createId())
|
.$defaultFn(() => createId())
|
||||||
.notNull(),
|
.notNull(),
|
||||||
name: varchar("name", { length: 256 }),
|
name: varchar("name", { length: 256 }),
|
||||||
|
description: text("description"),
|
||||||
slug: varchar("slug", { length: 256 }).unique().notNull(),
|
slug: varchar("slug", { length: 256 }).unique().notNull(),
|
||||||
|
|
||||||
|
image: varchar("image", { length: 255 }),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.default(sql`CURRENT_TIMESTAMP`)
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
.notNull(),
|
.notNull(),
|
||||||
@ -75,7 +84,9 @@ export const categories = createTable(
|
|||||||
categoryNameIndex: index("category_name_idx").on(example.name),
|
categoryNameIndex: index("category_name_idx").on(example.name),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
export type Category = typeof categories.$inferSelect;
|
export type Category = typeof categories.$inferSelect & {
|
||||||
|
articles?: Article[];
|
||||||
|
};
|
||||||
|
|
||||||
export const categoryRelations = relations(categories, ({ many }) => ({
|
export const categoryRelations = relations(categories, ({ many }) => ({
|
||||||
articles: many(articles),
|
articles: many(articles),
|
||||||
|
|||||||
@ -57,7 +57,7 @@ export function TRPCReactProvider(props: { children: React.ReactNode }) {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
# On Linux and macOS you can run this script directly - `./start-database.sh`
|
# On Linux and macOS you can run this script directly - `./start-database.sh`
|
||||||
|
|
||||||
DB_CONTAINER_NAME="wiki-antifa-postgres"
|
DB_CONTAINER_NAME="logipedia-postgres"
|
||||||
|
|
||||||
if ! [ -x "$(command -v docker)" ]; then
|
if ! [ -x "$(command -v docker)" ]; then
|
||||||
echo -e "Docker is not installed. Please install docker and try again.\nDocker install guide: https://docs.docker.com/engine/install/"
|
echo -e "Docker is not installed. Please install docker and try again.\nDocker install guide: https://docs.docker.com/engine/install/"
|
||||||
@ -55,6 +55,6 @@ docker run -d \
|
|||||||
--name $DB_CONTAINER_NAME \
|
--name $DB_CONTAINER_NAME \
|
||||||
-e POSTGRES_USER="postgres" \
|
-e POSTGRES_USER="postgres" \
|
||||||
-e POSTGRES_PASSWORD="$DB_PASSWORD" \
|
-e POSTGRES_PASSWORD="$DB_PASSWORD" \
|
||||||
-e POSTGRES_DB=wiki-antifa \
|
-e POSTGRES_DB=logipedia \
|
||||||
-p "$DB_PORT":5432 \
|
-p "$DB_PORT":5432 \
|
||||||
docker.io/postgres && echo "Database container '$DB_CONTAINER_NAME' was successfully created"
|
docker.io/postgres && echo "Database container '$DB_CONTAINER_NAME' was successfully created"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user