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} */
|
||||
const config = {
|
||||
experimental: {
|
||||
reactCompiler: true,
|
||||
serverActions: {
|
||||
bodySizeLimit: "2mb",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
19
package.json
19
package.json
@ -21,13 +21,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@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",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@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-checkbox": "^1.1.4",
|
||||
"@radix-ui/react-collapsible": "^1.1.3",
|
||||
@ -43,17 +40,13 @@
|
||||
"@tanstack/react-query": "^5.50.0",
|
||||
"@tanstack/react-table": "^8.21.2",
|
||||
"@tiptap/core": "^2.11.5",
|
||||
"@tiptap/extension-color": "^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/extension-heading": "^2.11.5",
|
||||
"@tiptap/react": "^2.11.5",
|
||||
"@tiptap/starter-kit": "^2.11.5",
|
||||
"@trpc/client": "^11.0.0-rc.446",
|
||||
"@trpc/react-query": "^11.0.0-rc.446",
|
||||
"@trpc/server": "^11.0.0-rc.446",
|
||||
"babel-plugin-react-compiler": "19.0.0-beta-40c6c23-20250301",
|
||||
"cheerio": "^1.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.0",
|
||||
@ -63,11 +56,9 @@
|
||||
"next": "^15.0.1",
|
||||
"next-auth": "5.0.0-beta.25",
|
||||
"next-themes": "^0.4.4",
|
||||
"novel": "^1.0.2",
|
||||
"postgres": "^3.4.4",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"react": "^18.3.1",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-textarea-autosize": "^8.5.7",
|
||||
@ -76,7 +67,7 @@
|
||||
"superjson": "^2.2.1",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tiptap-extension-global-drag-handle": "^0.1.18",
|
||||
"use-debounce": "^10.0.4",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"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",
|
||||
"email": "salford2a@hubpages.com",
|
||||
"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 { db } from "../src/server/db";
|
||||
import { users } from "../src/server/db/schema";
|
||||
import fakeUsers from "./fake-users.json";
|
||||
import { db, DBType } from "../src/server/db";
|
||||
import { articles, categories, users } from "../src/server/db/schema";
|
||||
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() {
|
||||
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()
|
||||
|
||||
@ -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() {
|
||||
return (
|
||||
<div>Alle Artikel Page</div>
|
||||
)
|
||||
return <InfiniteArticlesGrid />;
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
function Page() {
|
||||
return <div>Alle Kategorien</div>;
|
||||
async function Page() {
|
||||
const categories = await api.category.getAll();
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-2xl font-bold">Kategorien</h1>
|
||||
<CategoriesGrid categories={categories} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 { SidebarProvider } from "@/components/ui/sidebar";
|
||||
import React from "react";
|
||||
@ -6,7 +6,7 @@ import React from "react";
|
||||
function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<WikiSidebar />
|
||||
<div className="w-full">
|
||||
<Navbar />
|
||||
<main className="space-y-4 p-4">{children}</main>
|
||||
|
||||
@ -1,43 +1,13 @@
|
||||
import { auth } from "@/server/auth";
|
||||
import { api } from "@/trpc/server";
|
||||
import Link from "next/link";
|
||||
import GlobalStats from "./_components/global-stats";
|
||||
import ArticleCard from "../../components/article/article-card";
|
||||
import CategoryCard from "./_components/category/category-card";
|
||||
import MainPage from "./_components/main-page";
|
||||
import { Article } from "@/server/db/schema";
|
||||
|
||||
export default async function Home() {
|
||||
const session = await auth();
|
||||
const articles = await api.article.getAllPreviews();
|
||||
const categories = await api.category.getAll();
|
||||
const articles = await api.article.getAll({ limit: 12 });
|
||||
const categories = await api.category.getAll({ limit: 6 });
|
||||
return (
|
||||
<>
|
||||
<div className="w-full space-y-1">
|
||||
<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>
|
||||
<MainPage initialData={{ articles: articles as Article[], categories }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 Link from "next/link";
|
||||
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({
|
||||
title,
|
||||
slug,
|
||||
author,
|
||||
createdAt,
|
||||
}: Pick<Article, "title" | "slug" | "createdAt">) {
|
||||
}: Pick<Article, "title" | "slug" | "createdAt" | "author">) {
|
||||
const authorName = author?.name ?? `${appConfig.name} Team`;
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Article, Category } from "@/server/db/schema";
|
||||
import { Article } from "@/server/db/schema";
|
||||
|
||||
import React from "react";
|
||||
|
||||
@ -19,13 +19,13 @@ import {
|
||||
import { articleSchema } from "@/lib/validation/zod/article";
|
||||
import { cn, debounce } from "@/lib/utils";
|
||||
import { updateArticle } from "@/server/actions/article";
|
||||
import Editor from "../text-editor";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import Link from "next/link";
|
||||
import CategorySelect from "@/components/category/category-select";
|
||||
import { CheckCircle, XCircle } from "lucide-react";
|
||||
import PublishArticleAlertDialog from "./publish-article-alert-dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import Editor from "../editor";
|
||||
|
||||
export default ({ server_article }: { server_article: Article }) => {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
@ -46,7 +46,10 @@ export default ({ server_article }: { server_article: Article }) => {
|
||||
console.log("Content before save", values.content);
|
||||
|
||||
setLoading(true);
|
||||
await updateArticle(values, server_article.id);
|
||||
await updateArticle(
|
||||
{ ...values, content: JSON.stringify(values.content) },
|
||||
server_article.id,
|
||||
);
|
||||
setLoading(false);
|
||||
form.reset(values);
|
||||
}
|
||||
@ -58,10 +61,14 @@ export default ({ server_article }: { server_article: Article }) => {
|
||||
[form],
|
||||
);
|
||||
const published = form.watch("published");
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<form
|
||||
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
|
||||
control={form.control}
|
||||
name="title"
|
||||
@ -70,7 +77,7 @@ export default ({ server_article }: { server_article: Article }) => {
|
||||
<FormControl>
|
||||
<TextareaAutosize
|
||||
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}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
@ -82,8 +89,6 @@ export default ({ server_article }: { server_article: Article }) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="content"
|
||||
@ -91,19 +96,10 @@ export default ({ server_article }: { server_article: Article }) => {
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<Editor
|
||||
// content={field.value}
|
||||
editorProviderProps={{
|
||||
content: field.value,
|
||||
editorProps: { attributes: { class: "min-h-64" } },
|
||||
onUpdate: (value) => {
|
||||
const newContent = value.editor.getHTML();
|
||||
console.log(
|
||||
"Content :: form",
|
||||
JSON.stringify(newContent),
|
||||
);
|
||||
field.onChange(newContent);
|
||||
debouncedSubmit();
|
||||
},
|
||||
initialContent={field.value}
|
||||
onContentChange={(content) => {
|
||||
field.onChange(content);
|
||||
debouncedSubmit();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
@ -112,99 +108,97 @@ export default ({ server_article }: { server_article: Article }) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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"
|
||||
>
|
||||
{!form.formState.isDirty && !loading ? (
|
||||
<>
|
||||
<CheckCircle className="size-4 text-emerald-600" />
|
||||
<span>Gespeichert</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="size-4 text-destructive" />
|
||||
<span>Nicht Gespeichert</span>
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs",
|
||||
published ? "text-emerald-700" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{published
|
||||
? "Veröffentlicht"
|
||||
: "Draft (nicht veröffentlicht)"}
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href={"/editoren-hilfe"}
|
||||
target="_blank"
|
||||
className="size-max scale-90 p-0 text-xs text-muted-foreground"
|
||||
<div
|
||||
className={cn(
|
||||
"top-4 h-max w-full max-w-xl xl:sticky xl: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 className="flex items-center gap-2">
|
||||
<Badge variant={"outline"} className="flex items-center gap-1">
|
||||
{!form.formState.isDirty && !loading ? (
|
||||
<>
|
||||
<CheckCircle className="size-4 text-emerald-600" />
|
||||
<span>Gespeichert</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="size-4 text-destructive" />
|
||||
<span>Nicht Gespeichert</span>
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs",
|
||||
published ? "text-emerald-700" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span>? Hilfe</span>
|
||||
</Link>
|
||||
{published
|
||||
? "Veröffentlicht"
|
||||
: "Draft (nicht veröffentlicht)"}
|
||||
</span>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="published"
|
||||
render={({ field }) => (
|
||||
<FormItem className="rounded-md border bg-background px-4 py-2">
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Veröffentlicht </Label>
|
||||
<PublishArticleAlertDialog
|
||||
published={field.value}
|
||||
setPublished={(value) => {
|
||||
field.onChange(value);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="categoryId"
|
||||
render={({ field }) => (
|
||||
<FormItem className="w-full">
|
||||
<FormControl>
|
||||
<CategorySelect
|
||||
initialValue={field.value}
|
||||
onSelect={(categoryId) => {
|
||||
field.onChange(categoryId);
|
||||
<Link
|
||||
href={"/editoren-hilfe"}
|
||||
target="_blank"
|
||||
className="size-max scale-90 p-0 text-xs text-muted-foreground"
|
||||
>
|
||||
<span>? Hilfe</span>
|
||||
</Link>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="published"
|
||||
render={({ field }) => (
|
||||
<FormItem className="rounded-md border bg-background px-4 py-2">
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label>Veröffentlicht </Label>
|
||||
<PublishArticleAlertDialog
|
||||
published={field.value}
|
||||
setPublished={(value) => {
|
||||
field.onChange(value);
|
||||
form.handleSubmit(onSubmit)();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<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>
|
||||
</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 Editor from "../text-editor";
|
||||
|
||||
function RenderArticle({ content }: { content: string }) {
|
||||
return <Editor readOnly editorProviderProps={{ content: content }} />;
|
||||
return "render article: in work"; //<Editor readOnly editorProviderProps={{ content: content }} />;
|
||||
}
|
||||
|
||||
export default RenderArticle;
|
||||
|
||||
@ -10,13 +10,15 @@ import {
|
||||
|
||||
function BreadNavigator({
|
||||
links,
|
||||
className,
|
||||
}: {
|
||||
links: { label: string; href: string }[];
|
||||
className?: string;
|
||||
}) {
|
||||
const labelClass =
|
||||
"w-full max-w-52 overflow-hidden text-ellipsis whitespace-nowrap capitalize block";
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<Breadcrumb className={className}>
|
||||
<BreadcrumbList>
|
||||
{links.map(({ label, href }, idx) => {
|
||||
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,
|
||||
data,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
"use no memo";
|
||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||
pageSize: 25,
|
||||
pageIndex: 0,
|
||||
@ -45,6 +46,7 @@ export function DataTable<TData, TValue>({
|
||||
[],
|
||||
);
|
||||
const [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>({});
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
@ -65,6 +67,7 @@ export function DataTable<TData, TValue>({
|
||||
});
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{JSON.stringify(sorting)}
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
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,
|
||||
} from "@/components/ui/popover";
|
||||
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";
|
||||
|
||||
async function Navbar() {
|
||||
|
||||
@ -10,7 +10,6 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import Link from "next/link";
|
||||
@ -26,7 +25,7 @@ import {
|
||||
import { appRoutes } from "@/config";
|
||||
import SidebarLink from "./sidebar-link";
|
||||
|
||||
export async function AppSidebar({
|
||||
export async function WikiSidebar({
|
||||
...props
|
||||
}: React.ComponentProps<typeof Sidebar>) {
|
||||
const sidebarContent = await api.app.getSidebarContent();
|
||||
@ -49,7 +48,9 @@ export async function AppSidebar({
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton>
|
||||
{category.name}{" "}
|
||||
<Link href={appRoutes.category(category.slug)}>
|
||||
<span>{category.name}</span>
|
||||
</Link>
|
||||
<Plus className="ml-auto group-data-[state=open]/collapsible:hidden" />
|
||||
<Minus className="ml-auto group-data-[state=closed]/collapsible:hidden" />
|
||||
</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<
|
||||
HTMLDivElement,
|
||||
@ -9,13 +9,13 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className
|
||||
"rounded-lg border bg-card text-card-foreground shadow-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@ -26,8 +26,8 @@ const CardHeader = React.forwardRef<
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@ -38,8 +38,8 @@ const CardTitle = React.forwardRef<
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@ -50,16 +50,16 @@ const CardDescription = React.forwardRef<
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
@ -70,7 +70,14 @@ const CardFooter = React.forwardRef<
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...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);
|
||||
};
|
||||
}
|
||||
|
||||
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({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
image: z.string().optional(),
|
||||
});
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
createArticleSchema,
|
||||
} from "@/lib/validation/zod/article";
|
||||
import { api } from "@/trpc/server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
@ -16,16 +17,15 @@ export async function createArticle(
|
||||
article,
|
||||
});
|
||||
if (!result[0]?.slug?.length) return false;
|
||||
revalidatePath("/");
|
||||
return redirect(appRoutes.editArticle(result[0].slug));
|
||||
}
|
||||
export async function updateArticle(
|
||||
article: z.infer<typeof articleSchema>,
|
||||
articleId: string,
|
||||
) {
|
||||
console.log("Content :: action", JSON.stringify(article.content));
|
||||
|
||||
const result = await api.article.update({
|
||||
article,
|
||||
article: { ...article, content: JSON.parse(article.content) },
|
||||
articleId,
|
||||
});
|
||||
// if (!result[0]?.id?.length) return false;
|
||||
|
||||
@ -16,12 +16,19 @@ export async function updateCategory(
|
||||
category: z.infer<typeof categorySchema>,
|
||||
categoryId: string,
|
||||
) {
|
||||
const result = await api.category.update({
|
||||
category,
|
||||
categoryId,
|
||||
});
|
||||
// if (!result[0]?.id?.length) return false;
|
||||
// return revalidatePath(`/artikel/${result[0].id}/edit`);
|
||||
try {
|
||||
const result = await api.category.update({
|
||||
category,
|
||||
categoryId,
|
||||
});
|
||||
|
||||
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) {
|
||||
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 { desc, ilike, like } from "drizzle-orm";
|
||||
import {
|
||||
articles as articlesTable,
|
||||
categories as categoriesTable,
|
||||
lower,
|
||||
} from "@/server/db/schema";
|
||||
|
||||
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 }) => {
|
||||
return await ctx.db.query.categories.findMany({
|
||||
with: {
|
||||
|
||||
@ -5,17 +5,25 @@ import {
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} from "@/server/api/trpc";
|
||||
import { articles } from "@/server/db/schema";
|
||||
import { Article, articles } from "@/server/db/schema";
|
||||
import {
|
||||
articleSchema,
|
||||
createArticleSchema,
|
||||
} 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 { generateSlug } from "@/lib/utils";
|
||||
|
||||
export const articleRouter = createTRPCRouter({
|
||||
// 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
|
||||
.input(z.object({ slug: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
@ -24,23 +32,57 @@ export const articleRouter = createTRPCRouter({
|
||||
with: { category: true },
|
||||
});
|
||||
}),
|
||||
|
||||
getAll: publicProcedure
|
||||
.input(z.object({ categoryId: z.string() }).optional())
|
||||
getByPage: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
categoryId: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
cursor: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.articles.findMany({
|
||||
where: input?.categoryId
|
||||
? eq(articles.categoryId, input.categoryId)
|
||||
: undefined,
|
||||
const { categoryId, cursor } = input!;
|
||||
const limit = input?.limit ?? 50;
|
||||
const cursorArg = cursor ? gt(articles.slug, cursor) : 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
|
||||
.input(z.object({ categoryId: z.string() }).optional())
|
||||
getAll: publicProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
categoryId: z.string().optional(),
|
||||
limit: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.articles.findMany({
|
||||
where: input?.categoryId
|
||||
? eq(articles.categoryId, input.categoryId)
|
||||
: undefined,
|
||||
limit: input?.limit,
|
||||
columns: {
|
||||
title: true,
|
||||
slug: true,
|
||||
@ -48,6 +90,7 @@ export const articleRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
getCount: publicProcedure
|
||||
.input(z.object({ categoryId: z.string() }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
@ -76,6 +119,12 @@ export const articleRouter = createTRPCRouter({
|
||||
.values({ ...input.article, slug })
|
||||
.returning({
|
||||
slug: articles.slug,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: articles.slug,
|
||||
set: {
|
||||
slug: sql`${slug} || '-' || (SELECT COUNT(*) FROM ${articles} WHERE slug LIKE ${slug + "-%"})`,
|
||||
},
|
||||
});
|
||||
}),
|
||||
update: protectedProcedure
|
||||
|
||||
@ -4,25 +4,43 @@ import {
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} 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 { categorySchema } from "@/lib/validation/zod/category";
|
||||
import { generateSlug } from "@/lib/utils";
|
||||
|
||||
export const categoryRouter = createTRPCRouter({
|
||||
get: publicProcedure
|
||||
.input(z.object({ slug: z.string() }))
|
||||
search: publicProcedure
|
||||
.input(z.object({ query: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await ctx.db.query.categories.findFirst({
|
||||
where: eq(categories.slug, input.slug),
|
||||
return await ctx.db.query.categories.findMany({
|
||||
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 }) => {
|
||||
return await ctx.db.query.categories.findMany();
|
||||
}),
|
||||
getAll: publicProcedure
|
||||
.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 }) => {
|
||||
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;
|
||||
|
||||
export const db = drizzle(conn, { schema });
|
||||
|
||||
export type DBType = typeof db;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import { relations, SQL, sql } from "drizzle-orm";
|
||||
import {
|
||||
AnyPgColumn,
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
@ -11,15 +12,14 @@ import {
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { User } from "next-auth";
|
||||
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 articles = createTable(
|
||||
@ -32,7 +32,7 @@ export const articles = createTable(
|
||||
title: varchar("title", { length: 256 }),
|
||||
slug: varchar("slug", { length: 256 }).unique().notNull(),
|
||||
authorId: varchar("author_id", { length: 255 }),
|
||||
content: text("content"),
|
||||
content: jsonb("content").$type<JSONContent>(),
|
||||
categoryId: varchar("category_id", { length: 255 }),
|
||||
published: boolean("published").default(false),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
@ -46,12 +46,19 @@ export const articles = createTable(
|
||||
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 }) => ({
|
||||
category: one(categories, {
|
||||
fields: [articles.categoryId],
|
||||
references: [categories.id],
|
||||
}),
|
||||
author: one(users, {
|
||||
fields: [articles.authorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const categories = createTable(
|
||||
@ -62,8 +69,10 @@ export const categories = createTable(
|
||||
.$defaultFn(() => createId())
|
||||
.notNull(),
|
||||
name: varchar("name", { length: 256 }),
|
||||
description: text("description"),
|
||||
slug: varchar("slug", { length: 256 }).unique().notNull(),
|
||||
|
||||
image: varchar("image", { length: 255 }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
@ -75,7 +84,9 @@ export const categories = createTable(
|
||||
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 }) => ({
|
||||
articles: many(articles),
|
||||
|
||||
@ -57,7 +57,7 @@ export function TRPCReactProvider(props: { children: React.ReactNode }) {
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
|
||||
# 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
|
||||
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 \
|
||||
-e POSTGRES_USER="postgres" \
|
||||
-e POSTGRES_PASSWORD="$DB_PASSWORD" \
|
||||
-e POSTGRES_DB=wiki-antifa \
|
||||
-e POSTGRES_DB=logipedia \
|
||||
-p "$DB_PORT":5432 \
|
||||
docker.io/postgres && echo "Database container '$DB_CONTAINER_NAME' was successfully created"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user