Improve translation system, add fully responsive admin panel, and various other fixes.

This commit is contained in:
2025-06-14 20:19:45 +02:00
parent bb7ea482ec
commit d52af5c30f
64 changed files with 2523 additions and 1867 deletions

View File

@@ -16,6 +16,12 @@ export const appRouter = router({
healthCheck: publicProcedure.query(() => {
return "OK";
}),
checkAuthStatus: publicProcedure.query(({ ctx }) => {
return {
isLoggedIn: !!ctx.session,
user: ctx.session?.user || null
};
}),
privateData: protectedProcedure.query(({ ctx }) => {
return {
message: "This is private",

View File

@@ -1,578 +0,0 @@
{
"admin": {
"dashboard": {
"title": "Admin Panel",
"description": "Welcome to the admin panel. Choose a section you want to edit."
},
"homepage": {
"title": "Homepage",
"description": "Edit homepage content",
"welcomeText": "Welcome Text",
"welcomePlaceholder": "Enter welcome text...",
"specializationText": "Specialization",
"specializationPlaceholder": "Enter specialization text...",
"aboutMeText": "About Me",
"aboutMePlaceholder": "Enter about me text...",
"updateSuccess": "Homepage updated successfully"
},
"skills": {
"title": "Skills",
"addNew": "Add skill",
"addNewTitle": "Add new skill",
"editTitle": "Edit skill",
"namePl": "Name (PL)",
"nameEn": "Name (EN)",
"categoryPl": "Category (PL)",
"categoryEn": "Category (EN)",
"category": "Category",
"selectCategory": "Select category",
"categoriesTitle": "Skill categories",
"addNewCategory": "Add category",
"addNewCategoryTitle": "Add new category",
"editCategoryTitle": "Edit category",
"deleteCategoryTitle": "Delete category",
"deleteCategoryConfirm": "Are you sure you want to delete this category? Skills assigned to this category will not be assigned to any category.",
"icon": "Icon",
"iconName": "Icon name",
"iconProvider": "Icon provider",
"order": "Order",
"active": "Active",
"deleteTitle": "Delete skill",
"deleteConfirm": "Are you sure you want to delete this skill?"
},
"projects": {
"title": "Projects",
"addNew": "Add project",
"addNewTitle": "Add new project",
"editTitle": "Edit project",
"titlePl": "Title (PL)",
"titleEn": "Title (EN)",
"descriptionPl": "Description (PL)",
"descriptionEn": "Description (EN)",
"url": "Project URL",
"repoUrl": "Repository URL (GitHub)",
"repoUrl2": "Repository URL (Gitea)",
"imageUrl": "Image URL",
"order": "Order",
"active": "Active",
"deleteTitle": "Delete project",
"deleteConfirm": "Are you sure you want to delete this project?"
},
"contact": {
"title": "Contact",
"addNew": "Add contact item",
"addNewTitle": "Add new contact item",
"editTitle": "Edit contact item",
"namePl": "Name (PL)",
"nameEn": "Name (EN)",
"iconName": "Icon name",
"iconProvider": "Icon provider",
"url": "URL",
"external": "External link",
"newTab": "Open in new tab",
"order": "Order",
"deleteTitle": "Delete contact item",
"deleteConfirm": "Are you sure you want to delete this contact item?"
},
"navigation": {
"title": "Navigation",
"addNew": "Add navigation item",
"addNewTitle": "Add new navigation item",
"editTitle": "Edit navigation item",
"labelPl": "Label (PL)",
"labelEn": "Label (EN)",
"url": "URL",
"external": "External link",
"newTab": "Open in new tab",
"active": "Active",
"order": "Order",
"deleteTitle": "Delete navigation item",
"deleteConfirm": "Are you sure you want to delete this navigation item?",
"menu": "Menu"
},
"topBar": {
"title": "Top Bar",
"addNew": "Add top bar item",
"addNewTitle": "Add new top bar item",
"editTitle": "Edit top bar item",
"namePl": "Name (PL)",
"nameEn": "Name (EN)",
"iconName": "Icon name",
"iconProvider": "Icon provider",
"url": "URL",
"external": "External link",
"newTab": "Open in new tab",
"order": "Order",
"deleteTitle": "Delete top bar item",
"deleteConfirm": "Are you sure you want to delete this top bar item?"
},
"metaTags": {
"title": "Meta Tags",
"description": "Manage SEO meta tags for the homepage",
"projectsPageTitle": "Settings",
"projectsPageDescription": "Manage SEO meta tags for the projects page",
"skillsPageTitle": "Settings",
"skillsPageDescription": "Manage SEO meta tags for the skills page",
"contactPageTitle": "Settings",
"contactPageDescription": "Manage SEO meta tags for the contact page",
"metaTitle": "Meta Title",
"metaTitlePlaceholder": "Enter page title for search engines...",
"metaDescription": "Meta Description",
"metaDescriptionPlaceholder": "Enter page description for search engines...",
"metaKeywords": "Meta Keywords",
"metaKeywordsPlaceholder": "Enter keywords separated by commas...",
"ogImage": "Open Graph Image",
"ogImagePlaceholder": "Enter image URL for social media sharing..."
},
"blog": {
"title": "Blog",
"posts": "Posts",
"pageSettings": "Page Settings",
"postsManagement": "Posts Management",
"pageMetaTags": "Page Meta Tags",
"newPost": "New Post",
"noPosts": "No posts available",
"searchPosts": "Search posts...",
"deleteConfirm": "Are you sure you want to delete this post?",
"published": "Published",
"draft": "Draft",
"neverPublished": "Never published",
"category": "Category",
"noCategory": "No category",
"status": "Status",
"publishedAt": "Published At",
"views": "Views",
"actions": "Actions",
"view": "View",
"edit": "Edit",
"delete": "Delete",
"basicInfo": "Basic Information",
"titlePlaceholder": "Enter post title...",
"slug": "Slug",
"slugPlaceholder": "my-blog-post-title",
"content": "Content",
"contentPlaceholder": "Write your post content here...",
"excerpt": "Excerpt",
"excerptPlaceholder": "Brief description of the post...",
"publishSettings": "Publish Settings",
"selectCategory": "Select a category",
"publish": "Publish",
"publishDate": "Publish Date",
"featured": "Featured Post",
"createPost": "Create Post",
"postNotFound": "Post not found",
"postInfo": "Post Information",
"polishVersion": "Polish Version",
"englishVersion": "English Version",
"noContent": "No content available",
"editPost": "Edit Post",
"updatePost": "Update Post",
"viewPost": "View Post",
"categories": "Categories",
"categoriesManagement": "Categories Management",
"addCategory": "Add Category",
"editCategory": "Edit Category",
"deleteCategory": "Delete Category",
"deleteCategoryConfirm": "Are you sure you want to delete this category? Posts in this category will remain but without category assignment.",
"categoryNamePl": "Category Name (PL)",
"categoryNameEn": "Category Name (EN)",
"categorySlug": "Category Slug",
"categoryDescriptionPl": "Description (PL)",
"categoryDescriptionEn": "Description (EN)",
"categoryActive": "Active",
"iconName": "Icon Name",
"iconProvider": "Icon Provider",
"order": "Order",
"noCategories": "No categories found",
"subtitle": "Articles about programming, technology and software development",
"searchPlaceholder": "Search articles...",
"allCategories": "All",
"featuredArticles": "Featured Articles",
"allArticles": "All Articles",
"noArticlesFound": "No articles found matching your search criteria.",
"relatedArticles": "Related Articles",
"backToBlog": "Back to blog",
"articleNotFound": "Article not found",
"readMore": "Read more",
"rssFeed": "RSS Feed"
},
"comments": {
"title": "Comments",
"description": "Manage blog comments - approve, reject and delete comments",
"searchPlaceholder": "Search by author, content or article title...",
"statusUpdated": "Comment status has been updated",
"updateError": "An error occurred while updating comment status",
"deleted": "Comment has been deleted",
"deleteError": "An error occurred while deleting comment",
"confirmDelete": "Are you sure you want to delete this comment? This action cannot be undone.",
"deleteTitle": "Delete Comment",
"deleteMessage": "Are you sure you want to delete the comment from {authorName}? This action cannot be undone.",
"pending": "Pending",
"approved": "Approved",
"total": "Total",
"all": "All",
"statusPending": "Pending",
"statusApproved": "Approved",
"approve": "Approve",
"reject": "Reject",
"delete": "Delete",
"article": "Article:",
"replyToComment": "Reply to comment",
"replyTo": "Reply to:",
"noCommentsFound": "No comments found matching the search",
"noComments": "No comments",
"previous": "Previous",
"next": "Next",
"replyButton": "Reply"
}
},
"common": {
"loading": "Loading...",
"connected": "Connected",
"disconnected": "Disconnected",
"checking": "Checking...",
"save": "Save",
"saving": "Saving...",
"saved": "Changes saved successfully!",
"add": "Add",
"addImage": "Add image",
"edit": "Edit",
"update": "Update",
"delete": "Delete",
"cancel": "Cancel",
"actions": "Actions",
"yes": "Yes",
"no": "No",
"updating": "Updating...",
"updated": "Updated successfully",
"deleting": "Deleting...",
"deleted": "Deleted successfully",
"select": "Select",
"search": "Search",
"changeOrder": "Change order mode",
"back": "Back",
"untitled": "Untitled",
"draft": "Draft"
},
"fileUpload": {
"uploading": "Uploading...",
"dragAndDrop": "Drag & drop an image here, or",
"chooseFile": "Choose File",
"clear": "Clear",
"selectOrUploadImage": "Select or upload an image"
},
"welcome": "Welcome",
"login": {
"title": "Admin Panel",
"email": "Email address",
"password": "Password",
"signIn": "Sign in",
"success": "Sign in successful",
"submit": "Sign in",
"noAccount": "Don't have an account yet?",
"register": "Register"
},
"register": {
"title": "Register",
"success": "Registration successful",
"submit": "Register",
"haveAccount": "Already have an account?",
"signIn": "Sign in",
"name": "Full name",
"login": "Login"
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome back",
"welcomeUser": "Welcome {name}",
"privateData": "Private data: {{message}}",
"nav": {
"homepage": "Homepage",
"skills": "Skills",
"projects": "Projects",
"navigation": "Navigation",
"topbar": "Top Bar",
"contact": "Contact",
"blog": "Blog",
"privacyPolicy": "Privacy Policy",
"newsletter": "Newsletter"
}
},
"navigation": {
"home": "Return to website",
"dashboard": "Dashboard",
"settings": "Settings",
"keyboardHint": "You can use arrow keys for navigation",
"menu": "Menu"
},
"theme": {
"toggle": "Toggle theme",
"light": "Light",
"dark": "Dark",
"system": "System"
},
"footer": {
"copyright": "Bogusław Witek. All rights reserved."
},
"projects": {
"title": "My Projects",
"noProjects": "No projects to display",
"github": "GitHub",
"gitea": "Gitea",
"demo": "Demo"
},
"skills": {
"title": "Skills",
"noSkills": "No skills in this category",
"noCategories": "No skill categories"
},
"contact": {
"title": "Contact",
"directContact": "Contact Information",
"quickMessage": "Quick Message",
"form": {
"name": "Full Name",
"email": "Email Address",
"message": "Message",
"send": "Send Message",
"success": "Message sent successfully!",
"error": "An error occurred while sending the message. Please try again."
}
},
"language": {
"switch": "Switch language"
},
"userMenu": {
"signIn": "Sign In",
"signOut": "Sign Out",
"myAccount": "My Account"
},
"home": {
"apiStatus": "API Status"
},
"validation": {
"required": "This field is required",
"min": "This field must be at least {min} characters long",
"max": "This field must be at most {max} characters long",
"minLength": "This field must be at least {min} characters long",
"maxLength": "This field must be at most {max} characters long",
"minValue": "This field must be at least {min} value",
"maxValue": "This field must be at most {max} value",
"invalid": "This field is invalid",
"invalidUrl": "Enter a valid URL (http:// or https://)",
"turnstileRequired": "Please complete the security verification"
},
"comments": {
"title": "Comments",
"addComment": "Add Comment",
"reply": "Reply",
"replyButton": "Reply",
"replyForm": "Reply to Comment",
"noComments": "No comments yet. Be the first!",
"moderationNotice": "Your comment will be published after approval by a moderator.",
"loadError": "Cannot load comments - invalid article identifier.",
"form": {
"name": "Full Name",
"namePlaceholder": "Enter your full name",
"email": "Email Address",
"emailPlaceholder": "Enter your email address (will not be public)",
"website": "Website",
"websitePlaceholder": "https://your-website.com (optional)",
"content": "Comment Content",
"contentPlaceholder": "Write your comment...",
"submit": "Add Comment",
"success": "Comment added and awaiting approval!",
"error": "An error occurred while adding the comment. Please try again."
}
},
"blog": {
"title": "Blog",
"subtitle": "Articles about programming, technology and software development",
"searchPlaceholder": "Search articles...",
"allCategories": "All",
"featuredArticles": "Featured Articles",
"allArticles": "All Articles",
"noArticlesFound": "No articles found matching your search criteria.",
"relatedArticles": "Related Articles",
"backToBlog": "Back to blog",
"articleNotFound": "Article not found",
"readMore": "Read more",
"featured": "Featured",
"views": "views",
"rssFeed": "RSS Feed",
"share": {
"title": "Share article",
"facebook": "Share on Facebook",
"twitter": "Share on Twitter",
"linkedin": "Share on LinkedIn",
"whatsapp": "Share via WhatsApp",
"email": "Share via email",
"copyLink": "Copy link",
"linkCopied": "Link copied to clipboard!"
}
},
"notFound": {
"title": "Page not found",
"description": "The page you are looking for was not found. It may have been moved or deleted.",
"homepage": "Homepage"
},
"newsletter": {
"title": "Newsletter",
"description": "Get notified about new articles",
"email": "Email address",
"emailPlaceholder": "your@email.com",
"language": "Newsletter language",
"gdprConsent": "I consent to data processing for newsletter purposes in accordance with the",
"gdprConsentCompact": "I consent to the processing of my personal data for receiving the newsletter.",
"gdprConsentError": "You must consent to data processing",
"privacyPolicy": "privacy policy",
"subscribe": "Subscribe to newsletter",
"subscribeShort": "Subscribe",
"subscribing": "Subscribing...",
"successMessage": "Successfully subscribed to newsletter!",
"errorMessage": "An error occurred during subscription",
"blogSectionTitle": "Subscribe to Newsletter",
"blogSectionDescription": "Get notifications about new articles directly to your email inbox",
"articleSectionTitle": "Did you enjoy this article?",
"articleSectionDescription": "Subscribe to our newsletter to not miss the next ones!",
"contentPlaceholders": {
"privacyPolicyPl": "Enter privacy policy content in Polish...",
"privacyPolicyEn": "Enter privacy policy content in English..."
},
"languages": {
"polish": "Polish",
"english": "English"
},
"admin": {
"title": "Newsletter Management",
"description": "Manage newsletter subscriptions and send notifications to subscribers",
"stats": {
"title": "Newsletter Statistics",
"description": "Current subscriber counts by language",
"polish": "Polish Subscribers",
"english": "English Subscribers",
"total": "Total Subscribers",
"loading": "Loading statistics..."
},
"send": {
"title": "Send Newsletter",
"description": "Send a newsletter to subscribers about a published article",
"selectArticle": "Select Article",
"articlePlaceholder": "Choose an article to feature...",
"preview": "Selected Article Preview:",
"polishTitle": "Polish Title:",
"englishTitle": "English Title:",
"slug": "Slug:",
"status": "Status:",
"published": "Published",
"draft": "Draft",
"notAvailable": "Not available",
"sendToPolish": "Send to Polish Subscribers",
"sendToEnglish": "Send to English Subscribers",
"confirmTitle": "Confirm Newsletter Send",
"confirmDescription": "This action cannot be undone. The newsletter will be sent immediately to all subscribers.",
"confirmDetails": {
"article": "Article:",
"language": "Language:",
"recipients": "Recipients:",
"subscribers": "subscribers"
},
"cancel": "Cancel",
"sending": "Sending...",
"confirm": "Yes, Send Newsletter",
"selectArticleFirst": "Please select an article first",
"successMessage": "Newsletter sent to {count} {language} subscribers",
"polish": "Polish",
"english": "English",
"unsubscribe": {
"title": "Unsubscribe from newsletter",
"subtitle": "We're sorry to see you go. Your feedback will help us improve.",
"emailAddress": "Email address",
"emailPlaceholder": "your@email.com",
"reason": "Reason for unsubscribing (optional)",
"reasons": {
"too_frequent": "Too frequent emails",
"not_relevant": "Content is not relevant to me",
"never_subscribed": "I never subscribed",
"poor_content": "Poor content quality",
"technical_issues": "Technical issues",
"other": "Other reason"
},
"feedback": "Additional feedback",
"feedbackPlaceholder": "Tell us more about your feedback...",
"cancel": "Cancel",
"unsubscribeAction": "Unsubscribe",
"unsubscribing": "Unsubscribing...",
"emailRequired": "Email address is required",
"successTitle": "Unsubscribed successfully",
"successMessage": "You have been successfully unsubscribed from BWitek.dev newsletter. Thank you for your feedback!",
"feedbackNote": "Your feedback helps us better tailor our content to our readers' needs.",
"home": "Home",
"blog": "Blog"
}
}
}
},
"richEditor": {
"toolbar": {
"bold": "Bold",
"italic": "Italic",
"strikethrough": "Strikethrough",
"code": "Inline Code",
"heading1": "Heading 1",
"heading2": "Heading 2",
"heading3": "Heading 3",
"bulletList": "Bullet List",
"orderedList": "Ordered List",
"quote": "Quote",
"addLink": "Add Link",
"addImage": "Add Image",
"addYoutube": "Add YouTube Video",
"addCodeBlock": "Add Code Block",
"undo": "Undo",
"redo": "Redo"
},
"modals": {
"link": {
"title": "Add Link",
"description": "Enter the URL of the page you want to link to. Leave empty to remove link.",
"urlLabel": "URL",
"urlPlaceholder": "https://example.com",
"cancel": "Cancel",
"addLink": "Add Link",
"removeLink": "Remove Link"
},
"youtube": {
"title": "Add YouTube Video",
"description": "Enter a YouTube video link in one of the supported formats.",
"urlLabel": "YouTube URL",
"urlPlaceholder": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"supportedFormats": "Supported formats:",
"formats": [
"https://www.youtube.com/watch?v=VIDEO_ID",
"https://youtu.be/VIDEO_ID",
"https://www.youtube.com/embed/VIDEO_ID"
],
"invalidUrl": "Invalid YouTube URL format",
"cancel": "Cancel",
"addVideo": "Add Video"
},
"codeBlock": {
"title": "Add Code Block",
"description": "Enter a programming language for syntax highlighting (optional).",
"languageLabel": "Programming Language",
"languagePlaceholder": "javascript, python, css...",
"popularLanguages": "Popular languages:",
"languagesList": "javascript, typescript, python, css, html, json, sql, bash, php, java, cpp",
"noHighlighting": "Leave empty for plain text without highlighting.",
"cancel": "Cancel",
"addCodeBlock": "Add Code Block"
},
"image": {
"title": "Add Image",
"description": "Select or upload a new image for the article.",
"cancel": "Cancel"
}
}
}
}

View File

@@ -0,0 +1,235 @@
{
"dashboard": {
"title": "Admin Panel",
"description": "Welcome to the admin panel. Choose a section you want to edit."
},
"homepage": {
"title": "Homepage",
"description": "Edit homepage content",
"welcomeText": "Welcome Text",
"welcomePlaceholder": "Enter welcome text...",
"specializationText": "Specialization",
"specializationPlaceholder": "Enter specialization text...",
"aboutMeText": "About Me",
"aboutMePlaceholder": "Enter about me text...",
"updateSuccess": "Homepage updated successfully"
},
"skills": {
"title": "Skills",
"addNew": "Add skill",
"addNewTitle": "Add new skill",
"editTitle": "Edit skill",
"namePl": "Name (PL)",
"nameEn": "Name (EN)",
"categoryPl": "Category (PL)",
"categoryEn": "Category (EN)",
"category": "Category",
"selectCategory": "Select category",
"categoriesTitle": "Skill categories",
"addNewCategory": "Add category",
"addNewCategoryTitle": "Add new category",
"editCategoryTitle": "Edit category",
"deleteCategoryTitle": "Delete category",
"deleteCategoryConfirm": "Are you sure you want to delete this category? Skills assigned to this category will not be assigned to any category.",
"icon": "Icon",
"iconName": "Icon name",
"iconProvider": "Icon provider",
"order": "Order",
"active": "Active",
"deleteTitle": "Delete skill",
"deleteConfirm": "Are you sure you want to delete this skill?"
},
"projects": {
"title": "Projects",
"addNew": "Add project",
"addNewTitle": "Add new project",
"editTitle": "Edit project",
"titlePl": "Title (PL)",
"titleEn": "Title (EN)",
"descriptionPl": "Description (PL)",
"descriptionEn": "Description (EN)",
"url": "Project URL",
"repoUrl": "Repository URL (GitHub)",
"repoUrl2": "Repository URL (Gitea)",
"imageUrl": "Image URL",
"order": "Order",
"active": "Active",
"deleteTitle": "Delete project",
"deleteConfirm": "Are you sure you want to delete this project?"
},
"contact": {
"title": "Contact",
"addNew": "Add contact item",
"addNewTitle": "Add new contact item",
"editTitle": "Edit contact item",
"namePl": "Name (PL)",
"nameEn": "Name (EN)",
"iconName": "Icon name",
"iconProvider": "Icon provider",
"url": "URL",
"external": "External link",
"newTab": "Open in new tab",
"order": "Order",
"deleteTitle": "Delete contact item",
"deleteConfirm": "Are you sure you want to delete this contact item?"
},
"navigation": {
"title": "Navigation",
"addNew": "Add navigation item",
"addNewTitle": "Add new navigation item",
"editTitle": "Edit navigation item",
"labelPl": "Label (PL)",
"labelEn": "Label (EN)",
"url": "URL",
"external": "External link",
"newTab": "Open in new tab",
"active": "Active",
"order": "Order",
"deleteTitle": "Delete navigation item",
"deleteConfirm": "Are you sure you want to delete this navigation item?",
"menu": "Menu"
},
"topBar": {
"title": "Top Bar",
"addNew": "Add top bar item",
"addNewTitle": "Add new top bar item",
"editTitle": "Edit top bar item",
"namePl": "Name (PL)",
"nameEn": "Name (EN)",
"iconName": "Icon name",
"iconProvider": "Icon provider",
"url": "URL",
"external": "External link",
"newTab": "Open in new tab",
"order": "Order",
"deleteTitle": "Delete top bar item",
"deleteConfirm": "Are you sure you want to delete this top bar item?"
},
"metaTags": {
"title": "Meta Tags",
"description": "Manage SEO meta tags for the homepage",
"projectsPageTitle": "Settings",
"projectsPageDescription": "Manage SEO meta tags for the projects page",
"skillsPageTitle": "Settings",
"skillsPageDescription": "Manage SEO meta tags for the skills page",
"contactPageTitle": "Settings",
"contactPageDescription": "Manage SEO meta tags for the contact page",
"metaTitle": "Meta Title",
"metaTitlePlaceholder": "Enter page title for search engines...",
"metaDescription": "Meta Description",
"metaDescriptionPlaceholder": "Enter page description for search engines...",
"metaKeywords": "Meta Keywords",
"metaKeywordsPlaceholder": "Enter keywords separated by commas...",
"ogImage": "Open Graph Image",
"ogImagePlaceholder": "Enter image URL for social media sharing..."
},
"blog": {
"title": "Blog",
"posts": "Posts",
"pageSettings": "Page Settings",
"postsManagement": "Posts Management",
"pageMetaTags": "Page Meta Tags",
"newPost": "New Post",
"noPosts": "No posts available",
"searchPosts": "Search posts...",
"deleteConfirm": "Are you sure you want to delete this post?",
"published": "Published",
"draft": "Draft",
"neverPublished": "Never published",
"category": "Category",
"noCategory": "No category",
"status": "Status",
"publishedAt": "Published At",
"views": "Views",
"actions": "Actions",
"view": "View",
"edit": "Edit",
"delete": "Delete",
"basicInfo": "Basic Information",
"titlePlaceholder": "Enter post title...",
"slug": "Slug",
"slugPlaceholder": "my-blog-post-title",
"content": "Content",
"contentPlaceholder": "Write your post content here...",
"excerpt": "Excerpt",
"excerptPlaceholder": "Brief description of the post...",
"publishSettings": "Publish Settings",
"selectCategory": "Select a category",
"publish": "Publish",
"publishDate": "Publish Date",
"featured": "Featured Post",
"createPost": "Create Post",
"postNotFound": "Post not found",
"postInfo": "Post Information",
"polishVersion": "Polish Version",
"englishVersion": "English Version",
"noContent": "No content available",
"editPost": "Edit Post",
"updatePost": "Update Post",
"viewPost": "View Post",
"categories": "Categories",
"categoriesManagement": "Categories Management",
"addCategory": "Add Category",
"editCategory": "Edit Category",
"deleteCategory": "Delete Category",
"deleteCategoryConfirm": "Are you sure you want to delete this category? Posts in this category will remain but without category assignment.",
"categoryNamePl": "Category Name (PL)",
"categoryNameEn": "Category Name (EN)",
"categorySlug": "Category Slug",
"categoryDescriptionPl": "Description (PL)",
"categoryDescriptionEn": "Description (EN)",
"categoryActive": "Active",
"iconName": "Icon Name",
"iconProvider": "Icon Provider",
"order": "Order",
"noCategories": "No categories found",
"subtitle": "Articles about programming, technology and software development",
"searchPlaceholder": "Search articles...",
"allCategories": "All",
"featuredArticles": "Featured Articles",
"allArticles": "All Articles",
"noArticlesFound": "No articles found matching your search criteria.",
"relatedArticles": "Related Articles",
"backToBlog": "Back to blog",
"articleNotFound": "Article not found",
"readMore": "Read more",
"rssFeed": "RSS Feed"
},
"comments": {
"title": "Comments",
"description": "Manage blog comments - approve, reject and delete comments",
"searchPlaceholder": "Search by author, content or article title...",
"statusUpdated": "Comment status has been updated",
"updateError": "An error occurred while updating comment status",
"deleted": "Comment has been deleted",
"deleteError": "An error occurred while deleting comment",
"confirmDelete": "Are you sure you want to delete this comment? This action cannot be undone.",
"deleteTitle": "Delete Comment",
"deleteMessage": "Are you sure you want to delete the comment from {authorName}? This action cannot be undone.",
"pending": "Pending",
"approved": "Approved",
"total": "Total",
"all": "All",
"statusPending": "Pending",
"statusApproved": "Approved",
"approve": "Approve",
"reject": "Reject",
"delete": "Delete",
"article": "Article:",
"replyToComment": "Reply to comment",
"replyTo": "Reply to:",
"noCommentsFound": "No comments found matching the search",
"noComments": "No comments",
"previous": "Previous",
"next": "Next",
"replyButton": "Reply"
},
"privacy-policy": {
"title": "Privacy Policy",
"metaTags": "Settings",
"metaTagsDescription": "Manage SEO meta tags for the privacy policy page",
"plContent": "Polish content",
"enContent": "English content",
"content": "Privacy Policy Content"
}
}

View File

@@ -0,0 +1,26 @@
{
"title": "Blog",
"subtitle": "Articles about programming, technology and software development",
"searchPlaceholder": "Search articles...",
"allCategories": "All",
"featuredArticles": "Featured Articles",
"allArticles": "All Articles",
"noArticlesFound": "No articles found matching your search criteria.",
"relatedArticles": "Related Articles",
"backToBlog": "Back to blog",
"articleNotFound": "Article not found",
"readMore": "Read more",
"featured": "Featured",
"views": "views",
"rssFeed": "RSS Feed",
"share": {
"title": "Share article",
"facebook": "Share on Facebook",
"twitter": "Share on Twitter",
"linkedin": "Share on LinkedIn",
"whatsapp": "Share via WhatsApp",
"email": "Share via email",
"copyLink": "Copy link",
"linkCopied": "Link copied to clipboard!"
}
}

View File

@@ -0,0 +1,23 @@
{
"title": "Comments",
"addComment": "Add Comment",
"reply": "Reply",
"replyButton": "Reply",
"replyForm": "Reply to Comment",
"noComments": "No comments yet. Be the first!",
"moderationNotice": "Your comment will be published after approval by a moderator.",
"loadError": "Cannot load comments - invalid article identifier.",
"form": {
"name": "Full Name",
"namePlaceholder": "Enter your full name",
"email": "Email Address",
"emailPlaceholder": "Enter your email address (will not be public)",
"website": "Website",
"websitePlaceholder": "https://your-website.com (optional)",
"content": "Comment Content",
"contentPlaceholder": "Write your comment...",
"submit": "Add Comment",
"success": "Comment added and awaiting approval!",
"error": "An error occurred while adding the comment. Please try again."
}
}

View File

@@ -0,0 +1,33 @@
{
"welcome": "Welcome",
"loading": "Loading...",
"connected": "Connected",
"disconnected": "Disconnected",
"checking": "Checking...",
"save": "Save",
"saving": "Saving...",
"saved": "Changes saved successfully!",
"submitting": "Submitting...",
"add": "Add",
"addImage": "Add image",
"edit": "Edit",
"update": "Update",
"delete": "Delete",
"cancel": "Cancel",
"actions": "Actions",
"yes": "Yes",
"no": "No",
"updating": "Updating...",
"updated": "Updated successfully",
"deleting": "Deleting...",
"deleted": "Deleted successfully",
"select": "Select",
"search": "Search",
"changeOrder": "Change order mode",
"back": "Back",
"untitled": "Untitled",
"draft": "Draft",
"showMore": "Show more",
"noResults": "No results found",
"noData": "No data"
}

View File

@@ -0,0 +1,107 @@
{
"fileUpload": {
"uploading": "Uploading...",
"dragAndDrop": "Drag & drop an image here, or",
"chooseFile": "Choose File",
"clear": "Clear",
"selectOrUploadImage": "Select or upload an image"
},
"richEditor": {
"toolbar": {
"bold": "Bold",
"italic": "Italic",
"strikethrough": "Strikethrough",
"code": "Inline Code",
"heading1": "Heading 1",
"heading2": "Heading 2",
"heading3": "Heading 3",
"bulletList": "Bullet List",
"orderedList": "Ordered List",
"quote": "Quote",
"addLink": "Add Link",
"addImage": "Add Image",
"addYoutube": "Add YouTube Video",
"addCodeBlock": "Add Code Block",
"undo": "Undo",
"redo": "Redo"
},
"modals": {
"link": {
"title": "Add Link",
"description": "Enter the URL of the page you want to link to. Leave empty to remove link.",
"urlLabel": "URL",
"urlPlaceholder": "https://example.com",
"cancel": "Cancel",
"addLink": "Add Link",
"removeLink": "Remove Link"
},
"youtube": {
"title": "Add YouTube Video",
"description": "Enter a YouTube video link in one of the supported formats.",
"urlLabel": "YouTube URL",
"urlPlaceholder": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"supportedFormats": "Supported formats:",
"formats": [
"https://www.youtube.com/watch?v=VIDEO_ID",
"https://youtu.be/VIDEO_ID",
"https://www.youtube.com/embed/VIDEO_ID"
],
"invalidUrl": "Invalid YouTube URL format",
"cancel": "Cancel",
"addVideo": "Add Video"
},
"codeBlock": {
"title": "Add Code Block",
"description": "Enter a programming language for syntax highlighting (optional).",
"languageLabel": "Programming Language",
"languagePlaceholder": "javascript, python, css...",
"popularLanguages": "Popular languages:",
"languagesList": "javascript, typescript, python, css, html, json, sql, bash, php, java, cpp",
"noHighlighting": "Leave empty for plain text without highlighting.",
"cancel": "Cancel",
"addCodeBlock": "Add Code Block"
},
"image": {
"title": "Add Image",
"description": "Select or upload a new image for the article.",
"cancel": "Cancel"
}
}
},
"language": {
"switch": "Switch language"
},
"userMenu": {
"signIn": "Sign In",
"signOut": "Sign Out",
"myAccount": "My Account"
},
"theme": {
"toggle": "Toggle theme",
"light": "Light",
"dark": "Dark",
"system": "System"
},
"navigation": {
"home": "Return to website",
"dashboard": "Dashboard",
"settings": "Settings",
"keyboardHint": "You can use arrow keys for navigation",
"menu": "Menu"
},
"footer": {
"copyright": "Bogusław Witek. All rights reserved."
},
"validation": {
"required": "This field is required",
"min": "This field must be at least {min} characters long",
"max": "This field must be at most {max} characters long",
"minLength": "This field must be at least {min} characters long",
"maxLength": "This field must be at most {max} characters long",
"minValue": "This field must be at least {min} value",
"maxValue": "This field must be at most {max} value",
"invalid": "This field is invalid",
"invalidUrl": "Enter a valid URL (http:// or https://)",
"turnstileRequired": "Please complete the security verification"
}
}

View File

@@ -0,0 +1,97 @@
{
"title": "Newsletter",
"description": "Get notified about new articles",
"email": "Email address",
"emailPlaceholder": "your@email.com",
"language": "Newsletter language",
"gdprConsent": "I consent to data processing for newsletter purposes in accordance with the",
"gdprConsentCompact": "I consent to the processing of my personal data for receiving the newsletter.",
"gdprConsentError": "You must consent to data processing",
"privacyPolicy": "privacy policy",
"subscribe": "Subscribe to newsletter",
"subscribeShort": "Subscribe",
"subscribing": "Subscribing...",
"successMessage": "Successfully subscribed to newsletter!",
"errorMessage": "An error occurred during subscription",
"blogSectionTitle": "Subscribe to Newsletter",
"blogSectionDescription": "Get notifications about new articles directly to your email inbox",
"articleSectionTitle": "Did you enjoy this article?",
"articleSectionDescription": "Subscribe to our newsletter to not miss the next ones!",
"contentPlaceholders": {
"privacyPolicyPl": "Enter privacy policy content in Polish...",
"privacyPolicyEn": "Enter privacy policy content in English..."
},
"languages": {
"polish": "Polish",
"english": "English"
},
"admin": {
"title": "Newsletter Management",
"description": "Manage newsletter subscriptions and send notifications to subscribers",
"stats": {
"title": "Newsletter Statistics",
"description": "Current subscriber counts by language",
"polish": "Polish Subscribers",
"english": "English Subscribers",
"total": "Total Subscribers",
"loading": "Loading statistics..."
},
"send": {
"title": "Send Newsletter",
"description": "Send a newsletter to subscribers about a published article",
"selectArticle": "Select Article",
"articlePlaceholder": "Choose an article to feature...",
"preview": "Selected Article Preview:",
"polishTitle": "Polish Title:",
"englishTitle": "English Title:",
"slug": "Slug:",
"status": "Status:",
"published": "Published",
"draft": "Draft",
"notAvailable": "Not available",
"sendToPolish": "Send to Polish Subscribers",
"sendToEnglish": "Send to English Subscribers",
"confirmTitle": "Confirm Newsletter Send",
"confirmDescription": "This action cannot be undone. The newsletter will be sent immediately to all subscribers.",
"confirmDetails": {
"article": "Article:",
"language": "Language:",
"recipients": "Recipients:",
"subscribers": "subscribers"
},
"cancel": "Cancel",
"sending": "Sending...",
"confirm": "Yes, Send Newsletter",
"selectArticleFirst": "Please select an article first",
"successMessage": "Newsletter sent to {count} {language} subscribers",
"polish": "Polish",
"english": "English",
"unsubscribe": {
"title": "Unsubscribe from newsletter",
"subtitle": "We're sorry to see you go. Your feedback will help us improve.",
"emailAddress": "Email address",
"emailPlaceholder": "your@email.com",
"reason": "Reason for unsubscribing (optional)",
"reasons": {
"too_frequent": "Too frequent emails",
"not_relevant": "Content is not relevant to me",
"never_subscribed": "I never subscribed",
"poor_content": "Poor content quality",
"technical_issues": "Technical issues",
"other": "Other reason"
},
"feedback": "Additional feedback",
"feedbackPlaceholder": "Tell us more about your feedback...",
"cancel": "Cancel",
"unsubscribeAction": "Unsubscribe",
"unsubscribing": "Unsubscribing...",
"emailRequired": "Email address is required",
"successTitle": "Unsubscribed successfully",
"successMessage": "You have been successfully unsubscribed from BWitek.dev newsletter. Thank you for your feedback!",
"feedbackNote": "Your feedback helps us better tailor our content to our readers' needs.",
"home": "Home",
"blog": "Blog"
}
}
}
}

View File

@@ -0,0 +1,68 @@
{
"login": {
"title": "Admin Panel",
"email": "Email address",
"password": "Password",
"signIn": "Sign in",
"success": "Sign in successful",
"submit": "Sign in",
"noAccount": "Don't have an account yet?",
"register": "Register"
},
"register": {
"title": "Register",
"success": "Registration successful",
"submit": "Register",
"haveAccount": "Already have an account?",
"signIn": "Sign in",
"name": "Full name",
"login": "Login"
},
"dashboard": {
"title": "Dashboard",
"welcome": "Welcome back",
"welcomeUser": "Welcome {name}",
"privateData": "Private data: {{message}}",
"nav": {
"homepage": "Homepage",
"skills": "Skills",
"projects": "Projects",
"navigation": "Navigation",
"topbar": "Top Bar",
"contact": "Contact",
"blog": "Blog",
"privacyPolicy": "Privacy Policy",
"newsletter": "Newsletter"
}
},
"projects": {
"title": "My Projects",
"noProjects": "No projects to display",
"github": "GitHub",
"gitea": "Gitea",
"demo": "Demo"
},
"skills": {
"title": "Skills",
"noSkills": "No skills in this category",
"noCategories": "No skill categories"
},
"contact": {
"title": "Contact",
"directContact": "Contact Information",
"quickMessage": "Quick Message",
"form": {
"name": "Full Name",
"email": "Email Address",
"message": "Message",
"send": "Send Message",
"success": "Message sent successfully!",
"error": "An error occurred while sending the message. Please try again."
}
},
"notFound": {
"title": "Page not found",
"description": "The page you are looking for was not found. It may have been moved or deleted.",
"homepage": "Homepage"
}
}

View File

@@ -1,582 +0,0 @@
{
"admin": {
"dashboard": {
"title": "Panel Administracyjny",
"description": "Witaj w panelu administracyjnym. Wybierz sekcję, którą chcesz edytować."
},
"homepage": {
"title": "Strona Główna",
"description": "Edytuj zawartość strony głównej",
"welcomeText": "Tekst powitalny",
"welcomePlaceholder": "Wprowadź tekst powitalny...",
"specializationText": "Specjalizacja",
"specializationPlaceholder": "Wprowadź tekst o specjalizacji...",
"aboutMeText": "O mnie",
"aboutMePlaceholder": "Wprowadź tekst o sobie...",
"updateSuccess": "Pomyślnie zaktualizowano stronę główną"
},
"skills": {
"title": "Umiejętności",
"addNew": "Dodaj umiejętność",
"addNewTitle": "Dodaj nową umiejętność",
"editTitle": "Edytuj umiejętność",
"namePl": "Nazwa (PL)",
"nameEn": "Nazwa (EN)",
"categoryPl": "Kategoria (PL)",
"categoryEn": "Kategoria (EN)",
"category": "Kategoria",
"selectCategory": "Wybierz kategorię",
"categoriesTitle": "Kategorie umiejętności",
"addNewCategory": "Dodaj kategorię",
"addNewCategoryTitle": "Dodaj nową kategorię",
"editCategoryTitle": "Edytuj kategorię",
"deleteCategoryTitle": "Usuwanie kategorii",
"deleteCategoryConfirm": "Czy na pewno chcesz usunąć tę kategorię? Umiejętności przypisane do tej kategorii nie będą przypisane do żadnej kategorii.",
"icon": "Ikona",
"iconName": "Nazwa ikony",
"iconProvider": "Dostawca ikony",
"order": "Kolejność",
"active": "Aktywny",
"deleteTitle": "Usuwanie umiejętności",
"deleteConfirm": "Czy na pewno chcesz usunąć tę umiejętność?"
},
"projects": {
"title": "Projekty",
"addNew": "Dodaj projekt",
"addNewTitle": "Dodaj nowy projekt",
"editTitle": "Edytuj projekt",
"titlePl": "Tytuł (PL)",
"titleEn": "Tytuł (EN)",
"descriptionPl": "Opis (PL)",
"descriptionEn": "Opis (EN)",
"url": "Adres URL projektu",
"repoUrl": "Adres URL repozytorium (GitHub)",
"repoUrl2": "Adres URL repozytorium (Gitea)",
"imageUrl": "Adres URL obrazka",
"order": "Kolejność",
"active": "Aktywny",
"deleteTitle": "Usuwanie projektu",
"deleteConfirm": "Czy na pewno chcesz usunąć ten projekt?"
},
"contact": {
"title": "Kontakt",
"addNew": "Dodaj element kontaktu",
"addNewTitle": "Dodaj nowy element kontaktu",
"editTitle": "Edytuj element kontaktu",
"namePl": "Nazwa (PL)",
"nameEn": "Nazwa (EN)",
"iconName": "Nazwa ikony",
"iconProvider": "Dostawca ikony",
"url": "URL",
"external": "Link zewnętrzny",
"newTab": "Otwórz w nowej karcie",
"order": "Kolejność",
"deleteTitle": "Usuwanie elementu kontaktu",
"deleteConfirm": "Czy na pewno chcesz usunąć ten element kontaktu?"
},
"navigation": {
"title": "Nawigacja",
"addNew": "Dodaj element nawigacji",
"addNewTitle": "Dodaj nowy element nawigacji",
"editTitle": "Edytuj element nawigacji",
"labelPl": "Etykieta (PL)",
"labelEn": "Etykieta (EN)",
"url": "URL",
"external": "Link zewnętrzny",
"newTab": "Otwórz w nowej karcie",
"active": "Aktywny",
"order": "Kolejność",
"deleteTitle": "Usuwanie elementu nawigacji",
"deleteConfirm": "Czy na pewno chcesz usunąć ten element nawigacji?"
},
"topBar": {
"title": "Górny pasek",
"addNew": "Dodaj element górnego paska",
"addNewTitle": "Dodaj nowy element górnego paska",
"editTitle": "Edytuj element górnego paska",
"namePl": "Nazwa (PL)",
"nameEn": "Nazwa (EN)",
"iconName": "Nazwa ikony",
"iconProvider": "Dostawca ikony",
"url": "URL",
"external": "Link zewnętrzny",
"newTab": "Otwórz w nowej karcie",
"order": "Kolejność",
"deleteTitle": "Usuwanie elementu górnego paska",
"deleteConfirm": "Czy na pewno chcesz usunąć ten element górnego paska?"
},
"metaTags": {
"title": "Meta Tagi",
"description": "Zarządzaj meta tagami SEO dla strony głównej",
"projectsPageTitle": "Ustawienia",
"projectsPageDescription": "Zarządzaj meta tagami SEO dla strony projektów",
"skillsPageTitle": "Ustawienia",
"skillsPageDescription": "Zarządzaj meta tagami SEO dla strony umiejętności",
"contactPageTitle": "Ustawienia",
"contactPageDescription": "Zarządzaj meta tagami SEO dla strony kontaktu",
"metaTitle": "Tytuł Meta",
"metaTitlePlaceholder": "Wprowadź tytuł strony dla wyszukiwarek...",
"metaDescription": "Opis Meta",
"metaDescriptionPlaceholder": "Wprowadź opis strony dla wyszukiwarek...",
"metaKeywords": "Słowa Kluczowe Meta",
"metaKeywordsPlaceholder": "Wprowadź słowa kluczowe oddzielone przecinkami...",
"ogImage": "Obraz Open Graph",
"ogImagePlaceholder": "Wprowadź adres URL obrazka do udostępniania w mediach społecznościowych..."
},
"blog": {
"title": "Blog",
"posts": "Artykuły",
"pageSettings": "Ustawienia Strony",
"postsManagement": "Zarządzanie Artykułami",
"pageMetaTags": "Meta Tagi Strony",
"newPost": "Nowy Artykuł",
"noPosts": "Brak dostępnych artykułów",
"searchPosts": "Szukaj artykułów...",
"deleteConfirm": "Czy na pewno chcesz usunąć ten artykuł?",
"published": "Opublikowany",
"draft": "Szkic",
"neverPublished": "Nigdy nie opublikowany",
"category": "Kategoria",
"noCategory": "Brak kategorii",
"status": "Status",
"publishedAt": "Data Publikacji",
"views": "Wyświetlenia",
"actions": "Akcje",
"view": "Zobacz",
"edit": "Edytuj",
"delete": "Usuń",
"basicInfo": "Podstawowe Informacje",
"titlePlaceholder": "Wprowadź tytuł artykułu...",
"slug": "Slug",
"slugPlaceholder": "tytul-mojego-artykulu",
"content": "Treść",
"contentPlaceholder": "Napisz treść artykułu...",
"excerpt": "Streszczenie",
"excerptPlaceholder": "Krótki opis artykułu...",
"publishSettings": "Ustawienia Publikacji",
"selectCategory": "Wybierz kategorię",
"publish": "Opublikuj",
"publishDate": "Data Publikacji",
"featured": "Artykuł Wyróżniony",
"createPost": "Utwórz Artykuł",
"postNotFound": "Artykuł nie został znaleziony",
"postInfo": "Informacje o Artykule",
"polishVersion": "Wersja Polska",
"englishVersion": "Wersja Angielska",
"noContent": "Brak dostępnej treści",
"editPost": "Edytuj Artykuł",
"updatePost": "Aktualizuj Artykuł",
"viewPost": "Zobacz Artykuł",
"categories": "Kategorie",
"categoriesManagement": "Zarządzanie Kategoriami",
"addCategory": "Dodaj Kategorię",
"editCategory": "Edytuj Kategorię",
"deleteCategory": "Usuń Kategorię",
"deleteCategoryConfirm": "Czy na pewno chcesz usunąć tę kategorię? Artykuły w tej kategorii pozostaną, ale bez przypisania do kategorii.",
"categoryNamePl": "Nazwa Kategorii (PL)",
"categoryNameEn": "Nazwa Kategorii (EN)",
"categorySlug": "Slug Kategorii",
"categoryDescriptionPl": "Opis (PL)",
"categoryDescriptionEn": "Opis (EN)",
"categoryActive": "Aktywna",
"iconName": "Nazwa Ikony",
"iconProvider": "Dostawca Ikony",
"order": "Kolejność",
"noCategories": "Nie znaleziono kategorii",
"subtitle": "Artykuły o programowaniu, technologii i rozwoju oprogramowania",
"searchPlaceholder": "Szukaj artykułów...",
"allCategories": "Wszystkie",
"featuredArticles": "Wyróżnione artykuły",
"allArticles": "Wszystkie artykuły",
"noArticlesFound": "Nie znaleziono artykułów spełniających kryteria wyszukiwania.",
"relatedArticles": "Powiązane artykuły",
"backToBlog": "Powrót do bloga",
"articleNotFound": "Artykuł nie znaleziony",
"readMore": "Czytaj więcej",
"rssFeed": "Kanał RSS"
},
"comments": {
"title": "Komentarze",
"description": "Zarządzaj komentarzami na blogu - zatwierdzaj, odrzucaj i usuwaj komentarze",
"searchPlaceholder": "Szukaj po autorze, treści lub tytule artykułu...",
"statusUpdated": "Status komentarza został zaktualizowany",
"updateError": "Wystąpił błąd podczas aktualizacji statusu komentarza",
"deleted": "Komentarz został usunięty",
"deleteError": "Wystąpił błąd podczas usuwania komentarza",
"confirmDelete": "Czy na pewno chcesz usunąć ten komentarz? Ta akcja jest nieodwracalna.",
"deleteTitle": "Usuwanie komentarza",
"deleteMessage": "Czy na pewno chcesz usunąć komentarz od {authorName}? Ta akcja jest nieodwracalna.",
"pending": "Oczekujące",
"approved": "Zatwierdzone",
"total": "Łącznie",
"all": "Wszystkie",
"statusPending": "Oczekujący",
"statusApproved": "Zatwierdzony",
"approve": "Zatwierdź",
"reject": "Odrzuć",
"delete": "Usuń",
"article": "Artykuł:",
"replyToComment": "Odpowiedź na komentarz",
"replyTo": "Odpowiedź na:",
"noCommentsFound": "Nie znaleziono komentarzy pasujących do wyszukiwania",
"noComments": "Brak komentarzy",
"previous": "Poprzednia",
"next": "Następna",
"addComment": "Dodaj komentarz",
"reply": "Odpowiedź",
"replyButton": "Odpowiedz",
"replyForm": "Odpowiedz na komentarz",
"moderationNotice": "Twój komentarz zostanie opublikowany po zatwierdzeniu przez moderatora.",
"loadError": "Nie można załadować komentarzy - nieprawidłowy identyfikator artykułu."
}
},
"common": {
"loading": "Ładowanie...",
"connected": "Połączono",
"disconnected": "Rozłączono",
"checking": "Sprawdzanie...",
"save": "Zapisz",
"saving": "Zapisywanie...",
"saved": "Zmiany zostały zapisane!",
"add": "Dodaj",
"addImage": "Dodaj obraz",
"edit": "Edytuj",
"update": "Aktualizuj",
"delete": "Usuń",
"cancel": "Anuluj",
"actions": "Akcje",
"yes": "Tak",
"no": "Nie",
"updating": "Aktualizowanie...",
"updated": "Zaktualizowano pomyślnie",
"deleting": "Usuwanie...",
"deleted": "Usunięto pomyślnie",
"select": "Wybierz",
"search": "Szukaj",
"changeOrder": "Tryb zmiany kolejności",
"back": "Wstecz",
"untitled": "Bez tytułu",
"draft": "Szkic"
},
"fileUpload": {
"uploading": "Przesyłanie...",
"dragAndDrop": "Przeciągnij i upuść obraz tutaj, lub",
"chooseFile": "Wybierz plik",
"clear": "Wyczyść",
"selectOrUploadImage": "Wybierz lub prześlij obraz"
},
"welcome": "Witaj",
"login": {
"title": "Panel Administracyjny",
"email": "E-mail",
"password": "Hasło",
"signIn": "Zaloguj się",
"success": "Logowanie udane",
"submit": "Zaloguj się",
"noAccount": "Nie masz jeszcze konta?",
"register": "Zarejestruj się"
},
"register": {
"title": "Rejestracja",
"success": "Rejestracja udana",
"submit": "Zarejestruj się",
"haveAccount": "Masz już konto?",
"signIn": "Zaloguj się",
"name": "Imię i nazwisko",
"login": "Logowanie"
},
"dashboard": {
"title": "Panel administracyjny",
"welcome": "Witaj ponownie",
"welcomeUser": "Witaj {name}",
"privateData": "Prywatne dane: {{message}}",
"nav": {
"homepage": "Strona główna",
"skills": "Umiejętności",
"projects": "Projekty",
"navigation": "Nawigacja",
"topbar": "Górny pasek",
"contact": "Kontakt",
"blog": "Blog",
"privacyPolicy": "Polityka Prywatności",
"newsletter": "Newsletter"
}
},
"navigation": {
"home": "Powrót do strony",
"dashboard": "Panel",
"settings": "Ustawienia",
"keyboardHint": "Możesz użyć klawiszy strzałkowych do nawigacji",
"menu": "Menu"
},
"theme": {
"toggle": "Przełącz motyw",
"light": "Jasny",
"dark": "Ciemny",
"system": "Systemowy"
},
"footer": {
"copyright": "Wszelkie prawa zastrzeżone"
},
"projects": {
"title": "Moje projekty",
"noProjects": "Brak projektów do wyświetlenia",
"github": "GitHub",
"gitea": "Gitea",
"demo": "Demo"
},
"skills": {
"title": "Umiejętności",
"noSkills": "Brak umiejętności w tej kategorii",
"noCategories": "Brak kategorii umiejętności"
},
"contact": {
"title": "Kontakt",
"directContact": "Dane kontaktowe",
"quickMessage": "Wyślij wiadomość",
"form": {
"name": "Imię i nazwisko",
"email": "Adres e-mail",
"message": "Wiadomość",
"send": "Wyślij wiadomość",
"success": "Wiadomość została wysłana pomyślnie!",
"error": "Wystąpił błąd podczas wysyłania wiadomości. Spróbuj ponownie."
}
},
"language": {
"switch": "Zmień język"
},
"userMenu": {
"signIn": "Zaloguj się",
"signOut": "Wyloguj się",
"myAccount": "Moje konto"
},
"home": {
"apiStatus": "Status API"
},
"validation": {
"required": "To pole jest wymagane",
"min": "To pole musi mieć co najmniej {min} znaków",
"max": "To pole musi mieć co najwyżej {max} znaków",
"minLength": "To pole musi mieć co najmniej {min} znaków",
"maxLength": "To pole musi mieć co najwyżej {max} znaków",
"minValue": "To pole musi mieć co najmniej {min} wartość",
"maxValue": "To pole musi mieć co najwyżej {max} wartość",
"invalid": "To pole jest nieprawidłowe",
"invalidUrl": "Wprowadź prawidłowy adres URL (http:// lub https://)",
"turnstileRequired": "Proszę ukończyć weryfikację bezpieczeństwa"
},
"blog": {
"title": "Blog",
"subtitle": "Artykuły o programowaniu, technologii i rozwoju oprogramowania",
"searchPlaceholder": "Szukaj artykułów...",
"allCategories": "Wszystkie",
"featuredArticles": "Wyróżnione artykuły",
"allArticles": "Wszystkie artykuły",
"noArticlesFound": "Nie znaleziono artykułów spełniających kryteria wyszukiwania.",
"relatedArticles": "Powiązane artykuły",
"backToBlog": "Powrót do bloga",
"articleNotFound": "Artykuł nie znaleziony",
"readMore": "Czytaj więcej",
"featured": "Wyróżniony",
"views": "wyświetleń",
"rssFeed": "Kanał RSS",
"share": {
"title": "Udostępnij artykuł",
"facebook": "Udostępnij na Facebook",
"twitter": "Udostępnij na Twitter",
"linkedin": "Udostępnij na LinkedIn",
"whatsapp": "Udostępnij przez WhatsApp",
"email": "Udostępnij przez e-mail",
"copyLink": "Skopiuj link",
"linkCopied": "Link skopiowany do schowka!"
}
},
"notFound": {
"title": "Strona nie znaleziona",
"description": "Szukana strona nie została znaleziona. Możliwe, że została przeniesiona lub usunięta.",
"homepage": "Strona główna"
},
"newsletter": {
"title": "Newsletter",
"description": "Otrzymuj powiadomienia o nowych artykułach",
"email": "Adres e-mail",
"emailPlaceholder": "twoj@email.com",
"language": "Język newslettera",
"gdprConsent": "Wyrażam zgodę na przetwarzanie danych w celu otrzymywania newslettera zgodnie z",
"gdprConsentCompact": "Wyrażam zgodę na przetwarzanie moich danych osobowych w celu otrzymywania newslettera.",
"gdprConsentError": "Musisz wyrazić zgodę na przetwarzanie danych",
"privacyPolicy": "polityką prywatności",
"subscribe": "Zapisz się do newslettera",
"subscribeShort": "Zapisz się",
"subscribing": "Zapisywanie...",
"successMessage": "Pomyślnie zapisano do newslettera!",
"errorMessage": "Wystąpił błąd podczas zapisywania",
"blogSectionTitle": "Zapisz się do newslettera",
"blogSectionDescription": "Otrzymuj powiadomienia o nowych artykułach bezpośrednio na swoją skrzynkę e-mail",
"articleSectionTitle": "Podobał Ci się artykuł?",
"articleSectionDescription": "Zapisz się do newslettera, aby nie przegapić kolejnych!",
"contentPlaceholders": {
"privacyPolicyPl": "Wprowadź treść polityki prywatności w języku polskim...",
"privacyPolicyEn": "Wprowadź treść polityki prywatności w języku angielskim..."
},
"languages": {
"polish": "Polski",
"english": "Angielski"
},
"admin": {
"title": "Zarządzanie Newsletterem",
"description": "Zarządzaj subskrypcjami newslettera i wysyłaj powiadomienia do subskrybentów",
"stats": {
"title": "Statystyki Newslettera",
"description": "Aktualna liczba subskrybentów według języka",
"polish": "Subskrybenci Polscy",
"english": "Subskrybenci Angielscy",
"total": "Wszyscy Subskrybenci",
"loading": "Ładowanie statystyk..."
},
"send": {
"title": "Wyślij Newsletter",
"description": "Wyślij newsletter do subskrybentów o opublikowanym artykule",
"selectArticle": "Wybierz Artykuł",
"articlePlaceholder": "Wybierz artykuł do wyróżnienia...",
"preview": "Podgląd Wybranego Artykułu:",
"polishTitle": "Tytuł Polski:",
"englishTitle": "Tytuł Angielski:",
"slug": "Slug:",
"status": "Status:",
"published": "Opublikowany",
"draft": "Szkic",
"notAvailable": "Niedostępne",
"sendToPolish": "Wyślij do Polskich Subskrybentów",
"sendToEnglish": "Wyślij do Angielskich Subskrybentów",
"confirmTitle": "Potwierdź Wysyłanie Newslettera",
"confirmDescription": "Ta akcja jest nieodwracalna. Newsletter zostanie wysłany natychmiast do wszystkich subskrybentów.",
"confirmDetails": {
"article": "Artykuł:",
"language": "Język:",
"recipients": "Odbiorcy:",
"subscribers": "subskrybentów"
},
"cancel": "Anuluj",
"sending": "Wysyłanie...",
"confirm": "Tak, Wyślij Newsletter",
"selectArticleFirst": "Najpierw wybierz artykuł",
"successMessage": "Newsletter wysłany do {count} {language} subskrybentów",
"polish": "polskich",
"english": "angielskich",
"unsubscribe": {
"title": "Wypisz się z newslettera",
"subtitle": "Przykro nam, że odchodzisz. Twoja opinia pomoże nam się poprawić.",
"emailAddress": "Adres email",
"emailPlaceholder": "twoj@email.com",
"reason": "Powód wypisania się (opcjonalnie)",
"reasons": {
"too_frequent": "Zbyt częste wiadomości",
"not_relevant": "Treści nie są dla mnie odpowiednie",
"never_subscribed": "Nigdy się nie zapisałem/am",
"poor_content": "Niska jakość treści",
"technical_issues": "Problemy techniczne",
"other": "Inny powód"
},
"feedback": "Dodatkowe uwagi",
"feedbackPlaceholder": "Powiedz nam więcej o swoich uwagach...",
"cancel": "Anuluj",
"unsubscribeAction": "Wypisz się",
"unsubscribing": "Wypisywanie...",
"emailRequired": "Adres email jest wymagany",
"successTitle": "Wypisano z newslettera",
"successMessage": "Zostałeś pomyślnie wypisany z newslettera BWitek.dev. Dziękujemy za opinię!",
"feedbackNote": "Twoja opinia pomaga nam lepiej dostosować nasze treści do potrzeb czytelników.",
"home": "Strona główna",
"blog": "Blog"
}
}
}
},
"comments": {
"title": "Komentarze",
"addComment": "Dodaj komentarz",
"reply": "Odpowiedź",
"replyButton": "Odpowiedz",
"replyForm": "Odpowiedz na komentarz",
"noComments": "Brak komentarzy. Bądź pierwszy!",
"moderationNotice": "Twój komentarz zostanie opublikowany po zatwierdzeniu przez moderatora.",
"loadError": "Nie można załadować komentarzy - nieprawidłowy identyfikator artykułu.",
"form": {
"name": "Imię i nazwisko",
"namePlaceholder": "Wprowadź swoje imię i nazwisko",
"email": "Adres e-mail",
"emailPlaceholder": "Wprowadź swój adres e-mail (nie będzie publiczny)",
"website": "Strona internetowa",
"websitePlaceholder": "https://twoja-strona.pl (opcjonalnie)",
"content": "Treść komentarza",
"contentPlaceholder": "Napisz swój komentarz...",
"submit": "Dodaj komentarz",
"success": "Komentarz został dodany i oczekuje na zatwierdzenie!",
"error": "Wystąpił błąd podczas dodawania komentarza. Spróbuj ponownie."
}
},
"richEditor": {
"toolbar": {
"bold": "Pogrubienie",
"italic": "Kursywa",
"strikethrough": "Przekreślenie",
"code": "Kod wbudowany",
"heading1": "Nagłówek 1",
"heading2": "Nagłówek 2",
"heading3": "Nagłówek 3",
"bulletList": "Lista punktowana",
"orderedList": "Lista numerowana",
"quote": "Cytat",
"addLink": "Dodaj link",
"addImage": "Dodaj obraz",
"addYoutube": "Dodaj wideo YouTube",
"addCodeBlock": "Dodaj blok kodu",
"undo": "Cofnij",
"redo": "Ponów"
},
"modals": {
"link": {
"title": "Dodaj Link",
"description": "Wprowadź URL strony do której chcesz linkować. Pozostaw puste aby usunąć link.",
"urlLabel": "URL",
"urlPlaceholder": "https://example.com",
"cancel": "Anuluj",
"addLink": "Dodaj Link",
"removeLink": "Usuń Link"
},
"youtube": {
"title": "Dodaj Video YouTube",
"description": "Wprowadź link do filmu YouTube w jednym z obsługiwanych formatów.",
"urlLabel": "URL YouTube",
"urlPlaceholder": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"supportedFormats": "Obsługiwane formaty:",
"formats": [
"https://www.youtube.com/watch?v=VIDEO_ID",
"https://youtu.be/VIDEO_ID",
"https://www.youtube.com/embed/VIDEO_ID"
],
"invalidUrl": "Nieprawidłowy format URL YouTube",
"cancel": "Anuluj",
"addVideo": "Dodaj Video"
},
"codeBlock": {
"title": "Dodaj Blok Kodu",
"description": "Wprowadź język programowania dla podświetlania składni (opcjonalnie).",
"languageLabel": "Język programowania",
"languagePlaceholder": "javascript, python, css...",
"popularLanguages": "Popularne języki:",
"languagesList": "javascript, typescript, python, css, html, json, sql, bash, php, java, cpp",
"noHighlighting": "Pozostaw puste dla zwykłego tekstu bez podświetlania.",
"cancel": "Anuluj",
"addCodeBlock": "Dodaj Blok Kodu"
},
"image": {
"title": "Dodaj Obraz",
"description": "Wybierz lub wgraj nowy obraz do artykułu.",
"cancel": "Anuluj"
}
}
}
}

View File

@@ -0,0 +1,239 @@
{
"dashboard": {
"title": "Panel Administracyjny",
"description": "Witaj w panelu administracyjnym. Wybierz sekcję, którą chcesz edytować."
},
"homepage": {
"title": "Strona Główna",
"description": "Edytuj zawartość strony głównej",
"welcomeText": "Tekst powitalny",
"welcomePlaceholder": "Wprowadź tekst powitalny...",
"specializationText": "Specjalizacja",
"specializationPlaceholder": "Wprowadź tekst o specjalizacji...",
"aboutMeText": "O mnie",
"aboutMePlaceholder": "Wprowadź tekst o sobie...",
"updateSuccess": "Pomyślnie zaktualizowano stronę główną"
},
"skills": {
"title": "Umiejętności",
"addNew": "Dodaj umiejętność",
"addNewTitle": "Dodaj nową umiejętność",
"editTitle": "Edytuj umiejętność",
"namePl": "Nazwa (PL)",
"nameEn": "Nazwa (EN)",
"categoryPl": "Kategoria (PL)",
"categoryEn": "Kategoria (EN)",
"category": "Kategoria",
"selectCategory": "Wybierz kategorię",
"categoriesTitle": "Kategorie umiejętności",
"addNewCategory": "Dodaj kategorię",
"addNewCategoryTitle": "Dodaj nową kategorię",
"editCategoryTitle": "Edytuj kategorię",
"deleteCategoryTitle": "Usuwanie kategorii",
"deleteCategoryConfirm": "Czy na pewno chcesz usunąć tę kategorię? Umiejętności przypisane do tej kategorii nie będą przypisane do żadnej kategorii.",
"icon": "Ikona",
"iconName": "Nazwa ikony",
"iconProvider": "Dostawca ikony",
"order": "Kolejność",
"active": "Aktywny",
"deleteTitle": "Usuwanie umiejętności",
"deleteConfirm": "Czy na pewno chcesz usunąć tę umiejętność?"
},
"projects": {
"title": "Projekty",
"addNew": "Dodaj projekt",
"addNewTitle": "Dodaj nowy projekt",
"editTitle": "Edytuj projekt",
"titlePl": "Tytuł (PL)",
"titleEn": "Tytuł (EN)",
"descriptionPl": "Opis (PL)",
"descriptionEn": "Opis (EN)",
"url": "Adres URL projektu",
"repoUrl": "Adres URL repozytorium (GitHub)",
"repoUrl2": "Adres URL repozytorium (Gitea)",
"imageUrl": "Adres URL obrazka",
"order": "Kolejność",
"active": "Aktywny",
"deleteTitle": "Usuwanie projektu",
"deleteConfirm": "Czy na pewno chcesz usunąć ten projekt?"
},
"contact": {
"title": "Kontakt",
"addNew": "Dodaj element kontaktu",
"addNewTitle": "Dodaj nowy element kontaktu",
"editTitle": "Edytuj element kontaktu",
"namePl": "Nazwa (PL)",
"nameEn": "Nazwa (EN)",
"iconName": "Nazwa ikony",
"iconProvider": "Dostawca ikony",
"url": "URL",
"external": "Link zewnętrzny",
"newTab": "Otwórz w nowej karcie",
"order": "Kolejność",
"deleteTitle": "Usuwanie elementu kontaktu",
"deleteConfirm": "Czy na pewno chcesz usunąć ten element kontaktu?"
},
"navigation": {
"title": "Nawigacja",
"addNew": "Dodaj element nawigacji",
"addNewTitle": "Dodaj nowy element nawigacji",
"editTitle": "Edytuj element nawigacji",
"labelPl": "Etykieta (PL)",
"labelEn": "Etykieta (EN)",
"url": "URL",
"external": "Link zewnętrzny",
"newTab": "Otwórz w nowej karcie",
"active": "Aktywny",
"order": "Kolejność",
"deleteTitle": "Usuwanie elementu nawigacji",
"deleteConfirm": "Czy na pewno chcesz usunąć ten element nawigacji?"
},
"topBar": {
"title": "Górny pasek",
"addNew": "Dodaj element górnego paska",
"addNewTitle": "Dodaj nowy element górnego paska",
"editTitle": "Edytuj element górnego paska",
"namePl": "Nazwa (PL)",
"nameEn": "Nazwa (EN)",
"iconName": "Nazwa ikony",
"iconProvider": "Dostawca ikony",
"url": "URL",
"external": "Link zewnętrzny",
"newTab": "Otwórz w nowej karcie",
"order": "Kolejność",
"deleteTitle": "Usuwanie elementu górnego paska",
"deleteConfirm": "Czy na pewno chcesz usunąć ten element górnego paska?"
},
"metaTags": {
"title": "Meta Tagi",
"description": "Zarządzaj meta tagami SEO dla strony głównej",
"projectsPageTitle": "Ustawienia",
"projectsPageDescription": "Zarządzaj meta tagami SEO dla strony projektów",
"skillsPageTitle": "Ustawienia",
"skillsPageDescription": "Zarządzaj meta tagami SEO dla strony umiejętności",
"contactPageTitle": "Ustawienia",
"contactPageDescription": "Zarządzaj meta tagami SEO dla strony kontaktu",
"metaTitle": "Tytuł Meta",
"metaTitlePlaceholder": "Wprowadź tytuł strony dla wyszukiwarek...",
"metaDescription": "Opis Meta",
"metaDescriptionPlaceholder": "Wprowadź opis strony dla wyszukiwarek...",
"metaKeywords": "Słowa Kluczowe Meta",
"metaKeywordsPlaceholder": "Wprowadź słowa kluczowe oddzielone przecinkami...",
"ogImage": "Obraz Open Graph",
"ogImagePlaceholder": "Wprowadź adres URL obrazka do udostępniania w mediach społecznościowych..."
},
"blog": {
"title": "Blog",
"posts": "Artykuły",
"pageSettings": "Ustawienia Strony",
"postsManagement": "Zarządzanie Artykułami",
"pageMetaTags": "Meta Tagi Strony",
"newPost": "Nowy Artykuł",
"noPosts": "Brak dostępnych artykułów",
"searchPosts": "Szukaj artykułów...",
"deleteConfirm": "Czy na pewno chcesz usunąć ten artykuł?",
"published": "Opublikowany",
"draft": "Szkic",
"neverPublished": "Nigdy nie opublikowany",
"category": "Kategoria",
"noCategory": "Brak kategorii",
"status": "Status",
"publishedAt": "Data Publikacji",
"views": "Wyświetlenia",
"actions": "Akcje",
"view": "Zobacz",
"edit": "Edytuj",
"delete": "Usuń",
"basicInfo": "Podstawowe Informacje",
"titlePlaceholder": "Wprowadź tytuł artykułu...",
"slug": "Slug",
"slugPlaceholder": "tytul-mojego-artykulu",
"content": "Treść",
"contentPlaceholder": "Napisz treść artykułu...",
"excerpt": "Streszczenie",
"excerptPlaceholder": "Krótki opis artykułu...",
"publishSettings": "Ustawienia Publikacji",
"selectCategory": "Wybierz kategorię",
"publish": "Opublikuj",
"publishDate": "Data Publikacji",
"featured": "Artykuł Wyróżniony",
"createPost": "Utwórz Artykuł",
"postNotFound": "Artykuł nie został znaleziony",
"postInfo": "Informacje o Artykule",
"polishVersion": "Wersja Polska",
"englishVersion": "Wersja Angielska",
"noContent": "Brak dostępnej treści",
"editPost": "Edytuj Artykuł",
"updatePost": "Aktualizuj Artykuł",
"viewPost": "Zobacz Artykuł",
"categories": "Kategorie",
"categoriesManagement": "Zarządzanie Kategoriami",
"addCategory": "Dodaj Kategorię",
"editCategory": "Edytuj Kategorię",
"deleteCategory": "Usuń Kategorię",
"deleteCategoryConfirm": "Czy na pewno chcesz usunąć tę kategorię? Artykuły w tej kategorii pozostaną, ale bez przypisania do kategorii.",
"categoryNamePl": "Nazwa Kategorii (PL)",
"categoryNameEn": "Nazwa Kategorii (EN)",
"categorySlug": "Slug Kategorii",
"categoryDescriptionPl": "Opis (PL)",
"categoryDescriptionEn": "Opis (EN)",
"categoryActive": "Aktywna",
"iconName": "Nazwa Ikony",
"iconProvider": "Dostawca Ikony",
"order": "Kolejność",
"noCategories": "Nie znaleziono kategorii",
"subtitle": "Artykuły o programowaniu, technologii i rozwoju oprogramowania",
"searchPlaceholder": "Szukaj artykułów...",
"allCategories": "Wszystkie",
"featuredArticles": "Wyróżnione artykuły",
"allArticles": "Wszystkie artykuły",
"noArticlesFound": "Nie znaleziono artykułów spełniających kryteria wyszukiwania.",
"relatedArticles": "Powiązane artykuły",
"backToBlog": "Powrót do bloga",
"articleNotFound": "Artykuł nie znaleziony",
"readMore": "Czytaj więcej",
"rssFeed": "Kanał RSS"
},
"comments": {
"title": "Komentarze",
"description": "Zarządzaj komentarzami na blogu - zatwierdzaj, odrzucaj i usuwaj komentarze",
"searchPlaceholder": "Szukaj po autorze, treści lub tytule artykułu...",
"statusUpdated": "Status komentarza został zaktualizowany",
"updateError": "Wystąpił błąd podczas aktualizacji statusu komentarza",
"deleted": "Komentarz został usunięty",
"deleteError": "Wystąpił błąd podczas usuwania komentarza",
"confirmDelete": "Czy na pewno chcesz usunąć ten komentarz? Ta akcja jest nieodwracalna.",
"deleteTitle": "Usuwanie komentarza",
"deleteMessage": "Czy na pewno chcesz usunąć komentarz od {authorName}? Ta akcja jest nieodwracalna.",
"pending": "Oczekujące",
"approved": "Zatwierdzone",
"total": "Łącznie",
"all": "Wszystkie",
"statusPending": "Oczekujący",
"statusApproved": "Zatwierdzony",
"approve": "Zatwierdź",
"reject": "Odrzuć",
"delete": "Usuń",
"article": "Artykuł:",
"replyToComment": "Odpowiedź na komentarz",
"replyTo": "Odpowiedź na:",
"noCommentsFound": "Nie znaleziono komentarzy pasujących do wyszukiwania",
"noComments": "Brak komentarzy",
"previous": "Poprzednia",
"next": "Następna",
"addComment": "Dodaj komentarz",
"reply": "Odpowiedź",
"replyButton": "Odpowiedz",
"replyForm": "Odpowiedz na komentarz",
"moderationNotice": "Twój komentarz zostanie opublikowany po zatwierdzeniu przez moderatora.",
"loadError": "Nie można załadować komentarzy - nieprawidłowy identyfikator artykułu."
},
"privacy-policy": {
"title": "Polityka Prywatności",
"metaTags": "Ustawienia",
"metaTagsDescription": "Zarządzaj meta tagami SEO dla strony polityki prywatności",
"plContent": "Polska treść",
"enContent": "Angielska treść",
"content": "Treść Polityki Prywatności"
}
}

View File

@@ -0,0 +1,26 @@
{
"title": "Blog",
"subtitle": "Artykuły o programowaniu, technologii i rozwoju oprogramowania",
"searchPlaceholder": "Szukaj artykułów...",
"allCategories": "Wszystkie",
"featuredArticles": "Wyróżnione artykuły",
"allArticles": "Wszystkie artykuły",
"noArticlesFound": "Nie znaleziono artykułów spełniających kryteria wyszukiwania.",
"relatedArticles": "Powiązane artykuły",
"backToBlog": "Powrót do bloga",
"articleNotFound": "Artykuł nie znaleziony",
"readMore": "Czytaj więcej",
"featured": "Wyróżniony",
"views": "wyświetleń",
"rssFeed": "Kanał RSS",
"share": {
"title": "Udostępnij artykuł",
"facebook": "Udostępnij na Facebook",
"twitter": "Udostępnij na Twitter",
"linkedin": "Udostępnij na LinkedIn",
"whatsapp": "Udostępnij przez WhatsApp",
"email": "Udostępnij przez e-mail",
"copyLink": "Skopiuj link",
"linkCopied": "Link skopiowany do schowka!"
}
}

View File

@@ -0,0 +1,23 @@
{
"title": "Komentarze",
"addComment": "Dodaj komentarz",
"reply": "Odpowiedź",
"replyButton": "Odpowiedz",
"replyForm": "Odpowiedz na komentarz",
"noComments": "Brak komentarzy. Bądź pierwszy!",
"moderationNotice": "Twój komentarz zostanie opublikowany po zatwierdzeniu przez moderatora.",
"loadError": "Nie można załadować komentarzy - nieprawidłowy identyfikator artykułu.",
"form": {
"name": "Imię i nazwisko",
"namePlaceholder": "Wprowadź swoje imię i nazwisko",
"email": "Adres e-mail",
"emailPlaceholder": "Wprowadź swój adres e-mail (nie będzie publiczny)",
"website": "Strona internetowa",
"websitePlaceholder": "https://twoja-strona.pl (opcjonalnie)",
"content": "Treść komentarza",
"contentPlaceholder": "Napisz swój komentarz...",
"submit": "Dodaj komentarz",
"success": "Komentarz został dodany i oczekuje na zatwierdzenie!",
"error": "Wystąpił błąd podczas dodawania komentarza. Spróbuj ponownie."
}
}

View File

@@ -0,0 +1,33 @@
{
"welcome": "Witaj",
"loading": "Ładowanie...",
"connected": "Połączono",
"disconnected": "Rozłączono",
"checking": "Sprawdzanie...",
"save": "Zapisz",
"saving": "Zapisywanie...",
"saved": "Zmiany zostały zapisane!",
"submitting": "Wysyłanie...",
"add": "Dodaj",
"addImage": "Dodaj obraz",
"edit": "Edytuj",
"update": "Aktualizuj",
"delete": "Usuń",
"cancel": "Anuluj",
"actions": "Akcje",
"yes": "Tak",
"no": "Nie",
"updating": "Aktualizowanie...",
"updated": "Zaktualizowano pomyślnie",
"deleting": "Usuwanie...",
"deleted": "Usunięto pomyślnie",
"select": "Wybierz",
"search": "Szukaj",
"changeOrder": "Tryb zmiany kolejności",
"back": "Wstecz",
"untitled": "Bez tytułu",
"draft": "Szkic",
"showMore": "Pokaż więcej",
"noResults": "Nie znaleziono wyników",
"noData": "Brak danych"
}

View File

@@ -0,0 +1,107 @@
{
"fileUpload": {
"uploading": "Przesyłanie...",
"dragAndDrop": "Przeciągnij i upuść obraz tutaj, lub",
"chooseFile": "Wybierz plik",
"clear": "Wyczyść",
"selectOrUploadImage": "Wybierz lub prześlij obraz"
},
"richEditor": {
"toolbar": {
"bold": "Pogrubienie",
"italic": "Kursywa",
"strikethrough": "Przekreślenie",
"code": "Kod wbudowany",
"heading1": "Nagłówek 1",
"heading2": "Nagłówek 2",
"heading3": "Nagłówek 3",
"bulletList": "Lista punktowana",
"orderedList": "Lista numerowana",
"quote": "Cytat",
"addLink": "Dodaj link",
"addImage": "Dodaj obraz",
"addYoutube": "Dodaj wideo YouTube",
"addCodeBlock": "Dodaj blok kodu",
"undo": "Cofnij",
"redo": "Ponów"
},
"modals": {
"link": {
"title": "Dodaj Link",
"description": "Wprowadź URL strony do której chcesz linkować. Pozostaw puste aby usunąć link.",
"urlLabel": "URL",
"urlPlaceholder": "https://example.com",
"cancel": "Anuluj",
"addLink": "Dodaj Link",
"removeLink": "Usuń Link"
},
"youtube": {
"title": "Dodaj Video YouTube",
"description": "Wprowadź link do filmu YouTube w jednym z obsługiwanych formatów.",
"urlLabel": "URL YouTube",
"urlPlaceholder": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"supportedFormats": "Obsługiwane formaty:",
"formats": [
"https://www.youtube.com/watch?v=VIDEO_ID",
"https://youtu.be/VIDEO_ID",
"https://www.youtube.com/embed/VIDEO_ID"
],
"invalidUrl": "Nieprawidłowy format URL YouTube",
"cancel": "Anuluj",
"addVideo": "Dodaj Video"
},
"codeBlock": {
"title": "Dodaj Blok Kodu",
"description": "Wprowadź język programowania dla podświetlania składni (opcjonalnie).",
"languageLabel": "Język programowania",
"languagePlaceholder": "javascript, python, css...",
"popularLanguages": "Popularne języki:",
"languagesList": "javascript, typescript, python, css, html, json, sql, bash, php, java, cpp",
"noHighlighting": "Pozostaw puste dla zwykłego tekstu bez podświetlania.",
"cancel": "Anuluj",
"addCodeBlock": "Dodaj Blok Kodu"
},
"image": {
"title": "Dodaj Obraz",
"description": "Wybierz lub wgraj nowy obraz do artykułu.",
"cancel": "Anuluj"
}
}
},
"language": {
"switch": "Zmień język"
},
"userMenu": {
"signIn": "Zaloguj się",
"signOut": "Wyloguj się",
"myAccount": "Moje konto"
},
"theme": {
"toggle": "Przełącz motyw",
"light": "Jasny",
"dark": "Ciemny",
"system": "Systemowy"
},
"navigation": {
"home": "Powrót do strony",
"dashboard": "Panel",
"settings": "Ustawienia",
"keyboardHint": "Możesz użyć klawiszy strzałkowych do nawigacji",
"menu": "Menu"
},
"footer": {
"copyright": "Wszelkie prawa zastrzeżone"
},
"validation": {
"required": "To pole jest wymagane",
"min": "To pole musi mieć co najmniej {min} znaków",
"max": "To pole musi mieć co najwyżej {max} znaków",
"minLength": "To pole musi mieć co najmniej {min} znaków",
"maxLength": "To pole musi mieć co najwyżej {max} znaków",
"minValue": "To pole musi mieć co najmniej {min} wartość",
"maxValue": "To pole musi mieć co najwyżej {max} wartość",
"invalid": "To pole jest nieprawidłowe",
"invalidUrl": "Wprowadź prawidłowy adres URL (http:// lub https://)",
"turnstileRequired": "Proszę ukończyć weryfikację bezpieczeństwa"
}
}

View File

@@ -0,0 +1,97 @@
{
"title": "Newsletter",
"description": "Otrzymuj powiadomienia o nowych artykułach",
"email": "Adres e-mail",
"emailPlaceholder": "twoj@email.com",
"language": "Język newslettera",
"gdprConsent": "Wyrażam zgodę na przetwarzanie danych w celu otrzymywania newslettera zgodnie z",
"gdprConsentCompact": "Wyrażam zgodę na przetwarzanie moich danych osobowych w celu otrzymywania newslettera.",
"gdprConsentError": "Musisz wyrazić zgodę na przetwarzanie danych",
"privacyPolicy": "polityką prywatności",
"subscribe": "Zapisz się do newslettera",
"subscribeShort": "Zapisz się",
"subscribing": "Zapisywanie...",
"successMessage": "Pomyślnie zapisano do newslettera!",
"errorMessage": "Wystąpił błąd podczas zapisywania",
"blogSectionTitle": "Zapisz się do newslettera",
"blogSectionDescription": "Otrzymuj powiadomienia o nowych artykułach bezpośrednio na swoją skrzynkę e-mail",
"articleSectionTitle": "Podobał Ci się artykuł?",
"articleSectionDescription": "Zapisz się do newslettera, aby nie przegapić kolejnych!",
"contentPlaceholders": {
"privacyPolicyPl": "Wprowadź treść polityki prywatności w języku polskim...",
"privacyPolicyEn": "Wprowadź treść polityki prywatności w języku angielskim..."
},
"languages": {
"polish": "Polski",
"english": "Angielski"
},
"admin": {
"title": "Zarządzanie Newsletterem",
"description": "Zarządzaj subskrypcjami newslettera i wysyłaj powiadomienia do subskrybentów",
"stats": {
"title": "Statystyki Newslettera",
"description": "Aktualna liczba subskrybentów według języka",
"polish": "Subskrybenci Polscy",
"english": "Subskrybenci Angielscy",
"total": "Wszyscy Subskrybenci",
"loading": "Ładowanie statystyk..."
},
"send": {
"title": "Wyślij Newsletter",
"description": "Wyślij newsletter do subskrybentów o opublikowanym artykule",
"selectArticle": "Wybierz Artykuł",
"articlePlaceholder": "Wybierz artykuł do wyróżnienia...",
"preview": "Podgląd Wybranego Artykułu:",
"polishTitle": "Tytuł Polski:",
"englishTitle": "Tytuł Angielski:",
"slug": "Slug:",
"status": "Status:",
"published": "Opublikowany",
"draft": "Szkic",
"notAvailable": "Niedostępne",
"sendToPolish": "Wyślij do Polskich Subskrybentów",
"sendToEnglish": "Wyślij do Angielskich Subskrybentów",
"confirmTitle": "Potwierdź Wysyłanie Newslettera",
"confirmDescription": "Ta akcja jest nieodwracalna. Newsletter zostanie wysłany natychmiast do wszystkich subskrybentów.",
"confirmDetails": {
"article": "Artykuł:",
"language": "Język:",
"recipients": "Odbiorcy:",
"subscribers": "subskrybentów"
},
"cancel": "Anuluj",
"sending": "Wysyłanie...",
"confirm": "Tak, Wyślij Newsletter",
"selectArticleFirst": "Najpierw wybierz artykuł",
"successMessage": "Newsletter wysłany do {count} {language} subskrybentów",
"polish": "polskich",
"english": "angielskich",
"unsubscribe": {
"title": "Wypisz się z newslettera",
"subtitle": "Przykro nam, że odchodzisz. Twoja opinia pomoże nam się poprawić.",
"emailAddress": "Adres email",
"emailPlaceholder": "twoj@email.com",
"reason": "Powód wypisania się (opcjonalnie)",
"reasons": {
"too_frequent": "Zbyt częste wiadomości",
"not_relevant": "Treści nie są dla mnie odpowiednie",
"never_subscribed": "Nigdy się nie zapisałem/am",
"poor_content": "Niska jakość treści",
"technical_issues": "Problemy techniczne",
"other": "Inny powód"
},
"feedback": "Dodatkowe uwagi",
"feedbackPlaceholder": "Powiedz nam więcej o swoich uwagach...",
"cancel": "Anuluj",
"unsubscribeAction": "Wypisz się",
"unsubscribing": "Wypisywanie...",
"emailRequired": "Adres email jest wymagany",
"successTitle": "Wypisano z newslettera",
"successMessage": "Zostałeś pomyślnie wypisany z newslettera BWitek.dev. Dziękujemy za opinię!",
"feedbackNote": "Twoja opinia pomaga nam lepiej dostosować nasze treści do potrzeb czytelników.",
"home": "Strona główna",
"blog": "Blog"
}
}
}
}

View File

@@ -0,0 +1,68 @@
{
"login": {
"title": "Panel Administracyjny",
"email": "E-mail",
"password": "Hasło",
"signIn": "Zaloguj się",
"success": "Logowanie udane",
"submit": "Zaloguj się",
"noAccount": "Nie masz jeszcze konta?",
"register": "Zarejestruj się"
},
"register": {
"title": "Rejestracja",
"success": "Rejestracja udana",
"submit": "Zarejestruj się",
"haveAccount": "Masz już konto?",
"signIn": "Zaloguj się",
"name": "Imię i nazwisko",
"login": "Logowanie"
},
"dashboard": {
"title": "Panel administracyjny",
"welcome": "Witaj ponownie",
"welcomeUser": "Witaj {name}",
"privateData": "Prywatne dane: {{message}}",
"nav": {
"homepage": "Strona główna",
"skills": "Umiejętności",
"projects": "Projekty",
"navigation": "Nawigacja",
"topbar": "Górny pasek",
"contact": "Kontakt",
"blog": "Blog",
"privacyPolicy": "Polityka Prywatności",
"newsletter": "Newsletter"
}
},
"projects": {
"title": "Moje projekty",
"noProjects": "Brak projektów do wyświetlenia",
"github": "GitHub",
"gitea": "Gitea",
"demo": "Demo"
},
"skills": {
"title": "Umiejętności",
"noSkills": "Brak umiejętności w tej kategorii",
"noCategories": "Brak kategorii umiejętności"
},
"contact": {
"title": "Kontakt",
"directContact": "Dane kontaktowe",
"quickMessage": "Wyślij wiadomość",
"form": {
"name": "Imię i nazwisko",
"email": "Adres e-mail",
"message": "Wiadomość",
"send": "Wyślij wiadomość",
"success": "Wiadomość została wysłana pomyślnie!",
"error": "Wystąpił błąd podczas wysyłania wiadomości. Spróbuj ponownie."
}
},
"notFound": {
"title": "Strona nie znaleziona",
"description": "Szukana strona nie została znaleziona. Możliwe, że została przeniesiona lub usunięta.",
"homepage": "Strona główna"
}
}

View File

@@ -11,6 +11,7 @@
"dependencies": {
"@hookform/resolvers": "^5.1.0",
"@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",

View File

@@ -145,7 +145,7 @@ export default function EditBlogPostPage() {
const handleSubmit = () => {
if (!formData.title.pl || !formData.title.en || !formData.slug) {
toast.error(t("validation.required"));
toast.error(t("components.validation.required"));
return;
}

View File

@@ -48,8 +48,8 @@ export default function ViewBlogPostPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center justify-between flex-col md:flex-row">
<div className="flex items-center gap-4 flex-col md:flex-row">
<Link href="/admin/blog">
<Button variant="outline" size="sm">
<Icon name="ArrowLeft" provider="lu" className="w-4 h-4 mr-2" />

View File

@@ -245,12 +245,12 @@ export default function CommentsAdminPage() {
{filteredComments.map((comment) => (
<Card key={comment.id} className="border border-gray-200 dark:border-gray-700">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex flex-col sm:flex-row items-start justify-between gap-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-red-100 dark:bg-red-900/20 rounded-full flex items-center justify-center">
<Icon name="User" provider="lu" className="text-red-600 dark:text-red-400" />
</div>
<div>
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900 dark:text-gray-100">
{comment.authorName}
@@ -259,7 +259,7 @@ export default function CommentsAdminPage() {
{comment.isApproved ? t('admin.comments.statusApproved') : t('admin.comments.statusPending')}
</Badge>
</div>
<div className="flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400 mt-1">
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 text-sm text-gray-500 dark:text-gray-400 mt-1">
<div className="flex items-center gap-1">
<Icon name="Mail" provider="lu" className="text-red-600 dark:text-red-400" />
{comment.authorEmail}
@@ -280,13 +280,13 @@ export default function CommentsAdminPage() {
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2 mt-2 sm:mt-0">
{!comment.isApproved && (
<Button
size="sm"
onClick={() => handleStatusUpdate(comment.id, true)}
disabled={updateStatusMutation.isPending}
className="bg-green-600 hover:bg-green-700 text-white"
className="bg-green-600 hover:bg-green-700 text-white w-full sm:w-auto"
>
<Icon name="Check" provider="lu" className="mr-1" />
{t('admin.comments.approve')}
@@ -299,6 +299,7 @@ export default function CommentsAdminPage() {
variant="outline"
onClick={() => handleStatusUpdate(comment.id, false)}
disabled={updateStatusMutation.isPending}
className="w-full sm:w-auto"
>
<Icon name="X" provider="lu" className="mr-1" />
{t('admin.comments.reject')}
@@ -310,6 +311,7 @@ export default function CommentsAdminPage() {
variant="destructive"
onClick={() => handleDeleteClick(comment.id, comment.authorName)}
disabled={deleteCommentMutation.isPending}
className="w-full sm:w-auto"
>
<Icon name="Trash" provider="lu" className="mr-1" />
{t('admin.comments.delete')}

View File

@@ -126,7 +126,7 @@ export default function NewBlogPostPage() {
const handleSubmit = () => {
if (!formData.title.pl || !formData.title.en || !formData.slug) {
toast.error(t("validation.required"));
toast.error(t("components.validation.required"));
return;
}

View File

@@ -16,7 +16,7 @@ import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { BlogCategoryForm } from "@/components/admin/blog-category-form";
import FileUpload from "@/components/admin/file-upload";
import { DataTable, type Column } from "@/components/admin/data-table";
import { ResponsiveDataTable, type Column } from "@/components/admin/data-table/index";
import DeleteConfirmationModal from "@/components/admin/delete-confirmation-modal";
type TranslatedField = {
@@ -249,35 +249,40 @@ export default function AdminBlogPage() {
header: t("admin.blog.categoryNamePl"),
render: (item) => item.name?.pl || "Unnamed",
sortable: true,
searchable: true
searchable: true,
priority: 'high'
},
{
key: "name.en",
header: t("admin.blog.categoryNameEn"),
render: (item) => item.name?.en || "Unnamed",
sortable: true,
searchable: true
searchable: true,
priority: 'medium'
},
{
key: "iconName",
header: t("admin.blog.iconName"),
render: (item) => item.iconName ? `${item.iconName} (${item.iconProvider})` : "-",
sortable: true,
searchable: false
searchable: false,
priority: 'medium'
},
{
key: "isActive",
header: t("admin.blog.categoryActive"),
render: (item) => item.isActive ? t("common.yes") : t("common.no"),
sortable: true,
searchable: false
searchable: false,
priority: 'low'
},
{
key: "order",
header: t("admin.blog.order"),
render: (item) => item.order,
sortable: true,
searchable: false
searchable: false,
priority: 'low'
}
];
@@ -302,6 +307,58 @@ export default function AdminBlogPage() {
});
};
const postColumns: Column[] = [
{
key: "title",
header: t("admin.blog.title"),
render: (item) => (
<div>
<div className="font-medium truncate max-w-[250px]" title={item.title?.pl || item.title?.en || t('common.untitled')}>
{item.title?.pl || item.title?.en || t('common.untitled')}
</div>
<div className="text-sm text-muted-foreground truncate max-w-[200px]" title={`Slug: ${item.slug}`}>
Slug: {item.slug}
</div>
</div>
),
sortable: true,
searchable: true,
priority: 'high'
},
{
key: "category",
header: t("admin.blog.category"),
render: (item) => item.category?.name?.pl || item.category?.name?.en || t("admin.blog.noCategory"),
sortable: true,
searchable: true,
priority: 'medium'
},
{
key: "status",
header: t("admin.blog.status"),
render: (item) => getStatusBadge(item.isPublished),
sortable: true,
searchable: false,
priority: 'medium'
},
{
key: "publishedAt",
header: t("admin.blog.publishedAt"),
render: (item) => formatDate(item.publishedAt),
sortable: true,
searchable: false,
priority: 'low'
},
{
key: "views",
header: t("admin.blog.views"),
render: (item) => item.viewCount || 0,
sortable: true,
searchable: false,
priority: 'low'
}
];
const renderPostsTable = () => {
if (!blogPosts.data || blogPosts.data.length === 0) {
return (
@@ -312,67 +369,40 @@ export default function AdminBlogPage() {
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("admin.blog.title")}</TableHead>
<TableHead>{t("admin.blog.category")}</TableHead>
<TableHead>{t("admin.blog.status")}</TableHead>
<TableHead>{t("admin.blog.publishedAt")}</TableHead>
<TableHead>{t("admin.blog.views")}</TableHead>
<TableHead>{t("admin.blog.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{blogPosts.data.map((post: any) => (
<TableRow key={post.id}>
<TableCell>
<div>
<div className="font-medium">{post.title?.pl || post.title?.en || t('common.untitled')}</div>
<div className="text-sm text-muted-foreground">Slug: {post.slug}</div>
</div>
</TableCell>
<TableCell>
{post.category?.name?.pl || post.category?.name?.en || t("admin.blog.noCategory")}
</TableCell>
<TableCell>
{getStatusBadge(post.isPublished)}
</TableCell>
<TableCell>
{formatDate(post.publishedAt)}
</TableCell>
<TableCell>
{post.viewCount || 0}
</TableCell>
<TableCell>
<div className="flex gap-2">
<Link href={`/admin/blog/${post.id}`}>
<Button variant="outline" size="sm">
<Icon name="Eye" provider="lu" className="w-4 h-4 mr-1" />
{t("admin.blog.view")}
</Button>
</Link>
<Link href={`/admin/blog/${post.id}/edit`}>
<Button variant="outline" size="sm">
<Icon name="Pencil" provider="lu" className="w-4 h-4 mr-1" />
{t("admin.blog.edit")}
</Button>
</Link>
<Button
variant="outline"
size="sm"
onClick={() => handleDeletePost(post.id, post.title?.pl || post.title?.en || t('common.untitled'))}
className="text-red-600 hover:text-red-700"
>
<Icon name="Trash2" provider="lu" className="w-4 h-4 mr-1" />
{t("admin.blog.delete")}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<ResponsiveDataTable
title=""
addButtonText=""
columns={postColumns}
data={blogPosts.data}
showAddButton={false}
showOrderButtons={false}
searchPlaceholder={t("admin.blog.searchPosts")}
customActions={(item: any) => (
<div className="flex gap-1 flex-wrap w-full justify-end">
<Link href={`/admin/blog/${item.id}`} className="flex-shrink-0">
<Button variant="outline" size="sm" className="w-full">
<Icon name="Eye" provider="lu" className="w-4 h-4 mr-1" />
{t("admin.blog.view")}
</Button>
</Link>
<Link href={`/admin/blog/${item.id}/edit`} className="flex-shrink-0">
<Button variant="outline" size="sm" className="w-full">
<Icon name="Pencil" provider="lu" className="w-4 h-4 mr-1" />
{t("admin.blog.edit")}
</Button>
</Link>
<Button
variant="outline"
size="sm"
onClick={() => handleDeletePost(item.id, item.title?.pl || item.title?.en || t('common.untitled'))}
className="text-red-600 hover:text-red-700 flex-shrink-0"
>
<Icon name="Trash2" provider="lu" className="w-4 h-4 mr-1" />
{t("admin.blog.delete")}
</Button>
</div>
)}
/>
);
};
@@ -413,7 +443,7 @@ export default function AdminBlogPage() {
<TabsContent value="categories">
<Card>
<CardContent className="p-6">
<DataTable
<ResponsiveDataTable
title={t("admin.blog.categoriesManagement")}
addButtonText={t("admin.blog.addCategory")}
columns={categoryColumns}

View File

@@ -10,7 +10,7 @@ import {useTranslations} from 'next-intl';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { useMutation } from "@tanstack/react-query";
import { DataTable, type Column } from "@/components/admin/data-table";
import { ResponsiveDataTable, type Column } from "@/components/admin/data-table/index";
import { ContactForm } from "@/components/admin/contact-form";
import { Icon } from '@/components/icon';
import FileUpload from "@/components/admin/file-upload";
@@ -213,29 +213,45 @@ export default function AdminPanelContact() {
{
key: "name.pl",
header: t("admin.contact.namePl"),
render: (item) => item.name.pl,
render: (item) => (
<div className="max-w-[180px] truncate" title={item.name.pl}>
{item.name.pl}
</div>
),
sortable: true,
searchable: true
searchable: true,
priority: 'high'
},
{
key: "name.en",
header: t("admin.contact.nameEn"),
render: (item) => item.name.en,
render: (item) => (
<div className="max-w-[180px] truncate" title={item.name.en}>
{item.name.en}
</div>
),
sortable: true,
searchable: true
searchable: true,
priority: 'medium'
},
{
key: "url",
header: t("admin.contact.url"),
render: (item) => item.url || "-",
render: (item) => (
<div className="max-w-[200px] truncate" title={item.url || "-"}>
{item.url || "-"}
</div>
),
sortable: true,
searchable: true
searchable: true,
priority: 'medium'
},
{
key: "order",
header: t("admin.contact.order"),
render: (item) => item.order,
sortable: true
sortable: true,
priority: 'low'
}
];
@@ -362,7 +378,7 @@ export default function AdminPanelContact() {
<Card>
<CardContent className="p-6">
<DataTable
<ResponsiveDataTable
title={t("admin.contact.title")}
addButtonText={t("admin.contact.addNew")}
columns={contactColumns}

View File

@@ -35,24 +35,24 @@ export default function AdminLayout({
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-bold">{t("dashboard.title")}</h1>
<h1 className="text-2xl font-bold">{t("pages.dashboard.title")}</h1>
<Sheet open={isMobileNavOpen} onOpenChange={setIsMobileNavOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="icon" className="lg:hidden">
<Icon name="Menu" provider="lu" className="h-5 w-5" />
<span className="sr-only">{t("navigation.menu")}</span>
<span className="sr-only">{t("components.navigation.menu")}</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[280px] p-0">
<SheetTitle className="sr-only">
{t("navigation.menu")}
{t("components.navigation.menu")}
</SheetTitle>
<AdminNav onNavItemClick={() => setIsMobileNavOpen(false)} isMobile />
</SheetContent>
</Sheet>
</div>
<p className="text-muted-foreground">
{t("dashboard.welcomeUser", { name: session?.user.name || "" })}
{t("pages.dashboard.welcomeUser", { name: session?.user.name || "" })}
</p>
</div>
@@ -60,8 +60,8 @@ export default function AdminLayout({
<div className="hidden lg:block w-64 flex-shrink-0">
<AdminNav />
</div>
<main className="flex-1 min-w-0 max-w-full overflow-x-auto">
<div className="max-w-6xl">
<main className="flex-1 min-w-0 overflow-hidden">
<div className="w-full max-w-full">
{children}
</div>
</main>

View File

@@ -6,7 +6,7 @@ import {useTranslations} from 'next-intl';
import { Card, CardContent } from "@/components/ui/card";
import { useMutation } from "@tanstack/react-query";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { DataTable, type Column } from "@/components/admin/data-table";
import { ResponsiveDataTable, type Column } from "@/components/admin/data-table/index";
import { NavigationForm } from "@/components/admin/navigation-form";
import { TopBarForm } from "@/components/admin/topbar-form";
@@ -224,35 +224,52 @@ export default function AdminPanelNavigation() {
{
key: "label.pl",
header: t("admin.navigation.labelPl"),
render: (item) => item.label.pl,
render: (item) => (
<div className="max-w-[180px] truncate" title={item.label.pl}>
{item.label.pl}
</div>
),
sortable: true,
searchable: true
searchable: true,
priority: 'high'
},
{
key: "label.en",
header: t("admin.navigation.labelEn"),
render: (item) => item.label.en,
render: (item) => (
<div className="max-w-[180px] truncate" title={item.label.en}>
{item.label.en}
</div>
),
sortable: true,
searchable: true
searchable: true,
priority: 'medium'
},
{
key: "url",
header: t("admin.navigation.url"),
render: (item) => item.url || "-",
render: (item) => (
<div className="max-w-[200px] truncate" title={item.url || "-"}>
{item.url || "-"}
</div>
),
sortable: true,
searchable: true
searchable: true,
priority: 'medium'
},
{
key: "isActive",
header: t("admin.navigation.active"),
render: (item) => item.isActive ? t("common.yes") : t("common.no"),
sortable: true
sortable: true,
priority: 'low'
},
{
key: "order",
header: t("admin.navigation.order"),
render: (item) => item.order,
sortable: true
sortable: true,
priority: 'low'
}
];
@@ -260,36 +277,53 @@ export default function AdminPanelNavigation() {
{
key: "name.pl",
header: t("admin.topBar.namePl"),
render: (item) => item.name.pl,
render: (item) => (
<div className="max-w-[180px] truncate" title={item.name.pl}>
{item.name.pl}
</div>
),
sortable: true,
searchable: true
searchable: true,
priority: 'high'
},
{
key: "name.en",
header: t("admin.topBar.nameEn"),
render: (item) => item.name.en,
render: (item) => (
<div className="max-w-[180px] truncate" title={item.name.en}>
{item.name.en}
</div>
),
sortable: true,
searchable: true
searchable: true,
priority: 'medium'
},
{
key: "iconName",
header: t("admin.topBar.iconName"),
render: (item) => item.iconName || "-",
sortable: true,
searchable: true
searchable: true,
priority: 'medium'
},
{
key: "url",
header: t("admin.topBar.url"),
render: (item) => item.url || "-",
render: (item) => (
<div className="max-w-[200px] truncate" title={item.url || "-"}>
{item.url || "-"}
</div>
),
sortable: true,
searchable: true
searchable: true,
priority: 'medium'
},
{
key: "order",
header: t("admin.topBar.order"),
render: (item) => item.order,
sortable: true
sortable: true,
priority: 'low'
}
];
@@ -307,7 +341,7 @@ export default function AdminPanelNavigation() {
</TabsList>
<TabsContent value="navigation">
<DataTable
<ResponsiveDataTable
title={t("admin.navigation.title")}
addButtonText={t("admin.navigation.addNew")}
columns={navigationColumns}
@@ -326,7 +360,7 @@ export default function AdminPanelNavigation() {
</TabsContent>
<TabsContent value="topbar">
<DataTable
<ResponsiveDataTable
title={t("admin.topBar.title")}
addButtonText={t("admin.topBar.addNew")}
columns={topBarColumns}

View File

@@ -169,8 +169,8 @@ export default function AdminPanelPrivacyPolicy() {
>
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold leading-none tracking-tight">Privacy Policy Page Meta Tags</h3>
<p className="text-sm text-muted-foreground mt-1">SEO settings for privacy policy page</p>
<h3 className="text-lg font-semibold leading-none tracking-tight">{t("admin.privacy-policy.metaTags")}</h3>
<p className="text-sm text-muted-foreground mt-1">{t("admin.privacy-policy.metaTagsDescription")}</p>
</div>
{isMetaExpanded ? (
<Icon name="ChevronUp" provider="lu" className="h-4 w-4" />
@@ -279,11 +279,11 @@ export default function AdminPanelPrivacyPolicy() {
<Card>
<CardHeader>
<CardTitle>Privacy Policy Content</CardTitle>
<CardTitle>{t("admin.privacy-policy.content")}</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-4">
<Label>Polish Content</Label>
<Label>{t("admin.privacy-policy.plContent")}</Label>
<RichTextEditor
content={privacyPolicyFormData.content.pl}
onChange={(content) => handlePrivacyPolicyFieldChange("pl", content)}
@@ -292,7 +292,7 @@ export default function AdminPanelPrivacyPolicy() {
</div>
<div className="space-y-4">
<Label>English Content</Label>
<Label>{t("admin.privacy-policy.enContent")}</Label>
<RichTextEditor
content={privacyPolicyFormData.content.en}
onChange={(content) => handlePrivacyPolicyFieldChange("en", content)}

View File

@@ -10,7 +10,7 @@ import {useTranslations} from 'next-intl';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { useMutation } from "@tanstack/react-query";
import { DataTable, type Column } from "@/components/admin/data-table";
import { ResponsiveDataTable, type Column } from "@/components/admin/data-table/index";
import { ProjectForm } from "@/components/admin/project-form";
import { Icon } from '@/components/icon';
import FileUpload from "@/components/admin/file-upload";
@@ -219,35 +219,52 @@ export default function AdminPanelProjects() {
{
key: "title.pl",
header: t("admin.projects.titlePl"),
render: (item) => item.title.pl,
render: (item) => (
<div className="max-w-[180px] truncate" title={item.title.pl}>
{item.title.pl}
</div>
),
sortable: true,
searchable: true
searchable: true,
priority: 'high'
},
{
key: "title.en",
header: t("admin.projects.titleEn"),
render: (item) => item.title.en,
render: (item) => (
<div className="max-w-[180px] truncate" title={item.title.en}>
{item.title.en}
</div>
),
sortable: true,
searchable: true
searchable: true,
priority: 'medium'
},
{
key: "url",
header: t("admin.projects.url"),
render: (item) => item.url || "-",
render: (item) => (
<div className="max-w-[200px] truncate" title={item.url || "-"}>
{item.url || "-"}
</div>
),
sortable: true,
searchable: true
searchable: true,
priority: 'medium'
},
{
key: "isActive",
header: t("admin.projects.active"),
render: (item) => item.isActive ? t("common.yes") : t("common.no"),
sortable: true
sortable: true,
priority: 'low'
},
{
key: "order",
header: t("admin.projects.order"),
render: (item) => item.order,
sortable: true
sortable: true,
priority: 'low'
}
];
@@ -374,7 +391,7 @@ export default function AdminPanelProjects() {
<Card>
<CardContent className="p-6">
<DataTable
<ResponsiveDataTable
title={t("admin.projects.title")}
addButtonText={t("admin.projects.addNew")}
columns={projectColumns}

View File

@@ -10,7 +10,7 @@ import {useTranslations} from 'next-intl';
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { useMutation } from "@tanstack/react-query";
import { DataTable, type Column } from "@/components/admin/data-table";
import { ResponsiveDataTable, type Column } from "@/components/admin/data-table/index";
import { SkillForm } from "@/components/admin/skill-form";
import { SkillCategoryForm } from "@/components/admin/skill-category-form";
import { Icon } from '@/components/icon';
@@ -308,20 +308,23 @@ export default function AdminPanelSkills() {
header: t("admin.skills.namePl"),
render: (item) => item.name.pl,
sortable: true,
searchable: true
searchable: true,
priority: 'high'
},
{
key: "name.en",
header: t("admin.skills.nameEn"),
render: (item) => item.name.en,
sortable: true,
searchable: true
searchable: true,
priority: 'medium'
},
{
key: "order",
header: t("admin.skills.order"),
render: (item) => item.order,
sortable: true
sortable: true,
priority: 'low'
}
];
@@ -331,21 +334,24 @@ export default function AdminPanelSkills() {
header: t("admin.skills.namePl"),
render: (item) => item.name.pl,
sortable: true,
searchable: true
searchable: true,
priority: 'high'
},
{
key: "name.en",
header: t("admin.skills.nameEn"),
render: (item) => item.name.en,
sortable: true,
searchable: true
searchable: true,
priority: 'medium'
},
{
key: "category",
header: t("admin.skills.category"),
render: (item) => item.category ? `${item.category.pl} / ${item.category.en}` : "-",
sortable: true,
searchable: true
searchable: true,
priority: 'medium'
},
{
key: "icon",
@@ -355,19 +361,22 @@ export default function AdminPanelSkills() {
return item.iconProvider ? `${item.iconName} (${item.iconProvider})` : item.iconName;
},
sortable: true,
searchable: true
searchable: true,
priority: 'low'
},
{
key: "isActive",
header: t("admin.skills.active"),
render: (item) => item.isActive ? t("common.yes") : t("common.no"),
sortable: true
sortable: true,
priority: 'medium'
},
{
key: "order",
header: t("admin.skills.order"),
render: (item) => item.order,
sortable: true
sortable: true,
priority: 'low'
}
];
@@ -496,7 +505,7 @@ export default function AdminPanelSkills() {
{/* Skills DataTables */}
<Card>
<CardContent className="p-6">
<DataTable
<ResponsiveDataTable
title={t("admin.skills.categoriesTitle")}
addButtonText={t("admin.skills.addNewCategory")}
columns={categoryColumns}
@@ -513,7 +522,7 @@ export default function AdminPanelSkills() {
showOrderButtons={true}
/>
<DataTable
<ResponsiveDataTable
title={t("admin.skills.title")}
addButtonText={t("admin.skills.addNew")}
columns={skillColumns}

View File

@@ -7,10 +7,20 @@ import { trpc } from "@/utils/trpc";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import MainLayout from "@/components/main-layout";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const registrationStatus = useQuery(trpc.getRegistrationStatus.queryOptions());
const [isRegistering, setIsRegistering] = useState(false);
const router = useRouter();
const authStatus = useQuery(trpc.checkAuthStatus.queryOptions());
useEffect(() => {
if (authStatus.data?.isLoggedIn) {
router.push("/admin");
}
}, [authStatus.data, router]);
useEffect(() => {
if (!registrationStatus.data?.enabled && isRegistering) {
@@ -18,7 +28,7 @@ export default function LoginPage() {
}
}, [registrationStatus.data?.enabled, isRegistering]);
if (!registrationStatus.data) {
if (!registrationStatus.data || authStatus.isLoading) {
return null;
}

View File

@@ -16,10 +16,10 @@ export default function NotFound() {
404
</h1>
<h2 className="text-2xl font-semibold text-gray-700 dark:text-gray-300 mb-4">
{t('notFound.title')}
{t('pages.notFound.title')}
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
{t('notFound.description')}
{t('pages.notFound.description')}
</p>
</div>
@@ -33,7 +33,7 @@ export default function NotFound() {
<Link href="/">
<Button variant="outline" className="flex items-center gap-2">
<Icon name="Home" provider="lu" className="w-4 h-4" />
{t('notFound.homepage')}
{t('pages.notFound.homepage')}
</Button>
</Link>
</div>

View File

@@ -18,7 +18,7 @@ export default function AdminHeader() {
className="flex items-center gap-2 text-sm font-medium transition-colors hover:text-foreground/80"
>
<Icon name="House" provider="lu" className="h-5 w-5" />
<span className="hidden sm:inline">{t('navigation.home')}</span>
<span className="hidden sm:inline">{t('components.navigation.home')}</span>
</Link>
<div className="flex items-center gap-2">

View File

@@ -13,12 +13,12 @@ interface AdminNavProps {
const navItems = [
{
path: "/admin",
labelKey: "dashboard.nav.homepage",
labelKey: "pages.dashboard.nav.homepage",
icon: "House"
},
{
path: "/admin/blog",
labelKey: "dashboard.nav.blog",
labelKey: "pages.dashboard.nav.blog",
icon: "FileText"
},
{
@@ -28,32 +28,32 @@ const navItems = [
},
{
path: "/admin/projects",
labelKey: "dashboard.nav.projects",
labelKey: "pages.dashboard.nav.projects",
icon: "Briefcase"
},
{
path: "/admin/skills",
labelKey: "dashboard.nav.skills",
labelKey: "pages.dashboard.nav.skills",
icon: "Award"
},
{
path: "/admin/contact",
labelKey: "dashboard.nav.contact",
labelKey: "pages.dashboard.nav.contact",
icon: "Mail"
},
{
path: "/admin/privacy-policy",
labelKey: "dashboard.nav.privacyPolicy",
labelKey: "pages.dashboard.nav.privacyPolicy",
icon: "Shield"
},
{
path: "/admin/navigation",
labelKey: "dashboard.nav.navigation",
labelKey: "pages.dashboard.nav.navigation",
icon: "Menu"
},
{
path: "/admin/newsletter",
labelKey: "dashboard.nav.newsletter",
labelKey: "pages.dashboard.nav.newsletter",
icon: "Send"
},
];

View File

@@ -69,10 +69,10 @@ export const BlogCategoryForm = ({
const schema = z.object({
name: z.object({
pl: z.string().min(1, t("validation.required") as string),
en: z.string().min(1, t("validation.required") as string),
pl: z.string().min(1, t("components.validation.required") as string),
en: z.string().min(1, t("components.validation.required") as string),
}),
slug: z.string().min(1, t("validation.required") as string),
slug: z.string().min(1, t("components.validation.required") as string),
description: z.object({
pl: z.string().optional(),
en: z.string().optional(),

View File

@@ -64,8 +64,8 @@ export const ContactForm = ({
const schema = z.object({
name: z.object({
pl: z.string().min(1, t("validation.required") as string),
en: z.string().min(1, t("validation.required") as string),
pl: z.string().min(1, t("components.validation.required") as string),
en: z.string().min(1, t("components.validation.required") as string),
}),
iconName: z.string().nullable().optional(),
iconProvider: z.string().nullable().optional(),

View File

@@ -1,376 +0,0 @@
import type { ReactNode } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useTranslations } from "next-intl";
import { useState, useEffect, useMemo } from "react";
import { Icon } from '@/components/icon';
export type Column = {
key: string;
header: string;
render?: (item: any) => ReactNode;
sortable?: boolean;
searchable?: boolean;
};
type DataTableProps = {
title: string;
addButtonText: string;
columns: Column[];
data: any[];
FormComponent: React.ComponentType<{
initialData?: any;
onSubmit: (data: any) => void;
onCancel: () => void;
}>;
onAdd: (data: any) => void;
onEdit?: (id: string, data: any) => void;
onDelete?: (id: string) => void;
onChangeOrder?: (id: string, direction: "up" | "down") => void;
addDialogTitle: string;
editDialogTitle: string;
deleteDialogTitle: string;
deleteDialogConfirmText: string;
showOrderButtons?: boolean;
idField?: string;
};
export const DataTable = ({
title,
addButtonText,
columns,
data,
FormComponent,
onAdd,
onEdit,
onDelete,
onChangeOrder,
addDialogTitle,
editDialogTitle,
deleteDialogTitle,
deleteDialogConfirmText,
showOrderButtons = false,
idField = "id",
}: DataTableProps) => {
const t = useTranslations();
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<any | null>(null);
const [itemToDelete, setItemToDelete] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState("");
const [orderingMode, setOrderingMode] = useState(false);
const [sortConfig, setSortConfig] = useState<{ key: string, direction: 'asc' | 'desc' } | null>(null);
const handleAdd = (data: any) => {
onAdd(data);
setIsAddDialogOpen(false);
};
const handleEdit = (data: any) => {
if (editingItem && onEdit) {
onEdit(editingItem[idField], data);
setEditingItem(null);
}
};
const handleDelete = () => {
if (itemToDelete && onDelete) {
onDelete(itemToDelete);
setItemToDelete(null);
}
};
const handleSort = (key: string) => {
setSortConfig((prevSortConfig) => {
if (!prevSortConfig || prevSortConfig.key !== key) {
return { key, direction: 'asc' };
}
if (prevSortConfig.direction === 'asc') {
return { key, direction: 'desc' };
}
return null;
});
};
useEffect(() => {
if (orderingMode) {
setSortConfig(null);
setSearchTerm("");
}
}, [orderingMode]);
const getNestedValue = (obj: any, path: string) => {
if (!obj) return '';
const column = columns.find(c => c.key === path);
if (column && column.render) {
const rendered = column.render(obj);
if (typeof rendered !== 'object') {
return rendered;
}
}
const keys = path.split('.');
let value = obj;
for (const key of keys) {
if (value === null || value === undefined) return '';
value = value[key];
}
return value === null || value === undefined ? '' : value;
};
const filteredAndSortedData = useMemo(() => {
let result = data;
if (searchTerm) {
const lowerCaseSearchTerm = searchTerm.toLowerCase();
result = data.filter((item) => {
return columns.some((column) => {
if (!column.searchable) return false;
const value = getNestedValue(item, column.key);
return String(value || '').toLowerCase().includes(lowerCaseSearchTerm);
});
});
}
if (sortConfig && !orderingMode) {
result = [...result].sort((a, b) => {
const aValue = getNestedValue(a, sortConfig.key);
const bValue = getNestedValue(b, sortConfig.key);
if (typeof aValue === 'number' && typeof bValue === 'number') {
return sortConfig.direction === 'asc' ? aValue - bValue : bValue - aValue;
}
const aString = String(aValue || '');
const bString = String(bValue || '');
return sortConfig.direction === 'asc'
? aString.localeCompare(bString)
: bString.localeCompare(aString);
});
} else if (orderingMode) {
result = [...result].sort((a, b) => a.order - b.order);
}
return result;
}, [data, searchTerm, columns, sortConfig, orderingMode]);
return (
<Card className="mt-4">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{title}</CardTitle>
<div className="flex space-x-2">
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button>{addButtonText}</Button>
</DialogTrigger>
<DialogContent className="max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{addDialogTitle}</DialogTitle>
</DialogHeader>
<FormComponent
onSubmit={handleAdd}
onCancel={() => setIsAddDialogOpen(false)}
/>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<div className="px-6 pb-2 flex flex-col sm:flex-row justify-between gap-4">
<div className="flex items-center space-x-2">
{showOrderButtons && onChangeOrder && (
<div className="flex items-center space-x-2">
<Switch
id="ordering-mode"
checked={orderingMode}
onCheckedChange={setOrderingMode}
/>
<Label htmlFor="ordering-mode" className="text-sm font-medium">
{t("common.changeOrder")}
</Label>
</div>
)}
</div>
<Input
className="max-w-xs"
placeholder={t("common.search")}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
disabled={orderingMode}
/>
</div>
<CardContent>
<div className="overflow-x-auto max-w-full">
<div className="rounded-md border">
<Table className="min-w-max">
<TableHeader>
<TableRow>
{columns.map((column) => {
const isTextColumn = column.key.includes('title') || column.key === 'url';
return (
<TableHead
key={column.key}
className={`${isTextColumn ? "min-w-[200px] max-w-[300px]" : "whitespace-nowrap"} ${column.sortable && !orderingMode ? "cursor-pointer hover:bg-muted" : ""}`}
onClick={() => column.sortable && !orderingMode && handleSort(column.key)}
>
<div className="flex items-center select-none">
{column.header}
{sortConfig && sortConfig.key === column.key && (
<span className="ml-1">
{sortConfig.direction === 'asc' ? '▲' : '▼'}
</span>
)}
</div>
</TableHead>
);
})}
{(onEdit || onDelete || (onChangeOrder && orderingMode)) && (
<TableHead className="select-none whitespace-nowrap">{t("common.actions")}</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{filteredAndSortedData.map((item, index) => (
<TableRow key={item[idField]}>
{columns.map((column) => {
const content = column.render
? column.render(item)
: item[column.key] || "-";
const isTextColumn = column.key.includes('title') || column.key === 'url';
return (
<TableCell key={`${item[idField]}-${column.key}`} className={isTextColumn ? "min-w-[200px] max-w-[300px]" : "whitespace-nowrap"}>
{isTextColumn ? (
<div
className="truncate"
title={typeof content === 'string' ? content : String(content)}
>
{content}
</div>
) : (
content
)}
</TableCell>
);
})}
{(onEdit || onDelete || onChangeOrder) && (
<TableCell className="whitespace-nowrap">
<div className="flex gap-2">
{onEdit && (
<Dialog
open={editingItem?.[idField] === item[idField]}
onOpenChange={(open) =>
setEditingItem(open ? item : null)
}
>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
{t("common.edit")}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editDialogTitle}</DialogTitle>
</DialogHeader>
<FormComponent
initialData={item}
onSubmit={handleEdit}
onCancel={() => setEditingItem(null)}
/>
</DialogContent>
</Dialog>
)}
{showOrderButtons && onChangeOrder && orderingMode && (
<>
<Button
variant="outline"
size="sm"
onClick={() => onChangeOrder(item[idField], "up")}
disabled={item.order === 1}
>
<Icon name="ArrowUp" provider="lu" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onChangeOrder(item[idField], "down")}
disabled={item.order === Math.max(...data.map(i => i.order))}
>
<Icon name="ArrowDown" provider="lu" />
</Button>
</>
)}
{onDelete && (
<Dialog
open={itemToDelete === item[idField]}
onOpenChange={(open) =>
setItemToDelete(open ? item[idField] : null)
}
>
<DialogTrigger asChild>
<Button variant="destructive" size="sm">
{t("common.delete")}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{deleteDialogTitle}</DialogTitle>
</DialogHeader>
<div className="py-4">
<p>{deleteDialogConfirmText}</p>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setItemToDelete(null)}
>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
onClick={handleDelete}
>
{t("common.delete")}
</Button>
</div>
</DialogContent>
</Dialog>
)}
</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,171 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Icon } from '@/components/icon';
export interface EditActionProps {
item: any;
idField: string;
editingItem: any;
setEditingItem: (item: any) => void;
onEdit?: (id: string, data: any) => void;
FormComponent?: React.ComponentType<{
initialData?: any;
onSubmit: (data: any) => void;
onCancel: () => void;
}>;
editDialogTitle?: string;
isMobile?: boolean;
t: (key: string) => string;
}
export const EditAction = ({
item,
idField,
editingItem,
setEditingItem,
onEdit,
FormComponent,
editDialogTitle,
isMobile = false,
t
}: EditActionProps) => {
return (
<Dialog
open={editingItem?.[idField] === item[idField]}
onOpenChange={(open) =>
setEditingItem(open ? item : null)
}
>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className={isMobile ? "flex-1 min-w-0 text-xs py-1 h-auto" : ""}
>
<Icon
name="Pencil"
provider="lu"
className={isMobile ? "w-3 h-3 mr-1" : ""}
/>
{isMobile && t("common.edit")}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editDialogTitle}</DialogTitle>
</DialogHeader>
{FormComponent && (
<FormComponent
initialData={item}
onSubmit={(data: any) => {
if (editingItem && onEdit) {
onEdit(editingItem[idField], data);
setEditingItem(null);
}
}}
onCancel={() => setEditingItem(null)}
/>
)}
</DialogContent>
</Dialog>
);
};
export interface DeleteActionProps {
item: any;
idField: string;
setItemToDelete: (id: string | null) => void;
isMobile?: boolean;
t: (key: string) => string;
}
export const DeleteAction = ({
item,
idField,
setItemToDelete,
isMobile = false,
t
}: DeleteActionProps) => {
return (
<Dialog
open={false}
onOpenChange={(open) => {
if (open) {
setItemToDelete(item[idField]);
}
}}
>
<DialogTrigger asChild>
<Button
variant="destructive"
size="sm"
className={isMobile ? "flex-1 min-w-0 text-xs py-1 h-auto" : ""}
>
<Icon
name="Trash"
provider="lu"
className={isMobile ? "w-3 h-3 mr-1" : ""}
/>
{isMobile && t("common.delete")}
</Button>
</DialogTrigger>
</Dialog>
);
};
export interface OrderActionsProps {
item: any;
idField: string;
data: any[];
onChangeOrder?: (id: string, direction: "up" | "down") => void;
isMobile?: boolean;
t: (key: string) => string;
}
export const OrderActions = ({
item,
idField,
data,
onChangeOrder,
isMobile = false,
t
}: OrderActionsProps) => {
return (
<>
<Button
variant="outline"
size="sm"
className={isMobile ? "flex-1 min-w-0 text-xs py-1 h-auto" : ""}
onClick={() => onChangeOrder && onChangeOrder(item[idField], "up")}
disabled={item.order === 1}
>
<Icon
name="ArrowUp"
provider="lu"
className={isMobile ? "w-3 h-3 mr-1" : ""}
/>
{isMobile && t("common.moveUp")}
</Button>
<Button
variant="outline"
size="sm"
className={isMobile ? "flex-1 min-w-0 text-xs py-1 h-auto" : ""}
onClick={() => onChangeOrder && onChangeOrder(item[idField], "down")}
disabled={item.order === Math.max(...data.map((i: any) => i.order))}
>
<Icon
name="ArrowDown"
provider="lu"
className={isMobile ? "w-3 h-3 mr-1" : ""}
/>
{isMobile && t("common.moveDown")}
</Button>
</>
);
};

View File

@@ -0,0 +1,126 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Icon } from '@/components/icon';
import { cn } from "@/lib/utils";
import type { DesktopDataTableProps } from "./types";
import { EditAction, DeleteAction, OrderActions } from "./data-table-actions";
export const DesktopDataTable = ({
columns,
data,
sortColumn,
sortDirection,
setSortColumn,
setSortDirection,
onEdit,
onDelete,
onChangeOrder,
editingItem,
setEditingItem,
setItemToDelete,
FormComponent,
editDialogTitle,
showOrderButtons,
orderingMode,
idField,
t,
customActions
}: DesktopDataTableProps) => {
const handleSort = (column: string) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('asc');
}
};
return (
<div className="rounded-md border">
<div className="relative w-full overflow-auto">
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => (
<TableHead
key={column.key}
className={cn(column.sortable && "cursor-pointer hover:bg-muted")}
onClick={() => column.sortable && handleSort(column.key)}
>
<div className="flex items-center gap-1">
{column.header}
{column.sortable && sortColumn === column.key && (
<Icon
name={sortDirection === 'asc' ? "ArrowUp" : "ArrowDown"}
provider="lu"
className="h-3 w-3"
/>
)}
</div>
</TableHead>
))}
{(onEdit || onDelete || onChangeOrder || customActions) && (
<TableHead className="text-right">
{t("common.actions")}
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{data.map((item) => (
<TableRow key={item[idField]}>
{columns.map((column) => (
<TableCell key={`${item[idField]}-${column.key}`}>
{column.render ? column.render(item) : item[column.key] || "-"}
</TableCell>
))}
{(onEdit || onDelete || onChangeOrder || customActions) && (
<TableCell className="text-right">
<div className="flex justify-end gap-2">
{onEdit && (
<EditAction
item={item}
idField={idField}
editingItem={editingItem}
setEditingItem={setEditingItem}
onEdit={onEdit}
FormComponent={FormComponent}
editDialogTitle={editDialogTitle}
t={t}
/>
)}
{showOrderButtons && onChangeOrder && orderingMode && (
<OrderActions
item={item}
idField={idField}
data={data}
onChangeOrder={onChangeOrder}
t={t}
/>
)}
{onDelete && (
<DeleteAction
item={item}
idField={idField}
setItemToDelete={setItemToDelete}
t={t}
/>
)}
{customActions && customActions(item)}
</div>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
};

View File

@@ -0,0 +1,247 @@
import { useState, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Icon } from '@/components/icon';
import { MobileDataCard } from "./mobile-data-card";
import { DesktopDataTable } from "./desktop-data-table";
import { type ResponsiveDataTableProps } from "./types";
const ResponsiveDataTable = ({
title,
addButtonText,
columns,
data,
FormComponent,
onAdd,
onEdit,
onDelete,
onChangeOrder,
addDialogTitle,
editDialogTitle,
deleteDialogTitle,
deleteDialogConfirmText,
showOrderButtons = false,
showAddButton = true,
idField = "id",
searchPlaceholder = "Search",
customActions,
}: ResponsiveDataTableProps) => {
const t = useTranslations();
const [searchTerm, setSearchTerm] = useState("");
const [sortColumn, setSortColumn] = useState<string | null>(null);
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [orderingMode, setOrderingMode] = useState(false);
const [editingItem, setEditingItem] = useState<any>(null);
const [itemToDelete, setItemToDelete] = useState<string | null>(null);
const [addDialogOpen, setAddDialogOpen] = useState(false);
// Filter data based on search term
const filteredData = useMemo(() => {
if (!searchTerm) return data;
return data.filter(item => {
return columns.some(column => {
if (!column.searchable) return false;
const value = item[column.key];
if (!value) return false;
return value.toString().toLowerCase().includes(searchTerm.toLowerCase());
});
});
}, [data, searchTerm, columns]);
// Sort data based on sort column and direction
const filteredAndSortedData = useMemo(() => {
if (!sortColumn) return filteredData;
return [...filteredData].sort((a, b) => {
const aValue = a[sortColumn];
const bValue = b[sortColumn];
if (aValue === bValue) return 0;
const comparison = aValue > bValue ? 1 : -1;
return sortDirection === 'asc' ? comparison : -comparison;
});
}, [filteredData, sortColumn, sortDirection]);
// Handle delete
const handleDelete = () => {
if (itemToDelete && onDelete) {
onDelete(itemToDelete);
setItemToDelete(null);
}
};
return (
<Card className="mt-4">
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<CardTitle>{title}</CardTitle>
<div className="flex space-x-2 w-full sm:w-auto">
{showAddButton && onAdd && (
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogTrigger asChild>
<Button className="w-full sm:w-auto">{addButtonText}</Button>
</DialogTrigger>
<DialogContent className="max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{addDialogTitle}</DialogTitle>
</DialogHeader>
{FormComponent && (
<FormComponent
onSubmit={(data: any) => {
if (onAdd) {
onAdd(data);
setAddDialogOpen(false);
}
}}
onCancel={() => setAddDialogOpen(false)}
/>
)}
</DialogContent>
</Dialog>
)}
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col sm:flex-row justify-between gap-2 mb-4">
<div className="max-w-sm">
<Input
placeholder={searchPlaceholder == "Search" ? t("common.search") : searchPlaceholder}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="max-w-sm"
/>
</div>
{showOrderButtons && onChangeOrder && (
<div className="flex items-center space-x-2">
<Switch
id="ordering-mode"
checked={orderingMode}
onCheckedChange={setOrderingMode}
/>
<Label htmlFor="ordering-mode">{t("common.changeOrder")}</Label>
</div>
)}
</div>
{/* Desktop Table View */}
<div className="hidden lg:block">
{filteredAndSortedData.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{searchTerm ? t("common.noResults") : t("common.noData")}
</div>
) : (
<DesktopDataTable
columns={columns}
data={filteredAndSortedData}
sortColumn={sortColumn}
sortDirection={sortDirection}
setSortColumn={setSortColumn}
setSortDirection={setSortDirection}
onEdit={onEdit}
onDelete={onDelete}
onChangeOrder={onChangeOrder}
editingItem={editingItem}
setEditingItem={setEditingItem}
setItemToDelete={setItemToDelete}
FormComponent={FormComponent}
editDialogTitle={editDialogTitle}
showOrderButtons={showOrderButtons}
orderingMode={orderingMode}
idField={idField}
t={t}
customActions={customActions}
/>
)}
</div>
{/* Mobile Card View */}
<div className="block lg:hidden">
{filteredAndSortedData.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{searchTerm ? t("common.noResults") : t("common.noData")}
</div>
) : (
<div className="space-y-4">
{filteredAndSortedData.map((item) => (
<MobileDataCard
key={item[idField]}
item={item}
columns={columns}
onEdit={onEdit}
onDelete={onDelete}
onChangeOrder={onChangeOrder}
editingItem={editingItem}
setEditingItem={setEditingItem}
setItemToDelete={setItemToDelete}
FormComponent={FormComponent}
editDialogTitle={editDialogTitle}
deleteDialogTitle={deleteDialogTitle}
deleteDialogConfirmText={deleteDialogConfirmText}
showOrderButtons={showOrderButtons}
orderingMode={orderingMode}
data={data}
idField={idField}
t={t}
customActions={customActions}
/>
))}
</div>
)}
</div>
{/* Delete Dialog */}
{itemToDelete && (
<Dialog
open={!!itemToDelete}
onOpenChange={(open) => !open && setItemToDelete(null)}
>
<DialogContent className="max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{deleteDialogTitle}</DialogTitle>
</DialogHeader>
<div className="py-4">
<p>{deleteDialogConfirmText}</p>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setItemToDelete(null)}
>
{t("common.cancel")}
</Button>
<Button
variant="destructive"
onClick={handleDelete}
>
{t("common.delete")}
</Button>
</div>
</DialogContent>
</Dialog>
)}
</CardContent>
</Card>
);
};
// Re-export components for direct access
export * from "./types";
export * from "./mobile-data-card";
export * from "./desktop-data-table";
// Export the main component
export { ResponsiveDataTable };

View File

@@ -0,0 +1,137 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import type { MobileDataCardProps, Column } from "./types";
import { EditAction, DeleteAction, OrderActions } from "./data-table-actions";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
export const MobileDataCard = ({
item,
columns,
onEdit,
onDelete,
onChangeOrder,
editingItem,
setEditingItem,
setItemToDelete,
FormComponent,
editDialogTitle,
showOrderButtons,
orderingMode,
data,
idField,
t,
customActions
}: MobileDataCardProps) => {
// Separate columns by priority
const highPriorityColumns = columns.filter((col: Column) => col.priority === 'high');
const mediumPriorityColumns = columns.filter((col: Column) => col.priority === 'medium');
const lowPriorityColumns = columns.filter((col: Column) => col.priority === 'low' || !col.priority);
const renderColumnValue = (column: Column, item: any) => {
const content = column.render ? column.render(item) : item[column.key] || "-";
return content;
};
return (
<Card className="mb-2 overflow-hidden">
<CardContent className="p-2 sm:p-4">
{/* High priority fields - always visible, larger text */}
{highPriorityColumns.length > 0 && (
<div className="space-y-1 mb-2">
{highPriorityColumns.map((column: Column) => (
<div key={column.key}>
<div className="font-medium text-base truncate w-full" title={typeof renderColumnValue(column, item) === 'string' ? renderColumnValue(column, item) : undefined}>
{renderColumnValue(column, item)}
</div>
</div>
))}
</div>
)}
{/* Medium priority fields */}
{mediumPriorityColumns.length > 0 && (
<div className="space-y-1 mb-2">
{mediumPriorityColumns.map((column: Column) => (
<div key={column.key} className="flex flex-col sm:flex-row sm:justify-between sm:items-center text-sm">
<span className="text-muted-foreground font-medium">{column.header}:</span>
<span className="truncate w-full sm:max-w-[180px] sm:text-right" title={typeof renderColumnValue(column, item) === 'string' ? renderColumnValue(column, item) : undefined}>
{renderColumnValue(column, item)}
</span>
</div>
))}
</div>
)}
{/* Low priority fields - collapsible */}
{lowPriorityColumns.length > 0 && (
<details className="mb-2">
<summary className="text-sm text-muted-foreground cursor-pointer hover:text-foreground">
{t("common.showMore")} ({lowPriorityColumns.length})
</summary>
<div className="mt-2 space-y-1">
{lowPriorityColumns.map((column: Column) => (
<div key={column.key} className="flex flex-col sm:flex-row sm:justify-between sm:items-center text-sm">
<span className="text-muted-foreground font-medium">{column.header}:</span>
<span className="truncate w-full sm:max-w-[180px] sm:text-right" title={typeof renderColumnValue(column, item) === 'string' ? renderColumnValue(column, item) : undefined}>
{renderColumnValue(column, item)}
</span>
</div>
))}
</div>
</details>
)}
{/* Actions */}
{(onEdit || onDelete || onChangeOrder || customActions) && (
<div className="flex flex-wrap gap-1 pt-2 border-t">
{onDelete && (
<DeleteAction
item={item}
idField={idField}
setItemToDelete={setItemToDelete}
isMobile={true}
t={t}
/>
)}
{onEdit && (
<EditAction
item={item}
idField={idField}
editingItem={editingItem}
setEditingItem={setEditingItem}
onEdit={onEdit}
FormComponent={FormComponent}
editDialogTitle={editDialogTitle}
isMobile={true}
t={t}
/>
)}
{showOrderButtons && onChangeOrder && orderingMode && (
<OrderActions
item={item}
idField={idField}
data={data}
onChangeOrder={onChangeOrder}
isMobile={true}
t={t}
/>
)}
{customActions && (
<div className="flex flex-wrap gap-1 w-full mt-1">
{customActions(item)}
</div>
)}
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,86 @@
import type { ReactNode } from "react";
export type Column = {
key: string;
header: string;
render?: (item: any) => ReactNode;
sortable?: boolean;
searchable?: boolean;
priority?: 'high' | 'medium' | 'low';
};
export interface ResponsiveDataTableProps {
title: string;
addButtonText: string;
columns: Column[];
data: any[];
FormComponent?: React.ComponentType<{
initialData?: any;
onSubmit: (data: any) => void;
onCancel: () => void;
}>;
onAdd?: (data: any) => void;
onEdit?: (id: string, data: any) => void;
onDelete?: (id: string) => void;
onChangeOrder?: (id: string, direction: "up" | "down") => void;
addDialogTitle?: string;
editDialogTitle?: string;
deleteDialogTitle?: string;
deleteDialogConfirmText?: string;
showOrderButtons?: boolean;
showAddButton?: boolean;
idField?: string;
searchPlaceholder?: string;
customActions?: (item: any) => ReactNode;
}
export interface MobileDataCardProps {
item: any;
columns: Column[];
onEdit?: (id: string, data: any) => void;
onDelete?: (id: string) => void;
onChangeOrder?: (id: string, direction: "up" | "down") => void;
editingItem: any;
setEditingItem: (item: any) => void;
setItemToDelete: (id: string | null) => void;
FormComponent?: React.ComponentType<{
initialData?: any;
onSubmit: (data: any) => void;
onCancel: () => void;
}>;
editDialogTitle?: string;
deleteDialogTitle?: string;
deleteDialogConfirmText?: string;
showOrderButtons?: boolean;
orderingMode?: boolean;
data: any[];
idField: string;
t: (key: string) => string;
customActions?: (item: any) => ReactNode;
}
export interface DesktopDataTableProps {
columns: Column[];
data: any[];
sortColumn: string | null;
sortDirection: 'asc' | 'desc';
setSortColumn: (column: string | null) => void;
setSortDirection: (direction: 'asc' | 'desc') => void;
onEdit?: (id: string, data: any) => void;
onDelete?: (id: string) => void;
onChangeOrder?: (id: string, direction: "up" | "down") => void;
editingItem: any;
setEditingItem: (item: any) => void;
setItemToDelete: (id: string | null) => void;
FormComponent?: React.ComponentType<{
initialData?: any;
onSubmit: (data: any) => void;
onCancel: () => void;
}>;
editDialogTitle?: string;
showOrderButtons?: boolean;
orderingMode?: boolean;
idField: string;
t: (key: string) => string;
customActions?: (item: any) => ReactNode;
}

View File

@@ -62,9 +62,9 @@ export const EntityForm = ({
if (field.required) {
if (field.type === "number") {
validator = (validator as z.ZodNumber).min(0, t("validation.required") as string);
validator = (validator as z.ZodNumber).min(0, t("components.validation.required") as string);
} else if (field.type !== "switch") {
validator = (validator as z.ZodString).min(1, t("validation.required") as string);
validator = (validator as z.ZodString).min(1, t("components.validation.required") as string);
}
} else {
if (field.type !== "switch") {

View File

@@ -201,14 +201,14 @@ export default function FileUpload({
{isPending ? (
<div className="flex flex-col items-center gap-2">
<Icon name="Loader" provider="lu" className="h-8 w-8 animate-spin" />
<p className="text-sm text-muted-foreground">{t("fileUpload.uploading")}</p>
<p className="text-sm text-muted-foreground">{t("components.fileUpload.uploading")}</p>
</div>
) : (
<div className="flex flex-col items-center gap-4">
<Icon name="Image" provider="lu" className="h-8 w-8 text-muted-foreground" />
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
{t("fileUpload.dragAndDrop")}
{t("components.fileUpload.dragAndDrop")}
</p>
<Button
type="button"
@@ -216,7 +216,7 @@ export default function FileUpload({
onClick={() => fileInputRef.current?.click()}
>
<Icon name="Upload" provider="lu" className="h-4 w-4 mr-2" />
{t("fileUpload.chooseFile")}
{t("components.fileUpload.chooseFile")}
</Button>
</div>
</div>
@@ -237,7 +237,7 @@ export default function FileUpload({
size="sm"
onClick={handleClear}
>
{t("fileUpload.clear")}
{t("components.fileUpload.clear")}
</Button>
)}
</div>

View File

@@ -21,18 +21,18 @@ export function ModeToggle() {
<Button variant="outline" size="icon">
<Icon name="Sun" provider="lu" className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Icon name="Moon" provider="lu" className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">{t('theme.toggle')}</span>
<span className="sr-only">{t('components.theme.toggle')}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
{t('theme.light')}
{t('components.theme.light')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
{t('theme.dark')}
{t('components.theme.dark')}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
{t('theme.system')}
{t('components.theme.system')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -55,8 +55,8 @@ export const NavigationForm = ({
const schema = z.object({
label: z.object({
pl: z.string().min(1, t("validation.required") as string),
en: z.string().min(1, t("validation.required") as string),
pl: z.string().min(1, t("components.validation.required") as string),
en: z.string().min(1, t("components.validation.required") as string),
}),
url: z.string().nullable().optional(),
external: z.boolean().default(false),

View File

@@ -84,38 +84,38 @@ export default function NewsletterNotifications() {
const selectedArticleData = articles?.find(post => post.id === selectedArticle);
return (
<div className="space-y-6">
<div className="space-y-6 max-w-full overflow-hidden w-full">
{/* Newsletter Stats */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon name="Users" provider="lu" className="h-5 w-5" />
<Card className="w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl mx-auto md:mx-0">
<CardHeader className="w-full">
<CardTitle className="flex items-center gap-2 text-base">
<Icon name="Users" provider="lu" className="h-4 w-4" />
{t('newsletter.admin.stats.title')}
</CardTitle>
<CardDescription>
<CardDescription className="text-xs">
{t('newsletter.admin.stats.description')}
</CardDescription>
</CardHeader>
<CardContent>
<CardContent className="w-full">
{stats ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="text-center p-4 bg-blue-50 dark:bg-blue-950 rounded-lg">
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
<div className="grid grid-cols-2 md:grid-cols-3 gap-1 w-full">
<div className="text-center p-2 bg-blue-50 dark:bg-blue-950 rounded-lg">
<div className="text-xl font-bold text-blue-600 dark:text-blue-400">
{stats.polish.count}
</div>
<div className="text-sm text-muted-foreground">{t('newsletter.admin.stats.polish')}</div>
<div className="text-xs text-muted-foreground">{t('newsletter.admin.stats.polish')}</div>
</div>
<div className="text-center p-4 bg-green-50 dark:bg-green-950 rounded-lg">
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
<div className="text-center p-2 bg-green-50 dark:bg-green-950 rounded-lg">
<div className="text-xl font-bold text-green-600 dark:text-green-400">
{stats.english.count}
</div>
<div className="text-sm text-muted-foreground">{t('newsletter.admin.stats.english')}</div>
<div className="text-xs text-muted-foreground">{t('newsletter.admin.stats.english')}</div>
</div>
<div className="text-center p-4 bg-purple-50 dark:bg-purple-950 rounded-lg">
<div className="text-2xl font-bold text-purple-600 dark:text-purple-400">
<div className="text-center p-2 bg-purple-50 dark:bg-purple-950 rounded-lg">
<div className="text-xl font-bold text-purple-600 dark:text-purple-400">
{stats.total.count}
</div>
<div className="text-sm text-muted-foreground">{t('newsletter.admin.stats.total')}</div>
<div className="text-xs text-muted-foreground">{t('newsletter.admin.stats.total')}</div>
</div>
</div>
) : (
@@ -128,27 +128,28 @@ export default function NewsletterNotifications() {
</Card>
{/* Send Newsletter */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Icon name="Send" provider="lu" className="h-5 w-5" />
<Card className="w-full max-w-xs sm:max-w-sm md:max-w-md lg:max-w-lg xl:max-w-xl mx-auto md:mx-0">
<CardHeader className="w-full">
<CardTitle className="flex items-center gap-2 text-base">
<Icon name="Send" provider="lu" className="h-4 w-4" />
{t('newsletter.admin.send.title')}
</CardTitle>
<CardDescription>
<CardDescription className="text-xs">
{t('newsletter.admin.send.description')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<CardContent className="space-y-4 w-full">
<div className="space-y-4 w-full">
<Label htmlFor="article-select">{t('newsletter.admin.send.selectArticle')}</Label>
<Select
value={selectedArticle || ""}
onValueChange={(value) => setSelectedArticle(value)}
disabled={articlesLoading || isProcessing}
>
<SelectTrigger>
<SelectTrigger className="max-w-full">
<SelectValue placeholder={t('newsletter.admin.send.articlePlaceholder')} />
</SelectTrigger>
<SelectContent>
<SelectContent className="max-w-full">
{articlesLoading ? (
<div className="p-2 text-center">
<Icon name="Loader" provider="lu" className="h-4 w-4 animate-spin mx-auto" />
@@ -157,12 +158,12 @@ export default function NewsletterNotifications() {
articles?.map((post) => {
const title = typeof post.title === 'string' ? JSON.parse(post.title) : post.title;
return (
<SelectItem key={post.id} value={post.id}>
<div className="flex flex-col items-start">
<span className="font-medium">
<SelectItem key={post.id} value={post.id} className="max-w-full">
<div className="flex flex-col items-start w-full max-w-full overflow-hidden">
<span className="font-medium truncate w-full" title={title?.pl || title?.en || t('common.untitled')}>
{title?.pl || title?.en || t('common.untitled')}
</span>
<span className="text-xs text-muted-foreground">
<span className="text-xs text-muted-foreground truncate w-full">
{post.publishedAt
? new Date(post.publishedAt).toLocaleDateString()
: t('common.draft')
@@ -178,7 +179,7 @@ export default function NewsletterNotifications() {
</div>
{selectedArticleData && (
<div className="p-4 bg-muted/50 rounded-lg">
<div className="p-4 bg-muted/50 rounded-lg overflow-hidden">
<h4 className="font-medium mb-2">{t('newsletter.admin.send.preview')}</h4>
<div className="space-y-1 text-sm">
{(() => {
@@ -187,10 +188,30 @@ export default function NewsletterNotifications() {
: selectedArticleData.title;
return (
<>
<p><strong>{t('newsletter.admin.send.polishTitle')}</strong> {title?.pl || t('newsletter.admin.send.notAvailable')}</p>
<p><strong>{t('newsletter.admin.send.englishTitle')}</strong> {title?.en || t('newsletter.admin.send.notAvailable')}</p>
<p><strong>{t('newsletter.admin.send.slug')}</strong> {selectedArticleData.slug}</p>
<p><strong>{t('newsletter.admin.send.status')}</strong> {selectedArticleData.isPublished ? t('newsletter.admin.send.published') : t('newsletter.admin.send.draft')}</p>
<div className="grid grid-cols-[auto,1fr] gap-x-2">
<strong>{t('newsletter.admin.send.polishTitle')}</strong>
<span className="truncate" title={title?.pl || t('newsletter.admin.send.notAvailable')}>
{title?.pl || t('newsletter.admin.send.notAvailable')}
</span>
</div>
<div className="grid grid-cols-[auto,1fr] gap-x-2">
<strong>{t('newsletter.admin.send.englishTitle')}</strong>
<span className="truncate" title={title?.en || t('newsletter.admin.send.notAvailable')}>
{title?.en || t('newsletter.admin.send.notAvailable')}
</span>
</div>
<div className="grid grid-cols-[auto,1fr] gap-x-2">
<strong>{t('newsletter.admin.send.slug')}</strong>
<span className="truncate" title={selectedArticleData.slug}>
{selectedArticleData.slug}
</span>
</div>
<div className="grid grid-cols-[auto,1fr] gap-x-2">
<strong>{t('newsletter.admin.send.status')}</strong>
<span>
{selectedArticleData.isPublished ? t('newsletter.admin.send.published') : t('newsletter.admin.send.draft')}
</span>
</div>
</>
);
})()}
@@ -198,24 +219,26 @@ export default function NewsletterNotifications() {
</div>
)}
<div className="flex gap-4">
<div className="flex flex-col gap-2 w-full min-w-0 overflow-hidden">
<Button
onClick={() => openConfirmModal('pl')}
disabled={!selectedArticle || isProcessing}
className="flex-1"
className="w-full justify-start px-3"
variant="default"
size="sm"
>
<Icon name="Mail" provider="lu" className="mr-2 h-4 w-4" />
{t('newsletter.admin.send.sendToPolish')} ({getSubscriberCount('pl')})
<Icon name="Mail" provider="lu" className="mr-2 h-4 w-4 flex-shrink-0" />
<span className="truncate">{t('newsletter.admin.send.sendToPolish')} ({getSubscriberCount('pl')})</span>
</Button>
<Button
onClick={() => openConfirmModal('en')}
disabled={!selectedArticle || isProcessing}
className="flex-1"
className="w-full justify-start px-3"
variant="default"
size="sm"
>
<Icon name="Mail" provider="lu" className="mr-2 h-4 w-4" />
{t('newsletter.admin.send.sendToEnglish')} ({getSubscriberCount('en')})
<Icon name="Mail" provider="lu" className="mr-2 h-4 w-4 flex-shrink-0" />
<span className="truncate">{t('newsletter.admin.send.sendToEnglish')} ({getSubscriberCount('en')})</span>
</Button>
</div>
</CardContent>
@@ -223,7 +246,7 @@ export default function NewsletterNotifications() {
{/* Confirmation Modal */}
<Dialog open={isConfirmModalOpen} onOpenChange={setIsConfirmModalOpen}>
<DialogContent>
<DialogContent className="w-[95vw] max-w-[95vw] min-w-0 overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Icon name="AlertTriangle" provider="lu" className="h-5 w-5 text-yellow-500" />

View File

@@ -70,12 +70,12 @@ export const ProjectForm = ({
const schema = z.object({
title: z.object({
pl: z.string().min(1, t("validation.required") as string),
en: z.string().min(1, t("validation.required") as string),
pl: z.string().min(1, t("components.validation.required") as string),
en: z.string().min(1, t("components.validation.required") as string),
}),
description: z.object({
pl: z.string().min(1, t("validation.required") as string),
en: z.string().min(1, t("validation.required") as string),
pl: z.string().min(1, t("components.validation.required") as string),
en: z.string().min(1, t("components.validation.required") as string),
}),
url: z.string().nullable().optional(),
repoUrl: z.string().nullable().optional(),

View File

@@ -301,7 +301,7 @@ export default function RichTextEditor({ content, onChange, placeholder }: RichT
<ToolbarButton
onClick={() => editor.chain().focus().toggleBold().run()}
isActive={editor.isActive('bold')}
title={t('richEditor.toolbar.bold')}
title={t('components.richEditor.toolbar.bold')}
>
<Icon name="Bold" provider="lu" className="h-4 w-4" />
</ToolbarButton>
@@ -309,7 +309,7 @@ export default function RichTextEditor({ content, onChange, placeholder }: RichT
<ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')}
title={t('richEditor.toolbar.italic')}
title={t('components.richEditor.toolbar.italic')}
>
<Icon name="Italic" provider="lu" className="h-4 w-4" />
</ToolbarButton>
@@ -317,7 +317,7 @@ export default function RichTextEditor({ content, onChange, placeholder }: RichT
<ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive('strike')}
title={t('richEditor.toolbar.strikethrough')}
title={t('components.richEditor.toolbar.strikethrough')}
>
<Icon name="Strikethrough" provider="lu" className="h-4 w-4" />
</ToolbarButton>
@@ -325,7 +325,7 @@ export default function RichTextEditor({ content, onChange, placeholder }: RichT
<ToolbarButton
onClick={() => editor.chain().focus().toggleCode().run()}
isActive={editor.isActive('code')}
title={t('richEditor.toolbar.code')}
title={t('components.richEditor.toolbar.code')}
>
<Icon name="Code" provider="lu" className="h-4 w-4" />
</ToolbarButton>
@@ -335,7 +335,7 @@ export default function RichTextEditor({ content, onChange, placeholder }: RichT
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
isActive={editor.isActive('heading', { level: 1 })}
title={t('richEditor.toolbar.heading1')}
title={t('components.richEditor.toolbar.heading1')}
>
<Icon name="Heading1" provider="lu" className="h-4 w-4" />
</ToolbarButton>
@@ -343,7 +343,7 @@ export default function RichTextEditor({ content, onChange, placeholder }: RichT
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
isActive={editor.isActive('heading', { level: 2 })}
title={t('richEditor.toolbar.heading2')}
title={t('components.richEditor.toolbar.heading2')}
>
<Icon name="Heading2" provider="lu" className="h-4 w-4" />
</ToolbarButton>
@@ -351,7 +351,7 @@ export default function RichTextEditor({ content, onChange, placeholder }: RichT
<ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
isActive={editor.isActive('heading', { level: 3 })}
title={t('richEditor.toolbar.heading3')}
title={t('components.richEditor.toolbar.heading3')}
>
<Icon name="Heading3" provider="lu" className="h-4 w-4" />
</ToolbarButton>
@@ -361,7 +361,7 @@ export default function RichTextEditor({ content, onChange, placeholder }: RichT
<ToolbarButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
isActive={editor.isActive('bulletList')}
title={t('richEditor.toolbar.bulletList')}
title={t('components.richEditor.toolbar.bulletList')}
>
<Icon name="List" provider="lu" className="h-4 w-4" />
</ToolbarButton>
@@ -369,7 +369,7 @@ export default function RichTextEditor({ content, onChange, placeholder }: RichT
<ToolbarButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
isActive={editor.isActive('orderedList')}
title={t('richEditor.toolbar.orderedList')}
title={t('components.richEditor.toolbar.orderedList')}
>
<Icon name="ListOrdered" provider="lu" className="h-4 w-4" />
</ToolbarButton>
@@ -377,7 +377,7 @@ export default function RichTextEditor({ content, onChange, placeholder }: RichT
<ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')}
title={t('richEditor.toolbar.quote')}
title={t('components.richEditor.toolbar.quote')}
>
<Icon name="Quote" provider="lu" className="h-4 w-4" />
</ToolbarButton>
@@ -387,21 +387,21 @@ export default function RichTextEditor({ content, onChange, placeholder }: RichT
<ToolbarButton
onClick={setLink}
isActive={editor.isActive('link')}
title={t('richEditor.toolbar.addLink')}
title={t('components.richEditor.toolbar.addLink')}
>
<Icon name="Link" provider="lu" className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={addImage}
title={t('richEditor.toolbar.addImage')}
title={t('components.richEditor.toolbar.addImage')}
>
<Icon name="Image" provider="lu" className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={addYouTube}
title={t('richEditor.toolbar.addYoutube')}
title={t('components.richEditor.toolbar.addYoutube')}
>
<Icon name="Youtube" provider="lu" className="h-4 w-4" />
</ToolbarButton>
@@ -410,21 +410,21 @@ export default function RichTextEditor({ content, onChange, placeholder }: RichT
<ToolbarButton
onClick={addCodeBlock}
title={t('richEditor.toolbar.addCodeBlock')}
title={t('components.richEditor.toolbar.addCodeBlock')}
>
<Icon name="CodeBlock" provider="lu" className="h-4 w-4" />
<Icon name="SquareCode" provider="lu" className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().undo().run()}
title={t('richEditor.toolbar.undo')}
title={t('components.richEditor.toolbar.undo')}
>
<Icon name="Undo" provider="lu" className="h-4 w-4" />
</ToolbarButton>
<ToolbarButton
onClick={() => editor.chain().focus().redo().run()}
title={t('richEditor.toolbar.redo')}
title={t('components.richEditor.toolbar.redo')}
>
<Icon name="Redo" provider="lu" className="h-4 w-4" />
</ToolbarButton>
@@ -442,19 +442,19 @@ export default function RichTextEditor({ content, onChange, placeholder }: RichT
<DialogHeader>
<DialogTitle>{t('common.addImage')}</DialogTitle>
<DialogDescription>
{t('richEditor.modals.image.description')}
{t('components.richEditor.modals.image.description')}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<FileUpload
category="blog"
onChange={handleImageUpload}
label={t('fileUpload.selectOrUploadImage')}
label={t('components.fileUpload.selectOrUploadImage')}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowImageUpload(false)}>
{t('richEditor.modals.image.cancel')}
{t('components.richEditor.modals.image.cancel')}
</Button>
</DialogFooter>
</DialogContent>
@@ -464,28 +464,28 @@ export default function RichTextEditor({ content, onChange, placeholder }: RichT
<Dialog open={showLinkModal} onOpenChange={setShowLinkModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('richEditor.modals.link.title')}</DialogTitle>
<DialogTitle>{t('components.richEditor.modals.link.title')}</DialogTitle>
<DialogDescription>
{t('richEditor.modals.link.description')}
{t('components.richEditor.modals.link.description')}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="link-url">{t('richEditor.modals.link.urlLabel')}</Label>
<Label htmlFor="link-url">{t('components.richEditor.modals.link.urlLabel')}</Label>
<Input
id="link-url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
placeholder={t('richEditor.modals.link.urlPlaceholder')}
placeholder={t('components.richEditor.modals.link.urlPlaceholder')}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowLinkModal(false)}>
{t('richEditor.modals.link.cancel')}
{t('components.richEditor.modals.link.cancel')}
</Button>
<Button onClick={handleLinkSubmit}>
{linkUrl ? t('richEditor.modals.link.addLink') : t('richEditor.modals.link.removeLink')}
{linkUrl ? t('components.richEditor.modals.link.addLink') : t('components.richEditor.modals.link.removeLink')}
</Button>
</DialogFooter>
</DialogContent>
@@ -495,44 +495,44 @@ export default function RichTextEditor({ content, onChange, placeholder }: RichT
<Dialog open={showYoutubeModal} onOpenChange={setShowYoutubeModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('richEditor.modals.youtube.title')}</DialogTitle>
<DialogTitle>{t('components.richEditor.modals.youtube.title')}</DialogTitle>
<DialogDescription>
{t('richEditor.modals.youtube.description')}
{t('components.richEditor.modals.youtube.description')}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="youtube-url">{t('richEditor.modals.youtube.urlLabel')}</Label>
<Label htmlFor="youtube-url">{t('components.richEditor.modals.youtube.urlLabel')}</Label>
<Input
id="youtube-url"
value={youtubeUrl}
onChange={(e) => setYoutubeUrl(e.target.value)}
placeholder={t('richEditor.modals.youtube.urlPlaceholder')}
placeholder={t('components.richEditor.modals.youtube.urlPlaceholder')}
/>
<div className="text-sm text-muted-foreground">
<p className="font-medium mb-1">{t('richEditor.modals.youtube.supportedFormats')}</p>
<p className="font-medium mb-1">{t('components.richEditor.modals.youtube.supportedFormats')}</p>
<ul className="list-disc list-inside space-y-1">
{t.raw('richEditor.modals.youtube.formats').map((format: string, index: number) => (
{t.raw('components.richEditor.modals.youtube.formats').map((format: string, index: number) => (
<li key={index}>{format}</li>
))}
</ul>
</div>
{youtubeUrl && !/^(https?:\/\/)?(www\.)?(youtube\.com\/(watch\?v=|embed\/)|youtu\.be\/)[\w-]+/.test(youtubeUrl) && (
<p className="text-sm text-destructive">
{t('richEditor.modals.youtube.invalidUrl')}
{t('components.richEditor.modals.youtube.invalidUrl')}
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowYoutubeModal(false)}>
{t('richEditor.modals.youtube.cancel')}
{t('components.richEditor.modals.youtube.cancel')}
</Button>
<Button
onClick={handleYoutubeSubmit}
disabled={!youtubeUrl || !/^(https?:\/\/)?(www\.)?(youtube\.com\/(watch\?v=|embed\/)|youtu\.be\/)[\w-]+/.test(youtubeUrl)}
>
{t('richEditor.modals.youtube.addVideo')}
{t('components.richEditor.modals.youtube.addVideo')}
</Button>
</DialogFooter>
</DialogContent>
@@ -542,33 +542,33 @@ export default function RichTextEditor({ content, onChange, placeholder }: RichT
<Dialog open={showCodeBlockModal} onOpenChange={setShowCodeBlockModal}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('richEditor.modals.codeBlock.title')}</DialogTitle>
<DialogTitle>{t('components.richEditor.modals.codeBlock.title')}</DialogTitle>
<DialogDescription>
{t('richEditor.modals.codeBlock.description')}
{t('components.richEditor.modals.codeBlock.description')}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="code-language">{t('richEditor.modals.codeBlock.languageLabel')}</Label>
<Label htmlFor="code-language">{t('components.richEditor.modals.codeBlock.languageLabel')}</Label>
<Input
id="code-language"
value={codeLanguage}
onChange={(e) => setCodeLanguage(e.target.value)}
placeholder={t('richEditor.modals.codeBlock.languagePlaceholder')}
placeholder={t('components.richEditor.modals.codeBlock.languagePlaceholder')}
/>
<div className="text-sm text-muted-foreground">
<p className="font-medium mb-1">{t('richEditor.modals.codeBlock.popularLanguages')}</p>
<p>{t('richEditor.modals.codeBlock.languagesList')}</p>
<p className="mt-2">{t('richEditor.modals.codeBlock.noHighlighting')}</p>
<p className="font-medium mb-1">{t('components.richEditor.modals.codeBlock.popularLanguages')}</p>
<p>{t('components.richEditor.modals.codeBlock.languagesList')}</p>
<p className="mt-2">{t('components.richEditor.modals.codeBlock.noHighlighting')}</p>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowCodeBlockModal(false)}>
{t('richEditor.modals.codeBlock.cancel')}
{t('components.richEditor.modals.codeBlock.cancel')}
</Button>
<Button onClick={handleCodeBlockSubmit}>
{t('richEditor.modals.codeBlock.addCodeBlock')}
{t('components.richEditor.modals.codeBlock.addCodeBlock')}
</Button>
</DialogFooter>
</DialogContent>

View File

@@ -32,8 +32,8 @@ export const SkillCategoryForm = ({
const schema = z.object({
name: z.object({
pl: z.string().min(1, t("validation.required") as string),
en: z.string().min(1, t("validation.required") as string),
pl: z.string().min(1, t("components.validation.required") as string),
en: z.string().min(1, t("components.validation.required") as string),
}),
});

View File

@@ -65,8 +65,8 @@ export const SkillForm = ({
const schema = z.object({
name: z.object({
pl: z.string().min(1, t("validation.required") as string),
en: z.string().min(1, t("validation.required") as string),
pl: z.string().min(1, t("components.validation.required") as string),
en: z.string().min(1, t("components.validation.required") as string),
}),
categoryId: z.string().nullable(),
iconName: z.string().optional().nullable(),

View File

@@ -64,8 +64,8 @@ export const TopBarForm = ({
const schema = z.object({
name: z.object({
pl: z.string().min(1, t("validation.required") as string),
en: z.string().min(1, t("validation.required") as string),
pl: z.string().min(1, t("components.validation.required") as string),
en: z.string().min(1, t("components.validation.required") as string),
}),
iconName: z.string().nullable().optional(),
iconProvider: z.string().nullable().optional(),

View File

@@ -24,7 +24,7 @@ export default function UserMenu() {
if (!session) {
return (
<Button variant="outline" asChild>
<Link href="/login">{t('userMenu.signIn')}</Link>
<Link href="/login">{t('components.userMenu.signIn')}</Link>
</Button>
);
}
@@ -35,7 +35,7 @@ export default function UserMenu() {
<Button variant="outline">{session.user.name}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-card">
<DropdownMenuLabel>{t('userMenu.myAccount')}</DropdownMenuLabel>
<DropdownMenuLabel>{t('components.userMenu.myAccount')}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>{session.user.email}</DropdownMenuItem>
<DropdownMenuItem asChild>
@@ -52,7 +52,7 @@ export default function UserMenu() {
});
}}
>
{t('userMenu.signOut')}
{t('components.userMenu.signOut')}
</Button>
</DropdownMenuItem>
</DropdownMenuContent>

View File

@@ -67,27 +67,27 @@ export default function CommentsSection({ postId }: CommentsSectionProps) {
const newErrors: Partial<CommentFormData> = {};
if (!formData.authorName.trim()) {
newErrors.authorName = t('validation.required');
newErrors.authorName = t('components.validation.required');
}
if (!formData.authorEmail.trim()) {
newErrors.authorEmail = t('validation.required');
newErrors.authorEmail = t('components.validation.required');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.authorEmail)) {
newErrors.authorEmail = t('validation.invalid');
newErrors.authorEmail = t('components.validation.invalid');
}
if (formData.authorWebsite && !/^https?:\/\/.+/.test(formData.authorWebsite)) {
newErrors.authorWebsite = t('validation.invalidUrl');
newErrors.authorWebsite = t('components.validation.invalidUrl');
}
if (!formData.content.trim()) {
newErrors.content = t('validation.required');
newErrors.content = t('components.validation.required');
} else if (formData.content.trim().length < 10) {
newErrors.content = t('validation.minLength', { min: 10 });
newErrors.content = t('components.validation.minLength', { min: 10 });
}
if (!formData.turnstileToken) {
newErrors.turnstileToken = t('validation.turnstileRequired');
newErrors.turnstileToken = t('components.validation.turnstileRequired');
}
setErrors(newErrors);

View File

@@ -43,13 +43,13 @@ export default function ContactClientWrapper({ locale }: Props) {
const sendEmailMutation = useMutation(
trpc.mail.sendContactForm.mutationOptions({
onSuccess: () => {
toast.success(t('contact.form.success'));
toast.success(t('pages.contact.form.success'));
setFormData({ name: '', email: '', message: '', turnstileToken: '' });
setErrors({});
turnstileRef.current?.reset();
},
onError: (error) => {
toast.error(t('contact.form.error'));
toast.error(t('pages.contact.form.error'));
console.error('Error sending email:', error);
}
})
@@ -59,23 +59,23 @@ export default function ContactClientWrapper({ locale }: Props) {
const newErrors: Partial<FormData> = {};
if (!formData.name.trim()) {
newErrors.name = t('validation.required');
newErrors.name = t('components.validation.required');
}
if (!formData.email.trim()) {
newErrors.email = t('validation.required');
newErrors.email = t('components.validation.required');
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = t('validation.invalid');
newErrors.email = t('components.validation.invalid');
}
if (!formData.message.trim()) {
newErrors.message = t('validation.required');
newErrors.message = t('components.validation.required');
} else if (formData.message.trim().length < 10) {
newErrors.message = t('validation.minLength', { min: 10 });
newErrors.message = t('components.validation.minLength', { min: 10 });
}
if (!formData.turnstileToken) {
newErrors.turnstileToken = t('validation.turnstileRequired');
newErrors.turnstileToken = t('components.validation.turnstileRequired');
}
setErrors(newErrors);
@@ -112,7 +112,7 @@ export default function ContactClientWrapper({ locale }: Props) {
<MainLayout>
<div className="max-w-screen-lg mx-auto px-6 md:px-24 py-8">
<h1 className="text-3xl font-bold mb-6 text-gray-800 dark:text-gray-100">
{t('contact.title')}
{t('pages.contact.title')}
</h1>
{isLoading ? (
@@ -125,7 +125,7 @@ export default function ContactClientWrapper({ locale }: Props) {
<Card className="border border-gray-200 dark:border-gray-800">
<CardContent className="p-6">
<h2 className="text-xl font-semibold mb-4 text-gray-800 dark:text-gray-100">
{t('contact.directContact')}
{t('pages.contact.directContact')}
</h2>
<div className="space-y-2">
@@ -175,16 +175,16 @@ export default function ContactClientWrapper({ locale }: Props) {
<Card className="border border-gray-200 dark:border-gray-800">
<CardContent className="p-6">
<h2 className="text-xl font-semibold mb-4 text-gray-800 dark:text-gray-100">
{t('contact.quickMessage')}
{t('pages.contact.quickMessage')}
</h2>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="name">{t('contact.form.name')}</Label>
<Label htmlFor="name">{t('pages.contact.form.name')}</Label>
<Input
type="text"
id="name"
placeholder={t('contact.form.name')}
placeholder={t('pages.contact.form.name')}
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
className={errors.name ? 'border-rose-500' : ''}
@@ -195,11 +195,11 @@ export default function ContactClientWrapper({ locale }: Props) {
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="email">{t('contact.form.email')}</Label>
<Label htmlFor="email">{t('pages.contact.form.email')}</Label>
<Input
type="email"
id="email"
placeholder={t('contact.form.email')}
placeholder={t('pages.contact.form.email')}
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
className={errors.email ? 'border-rose-500' : ''}
@@ -210,10 +210,10 @@ export default function ContactClientWrapper({ locale }: Props) {
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="message">{t('contact.form.message')}</Label>
<Label htmlFor="message">{t('pages.contact.form.message')}</Label>
<Textarea
id="message"
placeholder={t('contact.form.message')}
placeholder={t('pages.contact.form.message')}
className={`min-h-[120px] ${errors.message ? 'border-rose-500' : ''}`}
value={formData.message}
onChange={(e) => handleInputChange('message', e.target.value)}
@@ -240,7 +240,7 @@ export default function ContactClientWrapper({ locale }: Props) {
className="w-full bg-rose-600 hover:bg-rose-700 text-white"
disabled={sendEmailMutation.isPending}
>
{sendEmailMutation.isPending ? t('common.saving') : t('contact.form.send')}
{sendEmailMutation.isPending ? t('common.saving') : t('pages.contact.form.send')}
</Button>
</form>
</CardContent>

View File

@@ -33,7 +33,7 @@ export default function Footer() {
<div className="flex items-center gap-6 flex-wrap justify-center">
<div className="flex flex-col items-center gap-1">
<span className="text-sm text-gray-700 dark:text-gray-200">{t('theme.toggle')}</span>
<span className="text-sm text-gray-700 dark:text-gray-200">{t('components.theme.toggle')}</span>
<Tabs
id="theme-tabs"
defaultValue={theme}
@@ -47,7 +47,7 @@ export default function Footer() {
aria-controls="theme-light-content"
tabIndex={theme === 'light' ? 0 : -1}>
<Icon name="Sun" provider="lu" />
<span>{t('theme.light')}</span>
<span>{t('components.theme.light')}</span>
</TabsTrigger>
<TabsTrigger
value="dark"
@@ -56,7 +56,7 @@ export default function Footer() {
aria-controls="theme-dark-content"
tabIndex={theme === 'dark' ? 0 : -1}>
<Icon name="Moon" provider="lu" />
<span>{t('theme.dark')}</span>
<span>{t('components.theme.dark')}</span>
</TabsTrigger>
</TabsList>
<TabsContent value="light" id="theme-light-content" className="hidden" />
@@ -65,7 +65,7 @@ export default function Footer() {
</div>
<div className="flex flex-col items-center gap-1">
<span className="text-sm text-gray-700 dark:text-gray-200">{t('language.switch')}</span>
<span className="text-sm text-gray-700 dark:text-gray-200">{t('components.language.switch')}</span>
<Tabs
id="language-tabs"
defaultValue={locale}
@@ -110,7 +110,7 @@ export default function Footer() {
</Link>
</div>
<div>
&copy; {currentYear} {t('footer.copyright')}
&copy; {currentYear} {t('components.footer.copyright')}
</div>
</div>
</div>

View File

@@ -85,7 +85,7 @@ export default function NavigationBar() {
})}
</ul>
<div className="hidden md:flex text-sm text-gray-900 dark:text-gray-100 mt-4 text-center items-center justify-center">
<Icon name="MoveLeft" provider="lu" className="inline mr-1" />{t('navigation.keyboardHint')}<Icon name="MoveRight" provider="lu" className="inline ml-1" />
<Icon name="MoveLeft" provider="lu" className="inline mr-1" />{t('components.navigation.keyboardHint')}<Icon name="MoveRight" provider="lu" className="inline ml-1" />
</div>
</div>
)}

View File

@@ -54,7 +54,7 @@ export default function NewsletterSignup({
e.preventDefault();
if (!email.trim()) {
toast.error(t('validation.required'));
toast.error(t('components.validation.required'));
return;
}

View File

@@ -148,7 +148,7 @@ export default function ProjectsClientWrapper({ locale }: Props) {
<MainLayout>
<div className="max-w-screen-lg mx-auto px-6 md:px-24 py-8">
<h1 className="text-3xl font-bold mb-4 text-gray-800 dark:text-gray-100">
{t('projects.title')}
{t('pages.projects.title')}
</h1>
{isLoading ? (
@@ -220,7 +220,7 @@ export default function ProjectsClientWrapper({ locale }: Props) {
onClick={() => project.repoUrl && window.open(project.repoUrl, '_blank', 'noopener,noreferrer')}
>
<Icon name="github" provider="si" className="text-rose-600 dark:text-rose-400" />
{t('projects.github')}
{t('pages.projects.github')}
</Button>
)}
{project.repoUrl2 && (
@@ -231,7 +231,7 @@ export default function ProjectsClientWrapper({ locale }: Props) {
onClick={() => project.repoUrl2 && window.open(project.repoUrl2, '_blank', 'noopener,noreferrer')}
>
<Icon name="gitea" provider="si" className="text-rose-600 dark:text-rose-400" />
{t('projects.gitea')}
{t('pages.projects.gitea')}
</Button>
)}
{project.url && (
@@ -242,7 +242,7 @@ export default function ProjectsClientWrapper({ locale }: Props) {
onClick={() => project.url && window.open(project.url, '_blank', 'noopener,noreferrer')}
>
<Icon name="ExternalLink" provider="lu" className="text-rose-600 dark:text-rose-400" />
{t('projects.demo')}
{t('pages.projects.demo')}
</Button>
)}
</CardFooter>
@@ -251,7 +251,7 @@ export default function ProjectsClientWrapper({ locale }: Props) {
</div>
) : (
<div className="text-center py-4 text-gray-600 dark:text-gray-400">
{t('projects.noProjects')}
{t('pages.projects.noProjects')}
</div>
)}
</div>

View File

@@ -32,7 +32,7 @@ export default function SignInForm({
},
onSubmit: async ({ value }) => {
if (!turnstileToken) {
setTurnstileError(t('validation.turnstileRequired'));
setTurnstileError(t('components.validation.turnstileRequired'));
return;
}
@@ -44,7 +44,7 @@ export default function SignInForm({
{
onSuccess: () => {
router.push("/admin")
toast.success(t('login.success'));
toast.success(t('pages.login.success'));
},
onError: (error) => {
toast.error(error.error.message);
@@ -69,7 +69,7 @@ export default function SignInForm({
const handleTurnstileError = () => {
setTurnstileToken("");
setTurnstileError(t('validation.turnstileRequired'));
setTurnstileError(t('components.validation.turnstileRequired'));
};
if (isPending) {
@@ -78,7 +78,7 @@ export default function SignInForm({
return (
<div className="mx-auto w-full mt-10 max-w-md p-6">
<h1 className="mb-6 text-center text-3xl font-bold">{t('login.title')}</h1>
<h1 className="mb-6 text-center text-3xl font-bold">{t('pages.login.title')}</h1>
<form
onSubmit={(e) => {
@@ -92,7 +92,7 @@ export default function SignInForm({
<form.Field name="email">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>{t('login.email')}</Label>
<Label htmlFor={field.name}>{t('pages.login.email')}</Label>
<Input
id={field.name}
name={field.name}
@@ -115,7 +115,7 @@ export default function SignInForm({
<form.Field name="password">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>{t('login.password')}</Label>
<Label htmlFor={field.name}>{t('pages.login.password')}</Label>
<Input
id={field.name}
name={field.name}
@@ -153,7 +153,7 @@ export default function SignInForm({
className="w-full"
disabled={!state.canSubmit || state.isSubmitting}
>
{state.isSubmitting ? "Submitting..." : t('login.submit')}
{state.isSubmitting ? t('common.submitting') : t('pages.login.submit')}
</Button>
)}
</form.Subscribe>
@@ -161,13 +161,13 @@ export default function SignInForm({
{!disabledRegistration && (
<div className="mt-4 text-center">
<p className="text-sm text-gray-600">{t('login.noAccount')}</p>
<p className="text-sm text-gray-600">{t('pages.login.noAccount')}</p>
<Button
variant="link"
onClick={onSwitchToSignUp}
className="text-indigo-600 hover:text-indigo-800"
>
{t('login.register')}
{t('pages.login.register')}
</Button>
</div>
)}

View File

@@ -31,7 +31,7 @@ export default function SignUpForm({
},
onSubmit: async ({ value }) => {
if (!turnstileToken) {
setTurnstileError(t('validation.turnstileRequired'));
setTurnstileError(t('components.validation.turnstileRequired'));
return;
}
@@ -44,7 +44,7 @@ export default function SignUpForm({
{
onSuccess: () => {
router.push("/admin");
toast.success(t('register.success'));
toast.success(t('pages.register.success'));
},
onError: (error) => {
toast.error(error.error.message);
@@ -70,7 +70,7 @@ export default function SignUpForm({
const handleTurnstileError = () => {
setTurnstileToken("");
setTurnstileError(t('validation.turnstileRequired'));
setTurnstileError(t('components.validation.turnstileRequired'));
};
if (isPending) {
@@ -79,7 +79,7 @@ export default function SignUpForm({
return (
<div className="mx-auto w-full mt-10 max-w-md p-6">
<h1 className="mb-6 text-center text-3xl font-bold">{t('register.title')}</h1>
<h1 className="mb-6 text-center text-3xl font-bold">{t('pages.register.title')}</h1>
<form
onSubmit={(e) => {
@@ -93,7 +93,7 @@ export default function SignUpForm({
<form.Field name="name">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>{t('register.name')}</Label>
<Label htmlFor={field.name}>{t('pages.register.name')}</Label>
<Input
id={field.name}
name={field.name}
@@ -115,7 +115,7 @@ export default function SignUpForm({
<form.Field name="email">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>{t('login.email')}</Label>
<Label htmlFor={field.name}>{t('pages.login.email')}</Label>
<Input
id={field.name}
name={field.name}
@@ -138,7 +138,7 @@ export default function SignUpForm({
<form.Field name="password">
{(field) => (
<div className="space-y-2">
<Label htmlFor={field.name}>{t('login.password')}</Label>
<Label htmlFor={field.name}>{t('pages.login.password')}</Label>
<Input
id={field.name}
name={field.name}
@@ -176,20 +176,20 @@ export default function SignUpForm({
className="w-full"
disabled={!state.canSubmit || state.isSubmitting}
>
{state.isSubmitting ? "Submitting..." : t('register.submit')}
{state.isSubmitting ? t('common.submitting') : t('pages.register.submit')}
</Button>
)}
</form.Subscribe>
</form>
<div className="mt-4 text-center">
<p className="text-sm text-gray-600">{t('register.haveAccount')}</p>
<p className="text-sm text-gray-600">{t('pages.register.haveAccount')}</p>
<Button
variant="link"
onClick={onSwitchToSignIn}
className="text-indigo-600 hover:text-indigo-800"
>
{t('register.signIn')}
{t('pages.register.signIn')}
</Button>
</div>
</div>

View File

@@ -22,7 +22,7 @@ export default function SkillsClientWrapper({ locale }: Props) {
<MainLayout>
<div className="max-w-screen-lg mx-auto px-6 md:px-24 py-8">
<h1 className="text-3xl font-bold mb-6 text-gray-800 dark:text-gray-100">
{t('skills.title')}
{t('pages.skills.title')}
</h1>
{isLoading ? (
@@ -81,7 +81,7 @@ export default function SkillsClientWrapper({ locale }: Props) {
))
) : (
<div className="text-center py-4 text-gray-600 dark:text-gray-400">
{t('skills.noSkills')}
{t('pages.skills.noSkills')}
</div>
)}
</div>
@@ -91,7 +91,7 @@ export default function SkillsClientWrapper({ locale }: Props) {
</Tabs>
) : (
<div className="text-center py-8 text-gray-600 dark:text-gray-400">
{t('skills.noCategories')}
{t('pages.skills.noCategories')}
</div>
)}
</div>

View File

@@ -0,0 +1,65 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { Icon } from '@/components/icon';
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<Icon name="ChevronDown" provider="lu" className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }

View File

@@ -1,16 +1,43 @@
import {getRequestConfig} from 'next-intl/server';
import {hasLocale} from 'next-intl';
import {routing} from './routing';
import fs from 'fs';
import path from 'path';
async function loadMessagesFromDirectory(locale: string) {
let messages: Record<string, any> = {};
try {
const messagesDirectory = path.join(process.cwd(), 'messages', locale);
if (fs.existsSync(messagesDirectory)) {
const files = fs.readdirSync(messagesDirectory)
.filter(file => file.endsWith('.json'));
for (const file of files) {
const moduleName = file.replace('.json', '');
const moduleMessages = (await import(`../../messages/${locale}/${file}`)).default;
messages[moduleName] = moduleMessages;
}
} else {
messages = (await import(`../../messages/${locale}.json`)).default;
}
} catch (error) {
console.error(`Failed to load messages for ${locale}`, error);
messages = (await import(`../../messages/${locale}.json`)).default;
}
return messages;
}
export default getRequestConfig(async ({requestLocale}) => {
// Typically corresponds to the `[locale]` segment
const requested = await requestLocale;
const locale = hasLocale(routing.locales, requested)
? requested
: routing.defaultLocale;
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default
messages: await loadMessagesFromDirectory(locale)
};
});

View File

@@ -40,6 +40,7 @@
"dependencies": {
"@hookform/resolvers": "^5.1.0",
"@marsidev/react-turnstile": "^1.1.0",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
@@ -248,6 +249,8 @@
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.2", "", {}, ""],
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-collapsible": "1.1.11", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],
"@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.4", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" } }, ""],