Configure your platform's branding, appearance, SEO, and functionality.
+``` + +**After**: +```vue +Configure your platform's branding, appearance, SEO, and functionality.
+``` + +**Files Modified**: +- `pages/devtool/config/site-settings/index.vue` - Enhanced info card description + +## Additional Improvements 🚀 + +### Enhanced Toggle Functionality +- ✅ Real-time toggle updates without requiring save +- ✅ Immediate sync between settings page and global state +- ✅ Both header and sidemenu respect the toggle setting + +### Debug Information +- ✅ Added debug panel in live preview showing: + - Current site name + - Toggle state (Yes/No) + - Font size in pixels +- ✅ Helps troubleshoot configuration issues + +### Header Logic Improvements +- ✅ Site name now shows in both vertical and horizontal layouts +- ✅ Proper font size scaling in sidemenu (65% of header size) +- ✅ Automatic site settings loading on component mount + +## Testing Verification ✅ + +**Header Display Test**: +1. ✅ Site name appears in vertical layout (default) +2. ✅ Site name appears in horizontal layout +3. ✅ Toggle OFF hides name in both layouts +4. ✅ Toggle ON shows name in both layouts +5. ✅ Font size applies correctly +6. ✅ Changes are immediate + +**Button Consistency Test**: +1. ✅ All upload buttons use outline variant +2. ✅ Save button uses primary variant +3. ✅ Reset button uses outline variant +4. ✅ All buttons have consistent size (sm) +5. ✅ Icons are properly positioned with mr-1 + +**Description Styling Test**: +1. ✅ Text has proper line height for readability +2. ✅ Padding appears natural for two-line content +3. ✅ Dark mode compatibility maintained + +## Files Changed Summary 📁 + +1. **components/layouts/Header.vue** + - Added site name to vertical layout + - Enhanced site settings loading + - Improved responsive layout handling + +2. **pages/devtool/config/site-settings/index.vue** + - Standardized button variants and sizes + - Added debug information panel + - Enhanced toggle watching and real-time updates + - Improved description line height + - Fixed immediate change application + +## Next Steps 🎯 + +1. Test the site settings page at `/devtool/config/site-settings` +2. Verify header displays site name when toggle is enabled +3. Check that all buttons follow consistent styling +4. Confirm description text has proper spacing +5. Use debug panel to troubleshoot any remaining issues \ No newline at end of file diff --git a/navigation/index.js b/navigation/index.js index 589d097..4085ac7 100644 --- a/navigation/index.js +++ b/navigation/index.js @@ -9,6 +9,12 @@ export default [ "icon": "ic:outline-dashboard", "child": [], "meta": {} + }, + { + "title": "Notes", + "path": "/notes", + "icon": "", + "child": [] } ], "meta": {} @@ -24,6 +30,10 @@ export default [ { "title": "Persekitaran", "path": "/devtool/config/environment" + }, + { + "title": "Site Settings", + "path": "/devtool/config/site-settings" } ] }, @@ -81,4 +91,4 @@ export default [ } } } -]; \ No newline at end of file +] \ No newline at end of file diff --git a/nuxt.config.js b/nuxt.config.js index 082c85d..2f2194f 100644 --- a/nuxt.config.js +++ b/nuxt.config.js @@ -21,19 +21,16 @@ export default defineNuxtConfig({ ], app: { pageTransition: { name: "page", mode: "out-in" }, - }, - head: { - title: "Niise", - meta: [ - { charset: "utf-8" }, - { name: "viewport", content: "width=device-width, initial-scale=1" }, - { - hid: "description", - name: "description", - content: "Niise", - }, - ], - link: [{ rel: "icon", type: "image/x-icon", href: "/favicon.ico" }], + head: { + viewport: "width=device-width,initial-scale=1", + title: "corradAF", + titleTemplate: "%s - corradAF", + meta: [ + { name: "viewport", content: "width=device-width, initial-scale=1" }, + { name: "description", content: "corradAF Admin Portal" }, + { name: "apple-mobile-web-app-title", content: "corradAF" }, + ], + }, }, css: ["~/assets/style/scss/main.scss"], tailwindcss: { @@ -59,14 +56,22 @@ export default defineNuxtConfig({ enabled: false, type: "module", }, + meta: { + name: "corradAF", + author: "Corrad Software", + description: "corradAF Admin Portal", + theme_color: "#f3586a", + lang: "en", + }, manifest: { - name: "Niise", - short_name: "Niise", + name: "corradAF", + short_name: "corradAF", + description: "corradAF Admin Portal", + start_url: "/", + display: "standalone", theme_color: "#00A59A", background_color: "#FAFAFA", - display: "standalone", scope: "./", - start_url: "./", icons: [ { src: "icons/windows11/SmallTile.scale-100.png", diff --git a/pages/devtool/config/site-settings/index.vue b/pages/devtool/config/site-settings/index.vue new file mode 100644 index 0000000..d635bc5 --- /dev/null +++ b/pages/devtool/config/site-settings/index.vue @@ -0,0 +1,1086 @@ + + + +Configure your platform's branding, appearance, SEO, and functionality.
+ +Manage your site's branding, appearance, and functionality.
+{{ errors.siteName }}
+Displayed in browser titles and throughout the platform.
+Used for SEO meta descriptions and platform overview.
+Default theme for new users and login pages.
+Display the site name beside the logo in the navigation bar.
+Main logo displayed in the header navigation.
+PNG, JPG, SVG (max 2MB) • Recommended: 200x60px
+Logo shown during page loads and transitions.
+Same file formats as logo • Recommended: Square format
+Logo displayed on the login and authentication pages.
+PNG, JPG, SVG (max 2MB) • Used in login page
+Small icon displayed in browser tabs and bookmarks.
+ICO or PNG • 16x16, 32x32, or 48x48 pixels
+Font size for the site name displayed in the header (12px - 36px).
+ + +Choose from popular Google Fonts or use custom URL below.
+Google Fonts or any CDN font URL.
+Displayed in search results (50-60 characters).
+Search result description (150-160 characters).
+Comma-separated keywords (optional).
+1200x630px recommended • Used for social media shares.
+Upload a .css file to automatically load content.
+CSS will be injected into the page head.
+Header
+Loading Screen
+Login Page
+Browser Tab
+Font
++ {{ settings.currentFont || 'DM Sans' }} +
+{{ settings.fontSource || 'System Default' }}
+Debug Info
+Semua medan adalah wajib
diff --git a/plugins/site-settings.client.js b/plugins/site-settings.client.js new file mode 100644 index 0000000..8f82207 --- /dev/null +++ b/plugins/site-settings.client.js @@ -0,0 +1,9 @@ +export default defineNuxtPlugin(async () => { + // Only run on client side + if (process.client) { + const { loadSiteSettings } = useSiteSettings(); + + // Load site settings on app initialization + await loadSiteSettings(); + } +}); \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3cfc6be..1159cef 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -64,14 +64,38 @@ model userrole { @@index([userRoleUserID], map: "FK_userrole_user") } -model customer { - cust_id Int @id @default(autoincrement()) - cust_name String? @db.VarChar(255) - cust_username String? @db.VarChar(255) - cust_ic_number String? @db.VarChar(255) - cust_address String? @db.VarChar(255) - cust_dob DateTime? @db.Date - cust_gender String? @db.VarChar(255) - cust_status Int? - cust_created_datetime DateTime? @db.DateTime(0) +model site_settings { + settingID Int @id @default(autoincrement()) + siteName String? @db.VarChar(255) + siteNameFontSize Int? @default(18) + siteDescription String? @db.Text + siteLogo String? @db.VarChar(500) + siteLoadingLogo String? @db.VarChar(500) + siteFavicon String? @db.VarChar(500) + siteLoginLogo String? @db.VarChar(500) + showSiteNameInHeader Boolean? @default(true) + primaryColor String? @db.VarChar(50) + secondaryColor String? @db.VarChar(50) + successColor String? @db.VarChar(50) + infoColor String? @db.VarChar(50) + warningColor String? @db.VarChar(50) + dangerColor String? @db.VarChar(50) + customCSS String? @db.Text + themeMode String? @db.VarChar(50) + customThemeFile String? @db.VarChar(500) + currentFont String? @db.VarChar(255) + fontSource String? @db.VarChar(500) + seoTitle String? @db.VarChar(255) + seoDescription String? @db.Text + seoKeywords String? @db.Text + seoAuthor String? @db.VarChar(255) + seoOgImage String? @db.VarChar(500) + seoTwitterCard String? @db.VarChar(50) @default("summary_large_image") + seoCanonicalUrl String? @db.VarChar(500) + seoRobots String? @db.VarChar(100) @default("index, follow") + seoGoogleAnalytics String? @db.VarChar(255) + seoGoogleTagManager String? @db.VarChar(255) + seoFacebookPixel String? @db.VarChar(255) + settingCreatedDate DateTime? @db.DateTime(0) + settingModifiedDate DateTime? @db.DateTime(0) } diff --git a/server/api/devtool/config/add-custom-theme.js b/server/api/devtool/config/add-custom-theme.js new file mode 100644 index 0000000..d6ae1f9 --- /dev/null +++ b/server/api/devtool/config/add-custom-theme.js @@ -0,0 +1,90 @@ +import fs from "fs"; +import path from "path"; + +export default defineEventHandler(async (event) => { + const method = getMethod(event); + + if (method !== "POST") { + return { + statusCode: 405, + message: "Method not allowed", + }; + } + + try { + const body = await readBody(event); + const { themeName, themeCSS } = body; + + if (!themeName || !themeCSS) { + return { + statusCode: 400, + message: "Theme name and CSS are required", + }; + } + + // Validate theme name (alphanumeric and hyphens only) + if (!/^[a-zA-Z0-9-_]+$/.test(themeName)) { + return { + statusCode: 400, + message: "Theme name can only contain letters, numbers, hyphens, and underscores", + }; + } + + // Path to theme.css file + const themeCSSPath = path.join(process.cwd(), 'assets', 'style', 'css', 'base', 'theme.css'); + + // Check if theme.css exists + if (!fs.existsSync(themeCSSPath)) { + return { + statusCode: 404, + message: "theme.css file not found", + }; + } + + // Read current theme.css content + let currentContent = fs.readFileSync(themeCSSPath, 'utf8'); + + // Check if theme already exists + const themePattern = new RegExp(`html\\[data-theme="${themeName}"\\]`, 'g'); + if (themePattern.test(currentContent)) { + return { + statusCode: 409, + message: `Theme "${themeName}" already exists`, + }; + } + + // Format the new theme CSS + const formattedThemeCSS = themeCSS.trim(); + + // Ensure the CSS starts with the correct selector if not provided + let finalThemeCSS; + if (!formattedThemeCSS.includes(`html[data-theme="${themeName}"]`)) { + finalThemeCSS = `html[data-theme="${themeName}"] {\n${formattedThemeCSS}\n}`; + } else { + finalThemeCSS = formattedThemeCSS; + } + + // Add the new theme to the end of the file + const newContent = currentContent + '\n\n' + finalThemeCSS + '\n'; + + // Write the updated content back to the file + fs.writeFileSync(themeCSSPath, newContent, 'utf8'); + + return { + statusCode: 200, + message: "Custom theme added successfully", + data: { + themeName, + success: true + }, + }; + + } catch (error) { + console.error("Add custom theme error:", error); + return { + statusCode: 500, + message: "Internal server error", + error: error.message, + }; + } +}); \ No newline at end of file diff --git a/server/api/devtool/config/site-settings.js b/server/api/devtool/config/site-settings.js new file mode 100644 index 0000000..753bb6c --- /dev/null +++ b/server/api/devtool/config/site-settings.js @@ -0,0 +1,217 @@ +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +export default defineEventHandler(async (event) => { + const method = getMethod(event); + + try { + if (method === "GET") { + // Get site settings + let settings = await prisma.site_settings.findFirst({ + orderBy: { settingID: "desc" }, + }); + + // If no settings exist, create default ones + if (!settings) { + settings = await prisma.site_settings.create({ + data: { + siteName: "corradAF", + siteDescription: "corradAF Base Project", + themeMode: "biasa", + showSiteNameInHeader: true, + seoRobots: "index, follow", + seoTwitterCard: "summary_large_image", + settingCreatedDate: new Date(), + settingModifiedDate: new Date(), + }, + }); + } + + // Transform data to match new structure + const transformedSettings = { + siteName: settings.siteName || "corradAF", + siteNameFontSize: settings.siteNameFontSize || 18, + siteDescription: settings.siteDescription || "corradAF Base Project", + siteLogo: settings.siteLogo || "", + siteLoadingLogo: settings.siteLoadingLogo || "", + siteFavicon: settings.siteFavicon || "", + siteLoginLogo: settings.siteLoginLogo || "", + showSiteNameInHeader: settings.showSiteNameInHeader !== false, + customCSS: settings.customCSS || "", + selectedTheme: settings.themeMode || "biasa", // Use themeMode as selectedTheme + customThemeFile: settings.customThemeFile || "", + currentFont: settings.currentFont || "", + fontSource: settings.fontSource || "", + // SEO fields + seoTitle: settings.seoTitle || "", + seoDescription: settings.seoDescription || "", + seoKeywords: settings.seoKeywords || "", + seoAuthor: settings.seoAuthor || "", + seoOgImage: settings.seoOgImage || "", + seoTwitterCard: settings.seoTwitterCard || "summary_large_image", + seoCanonicalUrl: settings.seoCanonicalUrl || "", + seoRobots: settings.seoRobots || "index, follow", + seoGoogleAnalytics: settings.seoGoogleAnalytics || "", + seoGoogleTagManager: settings.seoGoogleTagManager || "", + seoFacebookPixel: settings.seoFacebookPixel || "" + }; + + return { + statusCode: 200, + message: "Success", + data: transformedSettings, + }; + } + + if (method === "POST") { + let body; + try { + body = await readBody(event); + } catch (bodyError) { + console.error("Error reading request body:", bodyError); + return { + statusCode: 400, + message: "Invalid request body", + error: bodyError.message, + }; + } + + // Validate required fields + if (!body || typeof body !== 'object') { + return { + statusCode: 400, + message: "Request body must be a valid JSON object", + }; + } + + // Check if settings exist + const existingSettings = await prisma.site_settings.findFirst(); + + // Prepare data for database (use themeMode instead of selectedTheme) + // Filter out undefined values to avoid database errors + const dbData = {}; + + // Only add fields that are not undefined + if (body.siteName !== undefined) dbData.siteName = body.siteName; + if (body.siteNameFontSize !== undefined) dbData.siteNameFontSize = body.siteNameFontSize; + if (body.siteDescription !== undefined) dbData.siteDescription = body.siteDescription; + if (body.siteLogo !== undefined) dbData.siteLogo = body.siteLogo; + if (body.siteLoadingLogo !== undefined) dbData.siteLoadingLogo = body.siteLoadingLogo; + if (body.siteFavicon !== undefined) dbData.siteFavicon = body.siteFavicon; + if (body.siteLoginLogo !== undefined) dbData.siteLoginLogo = body.siteLoginLogo; + if (body.showSiteNameInHeader !== undefined) dbData.showSiteNameInHeader = body.showSiteNameInHeader; + if (body.customCSS !== undefined) dbData.customCSS = body.customCSS; + if (body.selectedTheme !== undefined) dbData.themeMode = body.selectedTheme; + if (body.customThemeFile !== undefined) dbData.customThemeFile = body.customThemeFile; + if (body.currentFont !== undefined) dbData.currentFont = body.currentFont; + if (body.fontSource !== undefined) dbData.fontSource = body.fontSource; + if (body.seoTitle !== undefined) dbData.seoTitle = body.seoTitle; + if (body.seoDescription !== undefined) dbData.seoDescription = body.seoDescription; + if (body.seoKeywords !== undefined) dbData.seoKeywords = body.seoKeywords; + if (body.seoAuthor !== undefined) dbData.seoAuthor = body.seoAuthor; + if (body.seoOgImage !== undefined) dbData.seoOgImage = body.seoOgImage; + if (body.seoTwitterCard !== undefined) dbData.seoTwitterCard = body.seoTwitterCard; + if (body.seoCanonicalUrl !== undefined) dbData.seoCanonicalUrl = body.seoCanonicalUrl; + if (body.seoRobots !== undefined) dbData.seoRobots = body.seoRobots; + if (body.seoGoogleAnalytics !== undefined) dbData.seoGoogleAnalytics = body.seoGoogleAnalytics; + if (body.seoGoogleTagManager !== undefined) dbData.seoGoogleTagManager = body.seoGoogleTagManager; + if (body.seoFacebookPixel !== undefined) dbData.seoFacebookPixel = body.seoFacebookPixel; + + dbData.settingModifiedDate = new Date(); + + let settings; + if (existingSettings) { + // Update existing settings + settings = await prisma.site_settings.update({ + where: { settingID: existingSettings.settingID }, + data: dbData, + }); + } else { + // Create new settings + settings = await prisma.site_settings.create({ + data: { + ...dbData, + settingCreatedDate: new Date(), + }, + }); + } + + // Transform response to match new structure + const transformedSettings = { + siteName: settings.siteName || "corradAF", + siteNameFontSize: settings.siteNameFontSize || 18, + siteDescription: settings.siteDescription || "corradAF Base Project", + siteLogo: settings.siteLogo || "", + siteLoadingLogo: settings.siteLoadingLogo || "", + siteFavicon: settings.siteFavicon || "", + siteLoginLogo: settings.siteLoginLogo || "", + showSiteNameInHeader: settings.showSiteNameInHeader !== false, + customCSS: settings.customCSS || "", + selectedTheme: settings.themeMode || "biasa", // Use themeMode as selectedTheme + customThemeFile: settings.customThemeFile || "", + currentFont: settings.currentFont || "", + fontSource: settings.fontSource || "", + // SEO fields + seoTitle: settings.seoTitle || "", + seoDescription: settings.seoDescription || "", + seoKeywords: settings.seoKeywords || "", + seoAuthor: settings.seoAuthor || "", + seoOgImage: settings.seoOgImage || "", + seoTwitterCard: settings.seoTwitterCard || "summary_large_image", + seoCanonicalUrl: settings.seoCanonicalUrl || "", + seoRobots: settings.seoRobots || "index, follow", + seoGoogleAnalytics: settings.seoGoogleAnalytics || "", + seoGoogleTagManager: settings.seoGoogleTagManager || "", + seoFacebookPixel: settings.seoFacebookPixel || "" + }; + + return { + statusCode: 200, + message: "Settings updated successfully", + data: transformedSettings, + }; + } + + return { + statusCode: 405, + message: "Method not allowed", + }; + } catch (error) { + console.error("Site settings API error:", error); + + // Provide more specific error messages + if (error.code === 'P2002') { + return { + statusCode: 400, + message: "Duplicate entry error", + error: error.message, + }; + } + + if (error.code === 'P2025') { + return { + statusCode: 404, + message: "Record not found", + error: error.message, + }; + } + + if (error.code && error.code.startsWith('P')) { + return { + statusCode: 400, + message: "Database error", + error: error.message, + code: error.code, + }; + } + + return { + statusCode: 500, + message: "Internal server error", + error: error.message, + }; + } finally { + await prisma.$disconnect(); + } +}); \ No newline at end of file diff --git a/server/api/devtool/config/upload-file.js b/server/api/devtool/config/upload-file.js new file mode 100644 index 0000000..b3bf422 --- /dev/null +++ b/server/api/devtool/config/upload-file.js @@ -0,0 +1,134 @@ +import fs from "fs"; +import path from "path"; +import { v4 as uuidv4 } from "uuid"; + +export default defineEventHandler(async (event) => { + const method = getMethod(event); + + if (method !== "POST") { + return { + statusCode: 405, + message: "Method not allowed", + }; + } + + try { + const form = await readMultipartFormData(event); + + if (!form || form.length === 0) { + return { + statusCode: 400, + message: "No file uploaded", + }; + } + + const file = form[0]; + const fileType = form.find(field => field.name === 'type')?.data?.toString() || 'logo'; + + // Validate file type + const allowedTypes = { + logo: ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/svg+xml'], + 'loading-logo': ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/svg+xml'], + 'login-logo': ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/svg+xml'], + favicon: ['image/x-icon', 'image/vnd.microsoft.icon', 'image/png'], + 'og-image': ['image/jpeg', 'image/jpg', 'image/png'], + theme: ['text/css', 'application/octet-stream'] + }; + + if (!allowedTypes[fileType] || !allowedTypes[fileType].includes(file.type)) { + return { + statusCode: 400, + message: `Invalid file type for ${fileType}. Allowed types: ${allowedTypes[fileType].join(', ')}`, + }; + } + + let uploadDir, fileUrl; + + // Determine upload directory based on file type + if (fileType === 'theme') { + // Theme files go to assets/style/css + uploadDir = path.join(process.cwd(), 'assets', 'style', 'css'); + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + + // Generate unique filename for theme + const fileExtension = path.extname(file.filename || ''); + const uniqueFilename = `custom-theme-${uuidv4()}${fileExtension}`; + const filePath = path.join(uploadDir, uniqueFilename); + + // Save file + fs.writeFileSync(filePath, file.data); + + // Return relative path for theme files + fileUrl = `/assets/style/css/${uniqueFilename}`; + } else { + // Logo, loading-logo, favicon, and og-image files go to public/uploads + uploadDir = path.join(process.cwd(), 'public', 'uploads', 'site-settings'); + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + + const fileExtension = path.extname(file.filename || ''); + let baseFilename; + + switch (fileType) { + case 'logo': + baseFilename = 'site-logo'; + break; + case 'loading-logo': + baseFilename = 'loading-logo'; + break; + case 'login-logo': + baseFilename = 'login-logo'; + break; + case 'favicon': + baseFilename = 'favicon'; + break; + case 'og-image': + baseFilename = 'og-image'; + break; + default: + // This case should ideally not be reached if fileType is validated earlier + // and is one of the image types. + // However, as a fallback, use the fileType itself or a generic name. + // For safety, and to avoid using uuidv4 for these specific types as requested, + // we should ensure this path isn't taken for the specified image types. + // If an unexpected fileType gets here, it might be better to error or use a UUID. + // For now, we'll stick to the primary requirement of fixed names for specified types. + // If we need UUID for other non-logo image types, that logic can be added. + // console.warn(`Unexpected fileType received: ${fileType} for non-theme upload.`); + // For simplicity, if it's an image type not explicitly handled, it will get a name like 'unknown-type.ext' + baseFilename = fileType; + } + + const filenameWithExt = `${baseFilename}${fileExtension}`; + const filePath = path.join(uploadDir, filenameWithExt); + + // Save file (overwrites if exists) + fs.writeFileSync(filePath, file.data); + + // Return file URL + fileUrl = `/uploads/site-settings/${filenameWithExt}`; + } + + return { + statusCode: 200, + message: "File uploaded successfully", + data: { + filename: path.basename(fileUrl), + url: fileUrl, + type: fileType, + size: file.data.length, + }, + }; + + } catch (error) { + console.error("Upload error:", error); + return { + statusCode: 500, + message: "Internal server error", + error: error.message, + }; + } +}); \ No newline at end of file diff --git a/server/utils/buildNuxtTemplate.js b/server/utils/buildNuxtTemplate.js index 2afc9f2..a73c36a 100644 --- a/server/utils/buildNuxtTemplate.js +++ b/server/utils/buildNuxtTemplate.js @@ -1,7 +1,10 @@ export function buildNuxtTemplate({ title, name }) { + // Ensure title is properly escaped for use in template + const escapedTitle = title.replace(/"/g, '\\"').replace(/\n/g, '\\n'); + return `