diff --git a/add-product-images-table.sql b/add-product-images-table.sql new file mode 100644 index 0000000..de8ab13 --- /dev/null +++ b/add-product-images-table.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS product_images ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + product_id INT UNSIGNED NOT NULL, + image VARCHAR(255) NOT NULL, + sort_order INT UNSIGNED NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_product_images_product + FOREIGN KEY (product_id) REFERENCES products(id) + ON DELETE CASCADE +); diff --git a/backup-before-dark-multiphoto-20260519-091652/database.sql b/backup-before-dark-multiphoto-20260519-091652/database.sql new file mode 100644 index 0000000..a58e462 --- /dev/null +++ b/backup-before-dark-multiphoto-20260519-091652/database.sql @@ -0,0 +1,58 @@ +CREATE DATABASE IF NOT EXISTS secondtech_market + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE secondtech_market; + +CREATE TABLE IF NOT EXISTS users ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(120) NOT NULL, + email VARCHAR(160) NOT NULL UNIQUE, + whatsapp VARCHAR(30) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role ENUM('user', 'superadmin') NOT NULL DEFAULT 'user', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS products ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NULL, + title VARCHAR(180) NOT NULL, + category VARCHAR(80) NOT NULL, + price INT UNSIGNED NOT NULL, + `condition` VARCHAR(80) NOT NULL, + location VARCHAR(120) NOT NULL, + seller VARCHAR(120) NOT NULL, + whatsapp VARCHAR(30) NOT NULL, + image VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + status ENUM('Tersedia', 'Terjual') NOT NULL DEFAULT 'Tersedia', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_products_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE SET NULL +); + +INSERT INTO products + (id, title, category, price, `condition`, location, seller, whatsapp, image, description, status) +VALUES + (1, 'Laptop Lenovo ThinkPad T480', 'Laptop', 3200000, 'Bekas Normal', 'Yogyakarta', 'Raka SIJA', '6281234567890', 'https://images.unsplash.com/photo-1496181133206-80ce9b88a853?q=80&w=1200&auto=format&fit=crop', 'Laptop bekas cocok untuk belajar coding, jaringan, virtual machine ringan, dan kebutuhan sekolah. RAM 8GB, SSD 256GB, keyboard normal.', 'Tersedia'), + (2, 'PC Rakitan i5 Gen 8', 'PC', 4100000, 'Bekas Normal', 'Bantul', 'Dimas Tech', '628111222333', 'https://images.unsplash.com/photo-1587202372775-e229f172b9d7?q=80&w=1200&auto=format&fit=crop', 'PC rakitan untuk lab jaringan, desain ringan, dan multitasking. Intel Core i5, RAM 16GB, SSD 512GB.', 'Tersedia'), + (3, 'Monitor LG 24 Inch IPS', 'Monitor', 1150000, 'Bekas Mulus', 'Sleman', 'Adit Hardware', '628555666777', 'https://images.unsplash.com/photo-1527443224154-c4a3942d3acf?q=80&w=1200&auto=format&fit=crop', 'Monitor IPS 24 inch, warna masih bagus, cocok untuk coding dan editing. Include kabel power dan HDMI.', 'Tersedia'), + (4, 'Keyboard Mechanical Keychron K2', 'Keyboard', 850000, 'Bekas Normal', 'Solo', 'Naufal Keys', '628333444555', 'https://images.unsplash.com/photo-1618384887929-16ec33fab9ef?q=80&w=1200&auto=format&fit=crop', 'Keyboard mechanical wireless, switch brown, cocok untuk coding. Kondisi normal dan keycap lengkap.', 'Tersedia'), + (5, 'MikroTik RB941 hAP Lite', 'Router', 180000, 'Bekas Normal', 'Kulon Progo', 'Lab Network', '628777888999', 'https://images.unsplash.com/photo-1606904825846-647eb07f5be2?q=80&w=1200&auto=format&fit=crop', 'Router MikroTik untuk belajar routing, firewall, DHCP, hotspot, dan konfigurasi dasar jaringan.', 'Tersedia'), + (6, 'Switch TP-Link 8 Port Gigabit', 'Switch', 250000, 'Bekas Normal', 'Magelang', 'Fajar Net', '628999111222', 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=1200&auto=format&fit=crop', 'Switch 8 port gigabit, cocok untuk lab kecil, warnet mini, dan praktik jaringan lokal.', 'Tersedia'), + (7, 'Server Dell PowerEdge R620', 'Server', 6500000, 'Bekas Server Room', 'Jakarta', 'Server Bekas ID', '628222333444', 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=1200&auto=format&fit=crop', 'Server rackmount bekas data center, cocok untuk belajar virtualization, Proxmox, Docker, dan homelab.', 'Tersedia'), + (8, 'Access Point TP-Link EAP225', 'Access Point', 620000, 'Bekas Mulus', 'Semarang', 'WiFi Store', '6281212121212', 'https://images.unsplash.com/photo-1544197150-b99a580bb7a8?q=80&w=1200&auto=format&fit=crop', 'Access point ceiling untuk hotspot sekolah, kantor, dan lab jaringan. Kondisi normal.', 'Tersedia') +ON DUPLICATE KEY UPDATE + title = VALUES(title), + category = VALUES(category), + price = VALUES(price), + `condition` = VALUES(`condition`), + location = VALUES(location), + seller = VALUES(seller), + whatsapp = VALUES(whatsapp), + image = VALUES(image), + description = VALUES(description), + status = VALUES(status); diff --git a/backup-before-dark-multiphoto-20260519-091652/server/server.js b/backup-before-dark-multiphoto-20260519-091652/server/server.js new file mode 100644 index 0000000..7bbd7c8 --- /dev/null +++ b/backup-before-dark-multiphoto-20260519-091652/server/server.js @@ -0,0 +1,356 @@ +import bcrypt from "bcryptjs" +import cors from "cors" +import dotenv from "dotenv" +import express from "express" +import fs from "fs" +import jwt from "jsonwebtoken" +import multer from "multer" +import path from "path" +import { fileURLToPath } from "url" +import { pool } from "./db.js" + +dotenv.config() + +const app = express() +const port = Number(process.env.PORT || 5000) +const jwtSecret = process.env.JWT_SECRET || "secondtech_dev_secret" +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const uploadsDir = path.join(__dirname, "uploads") + +if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }) +} + +app.use(cors({ + origin: process.env.CLIENT_URL || "http://localhost:5173", + credentials: true +})) +app.use(express.json()) +app.use("/uploads", express.static(uploadsDir)) + +const storage = multer.diskStorage({ + destination: uploadsDir, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase() + cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`) + } +}) + +const upload = multer({ + storage, + limits: { fileSize: 3 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + if (!file.mimetype.startsWith("image/")) { + cb(new Error("File harus berupa gambar.")) + return + } + cb(null, true) + } +}) + +function signUser(user) { + return jwt.sign({ id: user.id, email: user.email }, jwtSecret, { expiresIn: "7d" }) +} + +function publicUser(user) { + return { + id: user.id, + name: user.name, + email: user.email, + whatsapp: user.whatsapp, + role: user.role || "user" + } +} + +function auth(req, res, next) { + const header = req.headers.authorization || "" + const token = header.startsWith("Bearer ") ? header.slice(7) : "" + + if (!token) { + res.status(401).json({ message: "Silakan login terlebih dahulu." }) + return + } + + try { + req.user = jwt.verify(token, jwtSecret) + next() + } catch { + res.status(401).json({ message: "Sesi login tidak valid." }) + } +} + +async function findProductById(id) { + const [rows] = await pool.query( + "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE id = ?", + [id] + ) + + const product = rows[0] + if (!product) return null + + const [images] = await pool.query( + "SELECT image FROM product_images WHERE product_id = ? ORDER BY sort_order ASC, id ASC", + [id] + ) + + product.images = images.length ? images.map((item) => item.image) : [product.image] + + return product +} + + +app.get("/api/health", async (req, res, next) => { + try { + await pool.query("SELECT 1") + res.json({ status: "ok" }) + } catch (error) { + next(error) + } +}) + +app.post("/api/auth/register", async (req, res, next) => { + try { + const { name, email, whatsapp, password } = req.body + + if (!name || !email || !whatsapp || !password) { + res.status(400).json({ message: "Semua field wajib diisi." }) + return + } + + if (password.length < 6) { + res.status(400).json({ message: "Password minimal 6 karakter." }) + return + } + + const passwordHash = await bcrypt.hash(password, 10) + const [result] = await pool.query( + "INSERT INTO users (name, email, whatsapp, password_hash) VALUES (?, ?, ?, ?)", + [name, email.toLowerCase(), whatsapp, passwordHash] + ) + + const user = { id: result.insertId, name, email: email.toLowerCase(), whatsapp, role: "user" } + res.status(201).json({ token: signUser(user), user: publicUser(user) }) + } catch (error) { + if (error.code === "ER_DUP_ENTRY") { + res.status(409).json({ message: "Email sudah terdaftar." }) + return + } + next(error) + } +}) + +app.post("/api/auth/login", async (req, res, next) => { + try { + const { email, password } = req.body + + if (!email || !password) { + res.status(400).json({ message: "Email dan password wajib diisi." }) + return + } + + const [rows] = await pool.query("SELECT * FROM users WHERE email = ?", [email.toLowerCase()]) + const user = rows[0] + + if (!user || !(await bcrypt.compare(password, user.password_hash))) { + res.status(401).json({ message: "Email atau password salah." }) + return + } + + res.json({ token: signUser(user), user: publicUser(user) }) + } catch (error) { + next(error) + } +}) + +app.get("/api/auth/me", auth, async (req, res, next) => { + try { + const [rows] = await pool.query("SELECT id, name, email, whatsapp, role FROM users WHERE id = ?", [req.user.id]) + if (!rows[0]) { + res.status(404).json({ message: "User tidak ditemukan." }) + return + } + res.json({ user: rows[0] }) + } catch (error) { + next(error) + } +}) + +app.get("/api/products", async (req, res, next) => { + const products = await Promise.all( + rows.map(async (product) => { + const [images] = await pool.query( + "SELECT image FROM product_images WHERE product_id = ? ORDER BY sort_order ASC, id ASC", + [product.id] + ) + + return { + ...product, + images: images.length ? images.map((item) => item.image) : [product.image] + } + }) +) + +res.json({ products }) + +}) + +app.get("/api/products/mine", auth, async (req, res, next) => { + try { + const [rows] = await pool.query( + "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE user_id = ? ORDER BY created_at DESC, id DESC", + [req.user.id] + ) + res.json({ products: rows }) + } catch (error) { + next(error) + } +}) + +app.get("/api/products/:id", async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + if (!product) { + res.status(404).json({ message: "Produk tidak ditemukan." }) + return + } + res.json({ product }) + } catch (error) { + next(error) + } +}) + +app.post("/api/products", auth, upload.array("images", 6), async (req, res, next) => { + try { + const { title, category, price, condition, location, seller, whatsapp, description } = req.body + + if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !req.files?.length) { + res.status(400).json({ message: "Semua field produk dan minimal 1 foto wajib diisi." }) + return + } + + const mainImagePath = `/uploads/${req.files[0].filename}` + + const [result] = await pool.query( + "INSERT INTO products (user_id, title, category, price, `condition`, location, seller, whatsapp, image, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [req.user.id, title, category, Number(price), condition, location, seller, whatsapp, mainImagePath, description] + ) + + const imageRows = req.files.map((file, index) => [ + result.insertId, + `/uploads/${file.filename}`, + index + ]) + + await pool.query( + "INSERT INTO product_images (product_id, image, sort_order) VALUES ?", + [imageRows] + ) + + const product = await findProductById(result.insertId) + res.status(201).json({ product }) + } catch (error) { + next(error) + } +}) + + +app.patch("/api/products/:id/status", auth, async (req, res, next) => { + try { + const status = req.body.status === "Terjual" ? "Terjual" : "Tersedia" + const [result] = await pool.query( + "UPDATE products SET status = ? WHERE id = ? AND user_id = ?", + [status, req.params.id, req.user.id] + ) + + if (!result.affectedRows) { + res.status(404).json({ message: "Produk milik kamu tidak ditemukan." }) + return + } + + const product = await findProductById(req.params.id) + res.json({ product }) + } catch (error) { + next(error) + } +}) + +app.delete("/api/products/:id", auth, async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + if (!product || product.user_id !== req.user.id) { + res.status(404).json({ message: "Produk milik kamu tidak ditemukan." }) + return + } + + await pool.query("DELETE FROM products WHERE id = ? AND user_id = ?", [req.params.id, req.user.id]) + + if (product.image.startsWith("/uploads/")) { + fs.rm(path.join(uploadsDir, path.basename(product.image)), { force: true }, () => {}) + } + + res.json({ message: "Produk berhasil dihapus." }) + } catch (error) { + next(error) + } +}) + +app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + + if (!product) { + res.status(404).json({ message: "Produk tidak ditemukan." }) + return + } + + await pool.query("DELETE FROM products WHERE id = ?", [req.params.id]) + + if (product.image.startsWith("/uploads/")) { + fs.rm(path.join(uploadsDir, path.basename(product.image)), { force: true }, () => {}) + } + + res.json({ message: "Produk berhasil dihapus oleh superadmin." }) + } catch (error) { + next(error) + } +}) + + +app.use((error, req, res, next) => { + if (error instanceof multer.MulterError) { + res.status(400).json({ message: "Upload gagal. Ukuran foto maksimal 10MB." }) + return + } + + if (error.message === "File harus berupa gambar.") { + res.status(400).json({ message: error.message }) + return + } + + console.error(error) + res.status(500).json({ message: "Terjadi kesalahan pada server." }) +}) + +app.listen(port, () => { + console.log(`SecondTech API berjalan di http://localhost:${port}`) +}) + + +async function superadminOnly(req, res, next) { + try { + const [rows] = await pool.query( + "SELECT role FROM users WHERE id = ?", + [req.user.id] + ) + + if (!rows[0] || rows[0].role !== "superadmin") { + res.status(403).json({ message: "Hanya superadmin yang boleh mengakses fitur ini." }) + return + } + + next() + } catch (error) { + next(error) + } +} diff --git a/backup-before-dark-multiphoto-20260519-091652/src/assets/main.css b/backup-before-dark-multiphoto-20260519-091652/src/assets/main.css new file mode 100644 index 0000000..a5213cc --- /dev/null +++ b/backup-before-dark-multiphoto-20260519-091652/src/assets/main.css @@ -0,0 +1,41 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +* { + scroll-behavior: smooth; +} + +body { + @apply bg-slate-50 text-slate-900 font-sans; +} + +.container-page { + @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8; +} + +.btn-primary { + @apply inline-flex items-center justify-center gap-2 rounded-xl bg-brand-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-700; +} + +.btn-secondary { + @apply inline-flex items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-800 transition hover:bg-slate-100; +} + +.card { + @apply rounded-2xl border border-slate-200 bg-white shadow-sm; +} + +.input { + @apply w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-4 focus:ring-brand-100; +} + +.label { + @apply text-sm font-semibold text-slate-700; +} + +.btn-WA { + @apply inline-flex items-center justify-center gap-2 rounded-xl bg-green-500 px-5 py-3 text-sm font-semibold text-white transition hover:bg-green-600; +} diff --git a/backup-before-dark-multiphoto-20260519-091652/src/components/Navbar.vue b/backup-before-dark-multiphoto-20260519-091652/src/components/Navbar.vue new file mode 100644 index 0000000..f043072 --- /dev/null +++ b/backup-before-dark-multiphoto-20260519-091652/src/components/Navbar.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/backup-before-dark-multiphoto-20260519-091652/src/utils.js b/backup-before-dark-multiphoto-20260519-091652/src/utils.js new file mode 100644 index 0000000..1754fc4 --- /dev/null +++ b/backup-before-dark-multiphoto-20260519-091652/src/utils.js @@ -0,0 +1,138 @@ +export const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:5000" + +export function formatRupiah(value) { + return new Intl.NumberFormat("id-ID", { + style: "currency", + currency: "IDR", + maximumFractionDigits: 0 + }).format(value) +} + +export function getLocal(key, fallback) { + try { + const value = localStorage.getItem(key) + return value ? JSON.parse(value) : fallback + } catch { + return fallback + } +} + +export function setLocal(key, value) { + localStorage.setItem(key, JSON.stringify(value)) +} + +export function getToken() { + return localStorage.getItem("secondtech_token") +} + +export function getCurrentUser() { + return getLocal("secondtech_user", null) +} + +export function setAuth({ token, user }) { + localStorage.setItem("secondtech_token", token) + + setLocal("secondtech_user", { + id: user.id, + name: user.name, + email: user.email, + whatsapp: user.whatsapp, + role: user.role || "user" + }) +} + +export function clearAuth() { + localStorage.removeItem("secondtech_token") + localStorage.removeItem("secondtech_user") +} + +export function getProductImageUrl(image) { + if (!image) return "" + if (image.startsWith("http://") || image.startsWith("https://") || image.startsWith("data:")) return image + return `${API_BASE_URL}${image}` +} + +async function requestJson(path, options = {}) { + const headers = new Headers(options.headers || {}) + const token = getToken() + + if (token) headers.set("Authorization", `Bearer ${token}`) + if (!(options.body instanceof FormData)) headers.set("Content-Type", "application/json") + + const response = await fetch(`${API_BASE_URL}${path}`, { + ...options, + headers + }) + + const data = await response.json().catch(() => ({})) + + if (!response.ok) { + throw new Error(data.message || "Request gagal.") + } + + return data +} + +export function registerUser(payload) { + return requestJson("/api/auth/register", { + method: "POST", + body: JSON.stringify(payload) + }) +} + +export function loginUser(payload) { + return requestJson("/api/auth/login", { + method: "POST", + body: JSON.stringify(payload) + }) +} + +export function fetchCurrentUser() { + return requestJson("/api/auth/me") +} + +export function fetchProducts() { + return requestJson("/api/products") +} + +export function fetchProduct(id) { + return requestJson(`/api/products/${id}`) +} + +export function fetchMyProducts() { + return requestJson("/api/products/mine") +} + +export function createProduct(formData) { + return requestJson("/api/products", { + method: "POST", + body: formData + }) +} + +export function updateProductStatus(id, status) { + return requestJson(`/api/products/${id}/status`, { + method: "PATCH", + body: JSON.stringify({ status }) + }) +} + +export function deleteProductById(id) { + return requestJson(`/api/products/${id}`, { + method: "DELETE" + }) +} + +export function deleteProductAsAdmin(id) { + return requestJson(`/api/admin/products/${id}`, { + method: "DELETE" + }) +} + +export function getSavedProducts() { + return getLocal("secondtech_saved", []) +} + +export function setSavedProducts(ids) { + setLocal("secondtech_saved", ids) +} diff --git a/backup-before-dark-multiphoto-20260519-091652/src/views/ProductDetailView.vue b/backup-before-dark-multiphoto-20260519-091652/src/views/ProductDetailView.vue new file mode 100644 index 0000000..eec3fe5 --- /dev/null +++ b/backup-before-dark-multiphoto-20260519-091652/src/views/ProductDetailView.vue @@ -0,0 +1,105 @@ + + + diff --git a/backup-before-dark-multiphoto-20260519-091652/src/views/SellProductView.vue b/backup-before-dark-multiphoto-20260519-091652/src/views/SellProductView.vue new file mode 100644 index 0000000..4628875 --- /dev/null +++ b/backup-before-dark-multiphoto-20260519-091652/src/views/SellProductView.vue @@ -0,0 +1,171 @@ + + + diff --git a/backup-before-dark-multiphoto-20260519-091652/tailwind.config.js b/backup-before-dark-multiphoto-20260519-091652/tailwind.config.js new file mode 100644 index 0000000..5203320 --- /dev/null +++ b/backup-before-dark-multiphoto-20260519-091652/tailwind.config.js @@ -0,0 +1,25 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{vue,js}" + ], + theme: { + extend: { + fontFamily: { + sans: ["Inter", "system-ui", "sans-serif"] + }, + colors: { + brand: { + 50: "#eff6ff", + 100: "#dbeafe", + 500: "#2563eb", + 600: "#1d4ed8", + 700: "#1e40af", + 900: "#172554" + } + } + } + }, + plugins: [] +} diff --git a/backup-before-dark-multiphoto-20260519-092338/database.sql b/backup-before-dark-multiphoto-20260519-092338/database.sql new file mode 100644 index 0000000..f559507 --- /dev/null +++ b/backup-before-dark-multiphoto-20260519-092338/database.sql @@ -0,0 +1,69 @@ +CREATE DATABASE IF NOT EXISTS secondtech_market + CHARACTER SET utf8mb4 + COLLATE utf8mb4_unicode_ci; + +USE secondtech_market; + +CREATE TABLE IF NOT EXISTS users ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(120) NOT NULL, + email VARCHAR(160) NOT NULL UNIQUE, + whatsapp VARCHAR(30) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role ENUM('user', 'superadmin') NOT NULL DEFAULT 'user', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS products ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + user_id INT UNSIGNED NULL, + title VARCHAR(180) NOT NULL, + category VARCHAR(80) NOT NULL, + price INT UNSIGNED NOT NULL, + `condition` VARCHAR(80) NOT NULL, + location VARCHAR(120) NOT NULL, + seller VARCHAR(120) NOT NULL, + whatsapp VARCHAR(30) NOT NULL, + image VARCHAR(255) NOT NULL, + description TEXT NOT NULL, + status ENUM('Tersedia', 'Terjual') NOT NULL DEFAULT 'Tersedia', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_products_user + FOREIGN KEY (user_id) REFERENCES users(id) + ON DELETE SET NULL +); + +INSERT INTO products + (id, title, category, price, `condition`, location, seller, whatsapp, image, description, status) +VALUES + (1, 'Laptop Lenovo ThinkPad T480', 'Laptop', 3200000, 'Bekas Normal', 'Yogyakarta', 'Raka SIJA', '6281234567890', 'https://images.unsplash.com/photo-1496181133206-80ce9b88a853?q=80&w=1200&auto=format&fit=crop', 'Laptop bekas cocok untuk belajar coding, jaringan, virtual machine ringan, dan kebutuhan sekolah. RAM 8GB, SSD 256GB, keyboard normal.', 'Tersedia'), + (2, 'PC Rakitan i5 Gen 8', 'PC', 4100000, 'Bekas Normal', 'Bantul', 'Dimas Tech', '628111222333', 'https://images.unsplash.com/photo-1587202372775-e229f172b9d7?q=80&w=1200&auto=format&fit=crop', 'PC rakitan untuk lab jaringan, desain ringan, dan multitasking. Intel Core i5, RAM 16GB, SSD 512GB.', 'Tersedia'), + (3, 'Monitor LG 24 Inch IPS', 'Monitor', 1150000, 'Bekas Mulus', 'Sleman', 'Adit Hardware', '628555666777', 'https://images.unsplash.com/photo-1527443224154-c4a3942d3acf?q=80&w=1200&auto=format&fit=crop', 'Monitor IPS 24 inch, warna masih bagus, cocok untuk coding dan editing. Include kabel power dan HDMI.', 'Tersedia'), + (4, 'Keyboard Mechanical Keychron K2', 'Keyboard', 850000, 'Bekas Normal', 'Solo', 'Naufal Keys', '628333444555', 'https://images.unsplash.com/photo-1618384887929-16ec33fab9ef?q=80&w=1200&auto=format&fit=crop', 'Keyboard mechanical wireless, switch brown, cocok untuk coding. Kondisi normal dan keycap lengkap.', 'Tersedia'), + (5, 'MikroTik RB941 hAP Lite', 'Router', 180000, 'Bekas Normal', 'Kulon Progo', 'Lab Network', '628777888999', 'https://images.unsplash.com/photo-1606904825846-647eb07f5be2?q=80&w=1200&auto=format&fit=crop', 'Router MikroTik untuk belajar routing, firewall, DHCP, hotspot, dan konfigurasi dasar jaringan.', 'Tersedia'), + (6, 'Switch TP-Link 8 Port Gigabit', 'Switch', 250000, 'Bekas Normal', 'Magelang', 'Fajar Net', '628999111222', 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=1200&auto=format&fit=crop', 'Switch 8 port gigabit, cocok untuk lab kecil, warnet mini, dan praktik jaringan lokal.', 'Tersedia'), + (7, 'Server Dell PowerEdge R620', 'Server', 6500000, 'Bekas Server Room', 'Jakarta', 'Server Bekas ID', '628222333444', 'https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=1200&auto=format&fit=crop', 'Server rackmount bekas data center, cocok untuk belajar virtualization, Proxmox, Docker, dan homelab.', 'Tersedia'), + (8, 'Access Point TP-Link EAP225', 'Access Point', 620000, 'Bekas Mulus', 'Semarang', 'WiFi Store', '6281212121212', 'https://images.unsplash.com/photo-1544197150-b99a580bb7a8?q=80&w=1200&auto=format&fit=crop', 'Access point ceiling untuk hotspot sekolah, kantor, dan lab jaringan. Kondisi normal.', 'Tersedia') +ON DUPLICATE KEY UPDATE + title = VALUES(title), + category = VALUES(category), + price = VALUES(price), + `condition` = VALUES(`condition`), + location = VALUES(location), + seller = VALUES(seller), + whatsapp = VALUES(whatsapp), + image = VALUES(image), + description = VALUES(description), + status = VALUES(status); + +CREATE TABLE IF NOT EXISTS product_images ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + product_id INT UNSIGNED NOT NULL, + image VARCHAR(255) NOT NULL, + sort_order INT UNSIGNED NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_product_images_product + FOREIGN KEY (product_id) REFERENCES products(id) + ON DELETE CASCADE +); diff --git a/backup-before-dark-multiphoto-20260519-092338/server/server.js b/backup-before-dark-multiphoto-20260519-092338/server/server.js new file mode 100644 index 0000000..eae1282 --- /dev/null +++ b/backup-before-dark-multiphoto-20260519-092338/server/server.js @@ -0,0 +1,348 @@ +import bcrypt from "bcryptjs" +import cors from "cors" +import dotenv from "dotenv" +import express from "express" +import fs from "fs" +import jwt from "jsonwebtoken" +import multer from "multer" +import path from "path" +import { fileURLToPath } from "url" +import { pool } from "./db.js" + +dotenv.config() + +const app = express() +const port = Number(process.env.PORT || 5000) +const jwtSecret = process.env.JWT_SECRET || "secondtech_dev_secret" +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const uploadsDir = path.join(__dirname, "uploads") + +if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }) +} + +app.use(cors({ + origin: process.env.CLIENT_URL || "http://localhost:5173", + credentials: true +})) +app.use(express.json()) +app.use("/uploads", express.static(uploadsDir)) + +const storage = multer.diskStorage({ + destination: uploadsDir, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase() + cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`) + } +}) + +const upload = multer({ + storage, + limits: { fileSize: 3 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + if (!file.mimetype.startsWith("image/")) { + cb(new Error("File harus berupa gambar.")) + return + } + cb(null, true) + } +}) + +function signUser(user) { + return jwt.sign({ id: user.id, email: user.email }, jwtSecret, { expiresIn: "7d" }) +} + +function publicUser(user) { + return { + id: user.id, + name: user.name, + email: user.email, + whatsapp: user.whatsapp, + role: user.role || "user" + } +} + +function auth(req, res, next) { + const header = req.headers.authorization || "" + const token = header.startsWith("Bearer ") ? header.slice(7) : "" + + if (!token) { + res.status(401).json({ message: "Silakan login terlebih dahulu." }) + return + } + + try { + req.user = jwt.verify(token, jwtSecret) + next() + } catch { + res.status(401).json({ message: "Sesi login tidak valid." }) + } +} + +async function superadminOnly(req, res, next) { + try { + const [rows] = await pool.query("SELECT role FROM users WHERE id = ?", [req.user.id]) + + if (!rows[0] || rows[0].role !== "superadmin") { + res.status(403).json({ message: "Hanya superadmin yang boleh mengakses fitur ini." }) + return + } + + next() + } catch (error) { + next(error) + } +} + +async function getProductImages(productId, fallbackImage) { + const [images] = await pool.query( + "SELECT image FROM product_images WHERE product_id = ? ORDER BY sort_order ASC, id ASC", + [productId] + ) + + return images.length ? images.map((item) => item.image) : [fallbackImage] +} + +async function attachProductImages(products) { + return Promise.all( + products.map(async (product) => ({ + ...product, + images: await getProductImages(product.id, product.image) + })) + ) +} + +async function findProductById(id) { + const [rows] = await pool.query( + "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE id = ?", + [id] + ) + + const product = rows[0] + if (!product) return null + + product.images = await getProductImages(product.id, product.image) + return product +} + +async function deleteUploadedImages(product) { + const images = product.images?.length ? product.images : [product.image] + + images.forEach((image) => { + if (image?.startsWith("/uploads/")) { + fs.rm(path.join(uploadsDir, path.basename(image)), { force: true }, () => {}) + } + }) +} + +app.get("/api/health", async (req, res, next) => { + try { + await pool.query("SELECT 1") + res.json({ status: "ok" }) + } catch (error) { + next(error) + } +}) + +app.post("/api/auth/register", async (req, res, next) => { + try { + const { name, email, whatsapp, password } = req.body + + if (!name || !email || !whatsapp || !password) { + res.status(400).json({ message: "Semua field wajib diisi." }) + return + } + + if (password.length < 6) { + res.status(400).json({ message: "Password minimal 6 karakter." }) + return + } + + const passwordHash = await bcrypt.hash(password, 10) + const [result] = await pool.query( + "INSERT INTO users (name, email, whatsapp, password_hash) VALUES (?, ?, ?, ?)", + [name, email.toLowerCase(), whatsapp, passwordHash] + ) + + const user = { id: result.insertId, name, email: email.toLowerCase(), whatsapp, role: "user" } + res.status(201).json({ token: signUser(user), user: publicUser(user) }) + } catch (error) { + if (error.code === "ER_DUP_ENTRY") { + res.status(409).json({ message: "Email sudah terdaftar." }) + return + } + next(error) + } +}) + +app.post("/api/auth/login", async (req, res, next) => { + try { + const { email, password } = req.body + + if (!email || !password) { + res.status(400).json({ message: "Email dan password wajib diisi." }) + return + } + + const [rows] = await pool.query("SELECT * FROM users WHERE email = ?", [email.toLowerCase()]) + const user = rows[0] + + if (!user || !(await bcrypt.compare(password, user.password_hash))) { + res.status(401).json({ message: "Email atau password salah." }) + return + } + + res.json({ token: signUser(user), user: publicUser(user) }) + } catch (error) { + next(error) + } +}) + +app.get("/api/auth/me", auth, async (req, res, next) => { + try { + const [rows] = await pool.query("SELECT id, name, email, whatsapp, role FROM users WHERE id = ?", [req.user.id]) + if (!rows[0]) { + res.status(404).json({ message: "User tidak ditemukan." }) + return + } + res.json({ user: rows[0] }) + } catch (error) { + next(error) + } +}) + +app.get("/api/products", async (req, res, next) => { + try { + const [rows] = await pool.query( + "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products ORDER BY created_at DESC, id DESC" + ) + res.json({ products: await attachProductImages(rows) }) + } catch (error) { + next(error) + } +}) + +app.get("/api/products/mine", auth, async (req, res, next) => { + try { + const [rows] = await pool.query( + "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE user_id = ? ORDER BY created_at DESC, id DESC", + [req.user.id] + ) + res.json({ products: await attachProductImages(rows) }) + } catch (error) { + next(error) + } +}) + +app.get("/api/products/:id", async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + if (!product) { + res.status(404).json({ message: "Produk tidak ditemukan." }) + return + } + res.json({ product }) + } catch (error) { + next(error) + } +}) + +app.post("/api/products", auth, upload.array("images", 6), async (req, res, next) => { + try { + const { title, category, price, condition, location, seller, whatsapp, description } = req.body + + if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !req.files?.length) { + res.status(400).json({ message: "Semua field produk dan minimal 1 foto wajib diisi." }) + return + } + + const mainImagePath = `/uploads/${req.files[0].filename}` + const [result] = await pool.query( + "INSERT INTO products (user_id, title, category, price, `condition`, location, seller, whatsapp, image, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [req.user.id, title, category, Number(price), condition, location, seller, whatsapp, mainImagePath, description] + ) + + const imageRows = req.files.map((file, index) => [result.insertId, `/uploads/${file.filename}`, index]) + await pool.query("INSERT INTO product_images (product_id, image, sort_order) VALUES ?", [imageRows]) + + const product = await findProductById(result.insertId) + res.status(201).json({ product }) + } catch (error) { + next(error) + } +}) + +app.patch("/api/products/:id/status", auth, async (req, res, next) => { + try { + const status = req.body.status === "Terjual" ? "Terjual" : "Tersedia" + const [result] = await pool.query( + "UPDATE products SET status = ? WHERE id = ? AND user_id = ?", + [status, req.params.id, req.user.id] + ) + + if (!result.affectedRows) { + res.status(404).json({ message: "Produk milik kamu tidak ditemukan." }) + return + } + + const product = await findProductById(req.params.id) + res.json({ product }) + } catch (error) { + next(error) + } +}) + +app.delete("/api/products/:id", auth, async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + if (!product || product.user_id !== req.user.id) { + res.status(404).json({ message: "Produk milik kamu tidak ditemukan." }) + return + } + + await pool.query("DELETE FROM products WHERE id = ? AND user_id = ?", [req.params.id, req.user.id]) + await deleteUploadedImages(product) + + res.json({ message: "Produk berhasil dihapus." }) + } catch (error) { + next(error) + } +}) + +app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + + if (!product) { + res.status(404).json({ message: "Produk tidak ditemukan." }) + return + } + + await pool.query("DELETE FROM products WHERE id = ?", [req.params.id]) + await deleteUploadedImages(product) + + res.json({ message: "Produk berhasil dihapus oleh superadmin." }) + } catch (error) { + next(error) + } +}) + +app.use((error, req, res, next) => { + if (error instanceof multer.MulterError) { + res.status(400).json({ message: "Upload gagal. Ukuran setiap foto maksimal 3MB dan maksimal 6 foto." }) + return + } + + if (error.message === "File harus berupa gambar.") { + res.status(400).json({ message: error.message }) + return + } + + console.error(error) + res.status(500).json({ message: "Terjadi kesalahan pada server." }) +}) + +app.listen(port, () => { + console.log(`SecondTech API berjalan di http://localhost:${port}`) +}) diff --git a/backup-before-dark-multiphoto-20260519-092338/src/assets/main.css b/backup-before-dark-multiphoto-20260519-092338/src/assets/main.css new file mode 100644 index 0000000..75edddb --- /dev/null +++ b/backup-before-dark-multiphoto-20260519-092338/src/assets/main.css @@ -0,0 +1,95 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +* { + scroll-behavior: smooth; +} + +body { + @apply bg-slate-50 text-slate-900 font-sans; +} + +.container-page { + @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8; +} + +.btn-primary { + @apply inline-flex items-center justify-center gap-2 rounded-xl bg-brand-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-700; +} + +.btn-secondary { + @apply inline-flex items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-800 transition hover:bg-slate-100; +} + +.card { + @apply rounded-2xl border border-slate-200 bg-white shadow-sm; +} + +.input { + @apply w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-4 focus:ring-brand-100; +} + +.label { + @apply text-sm font-semibold text-slate-700; +} + +.btn-WA { + @apply inline-flex items-center justify-center gap-2 rounded-xl bg-green-500 px-5 py-3 text-sm font-semibold text-white transition hover:bg-green-600; +} + +/* SecondTech dark mode */ +html.dark body { + background: #121212; + color: #f8fafc; +} + +html.dark header { + background: rgba(18, 18, 18, 0.92); + border-color: rgba(37, 99, 235, 0.28); +} + +html.dark .card { + background: #181818; + border-color: rgba(37, 99, 235, 0.28); + box-shadow: 0 12px 32px rgba(37, 99, 235, 0.18); +} + +html.dark .input { + background: #181818; + border-color: rgba(37, 99, 235, 0.28); + color: #f8fafc; +} + +html.dark .input::placeholder { + color: #94a3b8; +} + +html.dark .btn-secondary { + background: #181818; + border-color: rgba(37, 99, 235, 0.32); + color: #f8fafc; +} + +html.dark .bg-white, +html.dark .bg-slate-50 { + background-color: #181818; +} + +html.dark .text-slate-950, +html.dark .text-slate-900, +html.dark .text-slate-800, +html.dark .text-slate-700 { + color: #f8fafc; +} + +html.dark .text-slate-600, +html.dark .text-slate-500 { + color: #cbd5e1; +} + +html.dark .border-slate-200 { + border-color: rgba(37, 99, 235, 0.28); +} diff --git a/backup-before-dark-multiphoto-20260519-092338/src/components/Navbar.vue b/backup-before-dark-multiphoto-20260519-092338/src/components/Navbar.vue new file mode 100644 index 0000000..b2715a5 --- /dev/null +++ b/backup-before-dark-multiphoto-20260519-092338/src/components/Navbar.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/backup-before-dark-multiphoto-20260519-092338/src/utils.js b/backup-before-dark-multiphoto-20260519-092338/src/utils.js new file mode 100644 index 0000000..725ba6a --- /dev/null +++ b/backup-before-dark-multiphoto-20260519-092338/src/utils.js @@ -0,0 +1,137 @@ +export const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:5000" + +export function formatRupiah(value) { + return new Intl.NumberFormat("id-ID", { + style: "currency", + currency: "IDR", + maximumFractionDigits: 0 + }).format(value) +} + +export function getLocal(key, fallback) { + try { + const value = localStorage.getItem(key) + return value ? JSON.parse(value) : fallback + } catch { + return fallback + } +} + +export function setLocal(key, value) { + localStorage.setItem(key, JSON.stringify(value)) +} + +export function getToken() { + return localStorage.getItem("secondtech_token") +} + +export function getCurrentUser() { + return getLocal("secondtech_user", null) +} + +export function setAuth({ token, user }) { + localStorage.setItem("secondtech_token", token) + + setLocal("secondtech_user", { + id: user.id, + name: user.name, + email: user.email, + whatsapp: user.whatsapp, + role: user.role || "user" + }) +} + +export function clearAuth() { + localStorage.removeItem("secondtech_token") + localStorage.removeItem("secondtech_user") +} + +export function getProductImageUrl(image) { + if (!image) return "" + if (image.startsWith("http://") || image.startsWith("https://") || image.startsWith("data:")) return image + return `${API_BASE_URL}${image}` +} + +async function requestJson(path, options = {}) { + const headers = new Headers(options.headers || {}) + const token = getToken() + + if (token) headers.set("Authorization", `Bearer ${token}`) + if (!(options.body instanceof FormData)) headers.set("Content-Type", "application/json") + + const response = await fetch(`${API_BASE_URL}${path}`, { + ...options, + headers + }) + const data = await response.json().catch(() => ({})) + + if (!response.ok) { + throw new Error(data.message || "Request gagal.") + } + + return data +} + +export function registerUser(payload) { + return requestJson("/api/auth/register", { + method: "POST", + body: JSON.stringify(payload) + }) +} + +export function loginUser(payload) { + return requestJson("/api/auth/login", { + method: "POST", + body: JSON.stringify(payload) + }) +} + +export function fetchCurrentUser() { + return requestJson("/api/auth/me") +} + +export function fetchProducts() { + return requestJson("/api/products") +} + +export function fetchProduct(id) { + return requestJson(`/api/products/${id}`) +} + +export function fetchMyProducts() { + return requestJson("/api/products/mine") +} + +export function createProduct(formData) { + return requestJson("/api/products", { + method: "POST", + body: formData + }) +} + +export function updateProductStatus(id, status) { + return requestJson(`/api/products/${id}/status`, { + method: "PATCH", + body: JSON.stringify({ status }) + }) +} + +export function deleteProductById(id) { + return requestJson(`/api/products/${id}`, { + method: "DELETE" + }) +} + +export function deleteProductAsAdmin(id) { + return requestJson(`/api/admin/products/${id}`, { + method: "DELETE" + }) +} + +export function getSavedProducts() { + return getLocal("secondtech_saved", []) +} + +export function setSavedProducts(ids) { + setLocal("secondtech_saved", ids) +} diff --git a/backup-before-dark-multiphoto-20260519-092338/src/views/ProductDetailView.vue b/backup-before-dark-multiphoto-20260519-092338/src/views/ProductDetailView.vue new file mode 100644 index 0000000..bf34d2c --- /dev/null +++ b/backup-before-dark-multiphoto-20260519-092338/src/views/ProductDetailView.vue @@ -0,0 +1,135 @@ + + + diff --git a/backup-before-dark-multiphoto-20260519-092338/src/views/SellProductView.vue b/backup-before-dark-multiphoto-20260519-092338/src/views/SellProductView.vue new file mode 100644 index 0000000..435c21f --- /dev/null +++ b/backup-before-dark-multiphoto-20260519-092338/src/views/SellProductView.vue @@ -0,0 +1,149 @@ + + + diff --git a/backup-before-dark-multiphoto-20260519-092338/tailwind.config.js b/backup-before-dark-multiphoto-20260519-092338/tailwind.config.js new file mode 100644 index 0000000..c5e9579 --- /dev/null +++ b/backup-before-dark-multiphoto-20260519-092338/tailwind.config.js @@ -0,0 +1,27 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: "class", + content: [ + "./index.html", + "./src/**/*.{vue,js}" + ], + theme: { + extend: { + fontFamily: { + sans: ["Inter", "system-ui", "sans-serif"] + }, + colors: { + brand: { + 50: "#eff6ff", + 100: "#dbeafe", + 500: "#2563eb", + 600: "#1d4ed8", + 700: "#1e40af", + 900: "#172554" + } + } + } + }, + plugins: [] +} + diff --git a/backup-before-darkmode-final-20260519-103706/src/assets/main.css b/backup-before-darkmode-final-20260519-103706/src/assets/main.css new file mode 100644 index 0000000..b8743a8 --- /dev/null +++ b/backup-before-darkmode-final-20260519-103706/src/assets/main.css @@ -0,0 +1,304 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +* { + scroll-behavior: smooth; +} + +body { + @apply bg-slate-50 text-slate-900 font-sans; +} + +.container-page { + @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8; +} + +.btn-primary { + @apply inline-flex items-center justify-center gap-2 rounded-xl bg-brand-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-700; +} + +.btn-secondary { + @apply inline-flex items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-800 transition hover:bg-slate-100; +} + +.card { + @apply rounded-2xl border border-slate-200 bg-white shadow-sm; +} + +.input { + @apply w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-4 focus:ring-brand-100; +} + +.label { + @apply text-sm font-semibold text-slate-700; +} + +.btn-WA { + @apply inline-flex items-center justify-center gap-2 rounded-xl bg-green-500 px-5 py-3 text-sm font-semibold text-white transition hover:bg-green-600; +} + +/* SecondTech dark mode */ +html.dark body { + background: #121212; + color: #f8fafc; +} + +html.dark header { + background: rgba(18, 18, 18, 0.94); + border-color: rgba(37, 99, 235, 0.34); +} + +html.dark main, +html.dark section { + background: #121212; +} + +html.dark .card { + background: #181818; + border-color: rgba(37, 99, 235, 0.30); + box-shadow: 0 14px 34px rgba(37, 99, 235, 0.20); +} + +html.dark .input { + background: #181818; + border-color: rgba(37, 99, 235, 0.34); + color: #f8fafc; +} + +html.dark .input::placeholder { + color: #94a3b8; +} + +html.dark .btn-secondary { + background: #181818; + border-color: rgba(37, 99, 235, 0.38); + color: #f8fafc; + box-shadow: 0 10px 24px rgba(37, 99, 235, 0.12); +} + +html.dark .btn-primary { + box-shadow: 0 12px 28px rgba(37, 99, 235, 0.24); +} + +html.dark .bg-white, +html.dark .bg-slate-50, +html.dark .bg-slate-100 { + background-color: #181818; +} + +html.dark .text-slate-950, +html.dark .text-slate-900, +html.dark .text-slate-800, +html.dark .text-slate-700 { + color: #f8fafc; +} + +html.dark .text-slate-600, +html.dark .text-slate-500 { + color: #cbd5e1; +} + +html.dark .border-slate-200 { + border-color: rgba(37, 99, 235, 0.30); +} + +/* SecondTech stronger dark mode repair */ +html.dark, +html.dark body { + background: #121212 !important; + color: #f8fafc !important; +} + +html.dark header, +html.dark footer, +html.dark main, +html.dark section { + background-color: #121212 !important; +} + +html.dark header { + border-color: rgba(37, 99, 235, 0.35) !important; +} + +html.dark .card { + background: #181818 !important; + border-color: rgba(37, 99, 235, 0.34) !important; + box-shadow: 0 14px 34px rgba(37, 99, 235, 0.22) !important; +} + +html.dark .input, +html.dark input, +html.dark select, +html.dark textarea { + background-color: #181818 !important; + border-color: rgba(37, 99, 235, 0.38) !important; + color: #f8fafc !important; +} + +html.dark .input::placeholder, +html.dark input::placeholder, +html.dark textarea::placeholder { + color: #94a3b8 !important; +} + +html.dark .btn-secondary { + background: #181818 !important; + border-color: rgba(37, 99, 235, 0.42) !important; + color: #f8fafc !important; + box-shadow: 0 10px 24px rgba(37, 99, 235, 0.14) !important; +} + +html.dark .btn-primary { + box-shadow: 0 12px 28px rgba(37, 99, 235, 0.26) !important; +} + +html.dark .bg-white, +html.dark .bg-slate-50, +html.dark .bg-slate-100, +html.dark .bg-brand-50 { + background-color: #181818 !important; +} + +html.dark .text-slate-950, +html.dark .text-slate-900, +html.dark .text-slate-800, +html.dark .text-slate-700, +html.dark .text-brand-900, +html.dark .text-brand-800, +html.dark .text-brand-700 { + color: #f8fafc !important; +} + +html.dark .text-slate-600, +html.dark .text-slate-500, +html.dark .text-slate-400 { + color: #cbd5e1 !important; +} + +html.dark .nav-link, +html.dark .mobile-link, +html.dark a:not(.btn-primary) { + color: #dbeafe !important; +} + +html.dark .nav-link:hover, +html.dark .mobile-link:hover, +html.dark a:not(.btn-primary):hover { + color: #ffffff !important; +} + +html.dark .border-slate-200, +html.dark .border-brand-600 { + border-color: rgba(37, 99, 235, 0.42) !important; +} + +html.dark .shadow-sm { + box-shadow: 0 14px 34px rgba(37, 99, 235, 0.18) !important; +} + +/* SecondTech final dark mode repair */ +html.dark, +html.dark body { + background: #121212 !important; + color: #f8fafc !important; +} + +html.dark header, +html.dark footer, +html.dark main, +html.dark section { + background-color: #121212 !important; +} + +html.dark header { + border-color: rgba(37, 99, 235, 0.35) !important; +} + +html.dark .card { + background: #181818 !important; + border-color: rgba(37, 99, 235, 0.34) !important; + box-shadow: 0 14px 34px rgba(37, 99, 235, 0.22) !important; +} + +html.dark .input, +html.dark input, +html.dark select, +html.dark textarea { + background-color: #181818 !important; + border-color: rgba(37, 99, 235, 0.38) !important; + color: #f8fafc !important; +} + +html.dark .input::placeholder, +html.dark input::placeholder, +html.dark textarea::placeholder { + color: #94a3b8 !important; +} + +html.dark .btn-secondary { + background: #181818 !important; + border-color: rgba(37, 99, 235, 0.42) !important; + color: #f8fafc !important; + box-shadow: 0 10px 24px rgba(37, 99, 235, 0.14) !important; +} + +html.dark .btn-primary { + box-shadow: 0 12px 28px rgba(37, 99, 235, 0.26) !important; +} + +html.dark .bg-white, +html.dark .bg-slate-50, +html.dark .bg-slate-100, +html.dark .bg-brand-50, +html.dark .bg-emerald-50, +html.dark .bg-red-50 { + background-color: #181818 !important; +} + +html.dark .text-zinc-950, +html.dark .text-zinc-900, +html.dark .text-zinc-800, +html.dark .text-slate-950, +html.dark .text-slate-900, +html.dark .text-slate-800, +html.dark .text-slate-700, +html.dark .text-brand-900, +html.dark .text-brand-800, +html.dark .text-brand-700, +html.dark .text-emerald-700, +html.dark .text-red-700 { + color: #f8fafc !important; +} + +html.dark .text-slate-600, +html.dark .text-slate-500, +html.dark .text-slate-400, +html.dark .text-zinc-600, +html.dark .text-zinc-500, +html.dark .text-zinc-400 { + color: #cbd5e1 !important; +} + +html.dark .nav-link, +html.dark .mobile-link, +html.dark a:not(.btn-primary) { + color: #dbeafe !important; +} + +html.dark .nav-link:hover, +html.dark .mobile-link:hover, +html.dark a:not(.btn-primary):hover { + color: #ffffff !important; +} + +html.dark .border-slate-200, +html.dark .border-brand-600 { + border-color: rgba(37, 99, 235, 0.42) !important; +} + +html.dark .shadow-sm { + box-shadow: 0 14px 34px rgba(37, 99, 235, 0.18) !important; +} diff --git a/backup-before-darkmode-final-20260519-103706/src/components/DashboardSidebar.vue b/backup-before-darkmode-final-20260519-103706/src/components/DashboardSidebar.vue new file mode 100644 index 0000000..5820201 --- /dev/null +++ b/backup-before-darkmode-final-20260519-103706/src/components/DashboardSidebar.vue @@ -0,0 +1,127 @@ + + + + + diff --git a/backup-before-darkmode-final-20260519-103706/src/components/ProductCard.vue b/backup-before-darkmode-final-20260519-103706/src/components/ProductCard.vue new file mode 100644 index 0000000..b70be73 --- /dev/null +++ b/backup-before-darkmode-final-20260519-103706/src/components/ProductCard.vue @@ -0,0 +1,66 @@ + + + diff --git a/backup-before-darkmode-final-20260519-103706/src/views/HomeView.vue b/backup-before-darkmode-final-20260519-103706/src/views/HomeView.vue new file mode 100644 index 0000000..88e668d --- /dev/null +++ b/backup-before-darkmode-final-20260519-103706/src/views/HomeView.vue @@ -0,0 +1,114 @@ + + + diff --git a/backup-before-fix-20260519-092338/server/server.js b/backup-before-fix-20260519-092338/server/server.js new file mode 100644 index 0000000..eae1282 --- /dev/null +++ b/backup-before-fix-20260519-092338/server/server.js @@ -0,0 +1,348 @@ +import bcrypt from "bcryptjs" +import cors from "cors" +import dotenv from "dotenv" +import express from "express" +import fs from "fs" +import jwt from "jsonwebtoken" +import multer from "multer" +import path from "path" +import { fileURLToPath } from "url" +import { pool } from "./db.js" + +dotenv.config() + +const app = express() +const port = Number(process.env.PORT || 5000) +const jwtSecret = process.env.JWT_SECRET || "secondtech_dev_secret" +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const uploadsDir = path.join(__dirname, "uploads") + +if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }) +} + +app.use(cors({ + origin: process.env.CLIENT_URL || "http://localhost:5173", + credentials: true +})) +app.use(express.json()) +app.use("/uploads", express.static(uploadsDir)) + +const storage = multer.diskStorage({ + destination: uploadsDir, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase() + cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`) + } +}) + +const upload = multer({ + storage, + limits: { fileSize: 3 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + if (!file.mimetype.startsWith("image/")) { + cb(new Error("File harus berupa gambar.")) + return + } + cb(null, true) + } +}) + +function signUser(user) { + return jwt.sign({ id: user.id, email: user.email }, jwtSecret, { expiresIn: "7d" }) +} + +function publicUser(user) { + return { + id: user.id, + name: user.name, + email: user.email, + whatsapp: user.whatsapp, + role: user.role || "user" + } +} + +function auth(req, res, next) { + const header = req.headers.authorization || "" + const token = header.startsWith("Bearer ") ? header.slice(7) : "" + + if (!token) { + res.status(401).json({ message: "Silakan login terlebih dahulu." }) + return + } + + try { + req.user = jwt.verify(token, jwtSecret) + next() + } catch { + res.status(401).json({ message: "Sesi login tidak valid." }) + } +} + +async function superadminOnly(req, res, next) { + try { + const [rows] = await pool.query("SELECT role FROM users WHERE id = ?", [req.user.id]) + + if (!rows[0] || rows[0].role !== "superadmin") { + res.status(403).json({ message: "Hanya superadmin yang boleh mengakses fitur ini." }) + return + } + + next() + } catch (error) { + next(error) + } +} + +async function getProductImages(productId, fallbackImage) { + const [images] = await pool.query( + "SELECT image FROM product_images WHERE product_id = ? ORDER BY sort_order ASC, id ASC", + [productId] + ) + + return images.length ? images.map((item) => item.image) : [fallbackImage] +} + +async function attachProductImages(products) { + return Promise.all( + products.map(async (product) => ({ + ...product, + images: await getProductImages(product.id, product.image) + })) + ) +} + +async function findProductById(id) { + const [rows] = await pool.query( + "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE id = ?", + [id] + ) + + const product = rows[0] + if (!product) return null + + product.images = await getProductImages(product.id, product.image) + return product +} + +async function deleteUploadedImages(product) { + const images = product.images?.length ? product.images : [product.image] + + images.forEach((image) => { + if (image?.startsWith("/uploads/")) { + fs.rm(path.join(uploadsDir, path.basename(image)), { force: true }, () => {}) + } + }) +} + +app.get("/api/health", async (req, res, next) => { + try { + await pool.query("SELECT 1") + res.json({ status: "ok" }) + } catch (error) { + next(error) + } +}) + +app.post("/api/auth/register", async (req, res, next) => { + try { + const { name, email, whatsapp, password } = req.body + + if (!name || !email || !whatsapp || !password) { + res.status(400).json({ message: "Semua field wajib diisi." }) + return + } + + if (password.length < 6) { + res.status(400).json({ message: "Password minimal 6 karakter." }) + return + } + + const passwordHash = await bcrypt.hash(password, 10) + const [result] = await pool.query( + "INSERT INTO users (name, email, whatsapp, password_hash) VALUES (?, ?, ?, ?)", + [name, email.toLowerCase(), whatsapp, passwordHash] + ) + + const user = { id: result.insertId, name, email: email.toLowerCase(), whatsapp, role: "user" } + res.status(201).json({ token: signUser(user), user: publicUser(user) }) + } catch (error) { + if (error.code === "ER_DUP_ENTRY") { + res.status(409).json({ message: "Email sudah terdaftar." }) + return + } + next(error) + } +}) + +app.post("/api/auth/login", async (req, res, next) => { + try { + const { email, password } = req.body + + if (!email || !password) { + res.status(400).json({ message: "Email dan password wajib diisi." }) + return + } + + const [rows] = await pool.query("SELECT * FROM users WHERE email = ?", [email.toLowerCase()]) + const user = rows[0] + + if (!user || !(await bcrypt.compare(password, user.password_hash))) { + res.status(401).json({ message: "Email atau password salah." }) + return + } + + res.json({ token: signUser(user), user: publicUser(user) }) + } catch (error) { + next(error) + } +}) + +app.get("/api/auth/me", auth, async (req, res, next) => { + try { + const [rows] = await pool.query("SELECT id, name, email, whatsapp, role FROM users WHERE id = ?", [req.user.id]) + if (!rows[0]) { + res.status(404).json({ message: "User tidak ditemukan." }) + return + } + res.json({ user: rows[0] }) + } catch (error) { + next(error) + } +}) + +app.get("/api/products", async (req, res, next) => { + try { + const [rows] = await pool.query( + "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products ORDER BY created_at DESC, id DESC" + ) + res.json({ products: await attachProductImages(rows) }) + } catch (error) { + next(error) + } +}) + +app.get("/api/products/mine", auth, async (req, res, next) => { + try { + const [rows] = await pool.query( + "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE user_id = ? ORDER BY created_at DESC, id DESC", + [req.user.id] + ) + res.json({ products: await attachProductImages(rows) }) + } catch (error) { + next(error) + } +}) + +app.get("/api/products/:id", async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + if (!product) { + res.status(404).json({ message: "Produk tidak ditemukan." }) + return + } + res.json({ product }) + } catch (error) { + next(error) + } +}) + +app.post("/api/products", auth, upload.array("images", 6), async (req, res, next) => { + try { + const { title, category, price, condition, location, seller, whatsapp, description } = req.body + + if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !req.files?.length) { + res.status(400).json({ message: "Semua field produk dan minimal 1 foto wajib diisi." }) + return + } + + const mainImagePath = `/uploads/${req.files[0].filename}` + const [result] = await pool.query( + "INSERT INTO products (user_id, title, category, price, `condition`, location, seller, whatsapp, image, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [req.user.id, title, category, Number(price), condition, location, seller, whatsapp, mainImagePath, description] + ) + + const imageRows = req.files.map((file, index) => [result.insertId, `/uploads/${file.filename}`, index]) + await pool.query("INSERT INTO product_images (product_id, image, sort_order) VALUES ?", [imageRows]) + + const product = await findProductById(result.insertId) + res.status(201).json({ product }) + } catch (error) { + next(error) + } +}) + +app.patch("/api/products/:id/status", auth, async (req, res, next) => { + try { + const status = req.body.status === "Terjual" ? "Terjual" : "Tersedia" + const [result] = await pool.query( + "UPDATE products SET status = ? WHERE id = ? AND user_id = ?", + [status, req.params.id, req.user.id] + ) + + if (!result.affectedRows) { + res.status(404).json({ message: "Produk milik kamu tidak ditemukan." }) + return + } + + const product = await findProductById(req.params.id) + res.json({ product }) + } catch (error) { + next(error) + } +}) + +app.delete("/api/products/:id", auth, async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + if (!product || product.user_id !== req.user.id) { + res.status(404).json({ message: "Produk milik kamu tidak ditemukan." }) + return + } + + await pool.query("DELETE FROM products WHERE id = ? AND user_id = ?", [req.params.id, req.user.id]) + await deleteUploadedImages(product) + + res.json({ message: "Produk berhasil dihapus." }) + } catch (error) { + next(error) + } +}) + +app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + + if (!product) { + res.status(404).json({ message: "Produk tidak ditemukan." }) + return + } + + await pool.query("DELETE FROM products WHERE id = ?", [req.params.id]) + await deleteUploadedImages(product) + + res.json({ message: "Produk berhasil dihapus oleh superadmin." }) + } catch (error) { + next(error) + } +}) + +app.use((error, req, res, next) => { + if (error instanceof multer.MulterError) { + res.status(400).json({ message: "Upload gagal. Ukuran setiap foto maksimal 3MB dan maksimal 6 foto." }) + return + } + + if (error.message === "File harus berupa gambar.") { + res.status(400).json({ message: error.message }) + return + } + + console.error(error) + res.status(500).json({ message: "Terjadi kesalahan pada server." }) +}) + +app.listen(port, () => { + console.log(`SecondTech API berjalan di http://localhost:${port}`) +}) diff --git a/backup-before-fix-20260519-092338/src/assets/main.css b/backup-before-fix-20260519-092338/src/assets/main.css new file mode 100644 index 0000000..75edddb --- /dev/null +++ b/backup-before-fix-20260519-092338/src/assets/main.css @@ -0,0 +1,95 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +* { + scroll-behavior: smooth; +} + +body { + @apply bg-slate-50 text-slate-900 font-sans; +} + +.container-page { + @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8; +} + +.btn-primary { + @apply inline-flex items-center justify-center gap-2 rounded-xl bg-brand-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-700; +} + +.btn-secondary { + @apply inline-flex items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-800 transition hover:bg-slate-100; +} + +.card { + @apply rounded-2xl border border-slate-200 bg-white shadow-sm; +} + +.input { + @apply w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-4 focus:ring-brand-100; +} + +.label { + @apply text-sm font-semibold text-slate-700; +} + +.btn-WA { + @apply inline-flex items-center justify-center gap-2 rounded-xl bg-green-500 px-5 py-3 text-sm font-semibold text-white transition hover:bg-green-600; +} + +/* SecondTech dark mode */ +html.dark body { + background: #121212; + color: #f8fafc; +} + +html.dark header { + background: rgba(18, 18, 18, 0.92); + border-color: rgba(37, 99, 235, 0.28); +} + +html.dark .card { + background: #181818; + border-color: rgba(37, 99, 235, 0.28); + box-shadow: 0 12px 32px rgba(37, 99, 235, 0.18); +} + +html.dark .input { + background: #181818; + border-color: rgba(37, 99, 235, 0.28); + color: #f8fafc; +} + +html.dark .input::placeholder { + color: #94a3b8; +} + +html.dark .btn-secondary { + background: #181818; + border-color: rgba(37, 99, 235, 0.32); + color: #f8fafc; +} + +html.dark .bg-white, +html.dark .bg-slate-50 { + background-color: #181818; +} + +html.dark .text-slate-950, +html.dark .text-slate-900, +html.dark .text-slate-800, +html.dark .text-slate-700 { + color: #f8fafc; +} + +html.dark .text-slate-600, +html.dark .text-slate-500 { + color: #cbd5e1; +} + +html.dark .border-slate-200 { + border-color: rgba(37, 99, 235, 0.28); +} diff --git a/backup-before-fix-20260519-092338/src/components/Navbar.vue b/backup-before-fix-20260519-092338/src/components/Navbar.vue new file mode 100644 index 0000000..b2715a5 --- /dev/null +++ b/backup-before-fix-20260519-092338/src/components/Navbar.vue @@ -0,0 +1,150 @@ + + + + + diff --git a/backup-before-fix-20260519-092338/src/components/ProductCard.vue b/backup-before-fix-20260519-092338/src/components/ProductCard.vue new file mode 100644 index 0000000..b70be73 --- /dev/null +++ b/backup-before-fix-20260519-092338/src/components/ProductCard.vue @@ -0,0 +1,66 @@ + + + diff --git a/backup-before-fix-20260519-092338/src/data/products.js b/backup-before-fix-20260519-092338/src/data/products.js new file mode 100644 index 0000000..ff2457c --- /dev/null +++ b/backup-before-fix-20260519-092338/src/data/products.js @@ -0,0 +1,137 @@ +export const products = [ + { + id: 1, + title: "Laptop Lenovo ThinkPad T480", + category: "Laptop", + price: 3200000, + condition: "Bekas Normal", + location: "Yogyakarta", + seller: "Raka SIJA", + whatsapp: "6281234567890", + image: "https://images.unsplash.com/photo-1496181133206-80ce9b88a853?q=80&w=1200&auto=format&fit=crop", + description: "Laptop bekas cocok untuk belajar coding, jaringan, virtual machine ringan, dan kebutuhan sekolah. RAM 8GB, SSD 256GB, keyboard normal." + }, + { + id: 2, + title: "PC Rakitan i5 Gen 8", + category: "PC", + price: 4100000, + condition: "Bekas Normal", + location: "Bantul", + seller: "Dimas Tech", + whatsapp: "628111222333", + image: "https://images.unsplash.com/photo-1587202372775-e229f172b9d7?q=80&w=1200&auto=format&fit=crop", + description: "PC rakitan untuk lab jaringan, desain ringan, dan multitasking. Intel Core i5, RAM 16GB, SSD 512GB." + }, + { + id: 3, + title: "Monitor LG 24 Inch IPS", + category: "Monitor", + price: 1150000, + condition: "Bekas Mulus", + location: "Sleman", + seller: "Adit Hardware", + whatsapp: "628555666777", + image: "https://images.unsplash.com/photo-1527443224154-c4a3942d3acf?q=80&w=1200&auto=format&fit=crop", + description: "Monitor IPS 24 inch, warna masih bagus, cocok untuk coding dan editing. Include kabel power dan HDMI." + }, + { + id: 4, + title: "Keyboard Mechanical Keychron K2", + category: "Keyboard", + price: 850000, + condition: "Bekas Normal", + location: "Solo", + seller: "Naufal Keys", + whatsapp: "628333444555", + image: "https://images.unsplash.com/photo-1618384887929-16ec33fab9ef?q=80&w=1200&auto=format&fit=crop", + description: "Keyboard mechanical wireless, switch brown, cocok untuk coding. Kondisi normal dan keycap lengkap." + }, + { + id: 5, + title: "MikroTik RB941 hAP Lite", + category: "Router", + price: 180000, + condition: "Bekas Normal", + location: "Kulon Progo", + seller: "Lab Network", + whatsapp: "628777888999", + image: "https://images.unsplash.com/photo-1606904825846-647eb07f5be2?q=80&w=1200&auto=format&fit=crop", + description: "Router MikroTik untuk belajar routing, firewall, DHCP, hotspot, dan konfigurasi dasar jaringan." + }, + { + id: 6, + title: "Switch TP-Link 8 Port Gigabit", + category: "Switch", + price: 250000, + condition: "Bekas Normal", + location: "Magelang", + seller: "Fajar Net", + whatsapp: "628999111222", + image: "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=1200&auto=format&fit=crop", + description: "Switch 8 port gigabit, cocok untuk lab kecil, warnet mini, dan praktik jaringan lokal." + }, + { + id: 7, + title: "Server Dell PowerEdge R620", + category: "Server", + price: 6500000, + condition: "Bekas Server Room", + location: "Jakarta", + seller: "Server Bekas ID", + whatsapp: "628222333444", + image: "https://images.unsplash.com/photo-1558494949-ef010cbdcc31?q=80&w=1200&auto=format&fit=crop", + description: "Server rackmount bekas data center, cocok untuk belajar virtualization, Proxmox, Docker, dan homelab." + }, + { + id: 8, + title: "Access Point TP-Link EAP225", + category: "Access Point", + price: 620000, + condition: "Bekas Mulus", + location: "Semarang", + seller: "WiFi Store", + whatsapp: "6281212121212", + image: "https://images.unsplash.com/photo-1544197150-b99a580bb7a8?q=80&w=1200&auto=format&fit=crop", + description: "Access point ceiling untuk hotspot sekolah, kantor, dan lab jaringan. Kondisi normal." + } +] + +export const categories = [ + { + name: "Semua", + icon: "Boxes" + }, + { + name: "Laptop", + icon: "Laptop" + }, + { + name: "PC", + icon: "PcCase" + }, + { + name: "Monitor", + icon: "Monitor" + }, + { + name: "Keyboard", + icon: "Keyboard" + }, + { + name: "Router", + icon: "Router" + }, + { + name: "Switch", + icon: "Network" + }, + { + name: "Server", + icon: "Server" + }, + { + name: "Access Point", + icon: "Wifi" + } +] diff --git a/backup-before-fix-20260519-092338/src/views/ProductDetailView.vue b/backup-before-fix-20260519-092338/src/views/ProductDetailView.vue new file mode 100644 index 0000000..bf34d2c --- /dev/null +++ b/backup-before-fix-20260519-092338/src/views/ProductDetailView.vue @@ -0,0 +1,135 @@ + + + diff --git a/backup-before-fix-20260519-092338/src/views/SellProductView.vue b/backup-before-fix-20260519-092338/src/views/SellProductView.vue new file mode 100644 index 0000000..0842d46 --- /dev/null +++ b/backup-before-fix-20260519-092338/src/views/SellProductView.vue @@ -0,0 +1,162 @@ + + + diff --git a/backup-before-fix-20260519-092338/tailwind.config.js b/backup-before-fix-20260519-092338/tailwind.config.js new file mode 100644 index 0000000..c5e9579 --- /dev/null +++ b/backup-before-fix-20260519-092338/tailwind.config.js @@ -0,0 +1,27 @@ +/** @type {import('tailwindcss').Config} */ +export default { + darkMode: "class", + content: [ + "./index.html", + "./src/**/*.{vue,js}" + ], + theme: { + extend: { + fontFamily: { + sans: ["Inter", "system-ui", "sans-serif"] + }, + colors: { + brand: { + 50: "#eff6ff", + 100: "#dbeafe", + 500: "#2563eb", + 600: "#1d4ed8", + 700: "#1e40af", + 900: "#172554" + } + } + } + }, + plugins: [] +} + diff --git a/backup-before-force-multiphoto-dark-20260519-103129/server/server.js b/backup-before-force-multiphoto-dark-20260519-103129/server/server.js new file mode 100644 index 0000000..efec7d1 --- /dev/null +++ b/backup-before-force-multiphoto-dark-20260519-103129/server/server.js @@ -0,0 +1,395 @@ +import bcrypt from "bcryptjs" +import cors from "cors" +import dotenv from "dotenv" +import express from "express" +import fs from "fs" +import jwt from "jsonwebtoken" +import multer from "multer" +import path from "path" +import { fileURLToPath } from "url" +import { pool } from "./db.js" + +dotenv.config() + +const app = express() +const port = Number(process.env.PORT || 5000) +const jwtSecret = process.env.JWT_SECRET || "secondtech_dev_secret" +const maxImageSizeMb = Number(process.env.MAX_IMAGE_SIZE_MB || 10) +const maxImageCount = Number(process.env.MAX_IMAGE_COUNT || 6) +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const uploadsDir = path.join(__dirname, "uploads") + +if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }) +} + +app.use(cors({ + origin: process.env.CLIENT_URL || "http://localhost:5173", + credentials: true +})) +app.use(express.json()) +app.use("/uploads", express.static(uploadsDir)) + +const storage = multer.diskStorage({ + destination: uploadsDir, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase() + cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`) + } +}) + +const upload = multer({ + storage, + limits: { fileSize: maxImageSizeMb * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + if (!file.mimetype.startsWith("image/")) { + cb(new Error("File harus berupa gambar.")) + return + } + cb(null, true) + } +}) + +async function ensureSchema() { + await pool.query(` + CREATE TABLE IF NOT EXISTS product_images ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + product_id INT UNSIGNED NOT NULL, + image VARCHAR(255) NOT NULL, + sort_order INT UNSIGNED NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_product_images_product + FOREIGN KEY (product_id) REFERENCES products(id) + ON DELETE CASCADE + ) + `) +} + +function signUser(user) { + return jwt.sign({ id: user.id, email: user.email }, jwtSecret, { expiresIn: "7d" }) +} + +function publicUser(user) { + return { + id: user.id, + name: user.name, + email: user.email, + whatsapp: user.whatsapp, + role: user.role || "user" + } +} + +function auth(req, res, next) { + const header = req.headers.authorization || "" + const token = header.startsWith("Bearer ") ? header.slice(7) : "" + + if (!token) { + res.status(401).json({ message: "Silakan login terlebih dahulu." }) + return + } + + try { + req.user = jwt.verify(token, jwtSecret) + next() + } catch { + res.status(401).json({ message: "Sesi login tidak valid." }) + } +} + +async function superadminOnly(req, res, next) { + try { + const [rows] = await pool.query("SELECT role FROM users WHERE id = ?", [req.user.id]) + + if (!rows[0] || rows[0].role !== "superadmin") { + res.status(403).json({ message: "Hanya superadmin yang boleh mengakses fitur ini." }) + return + } + + next() + } catch (error) { + next(error) + } +} + +function getUploadedImages(req) { + if (Array.isArray(req.files)) return req.files.slice(0, maxImageCount) + return [ + ...(req.files?.images || []), + ...(req.files?.image || []) + ].slice(0, maxImageCount) +} + +async function getProductImages(productId, fallbackImage) { + const [images] = await pool.query( + "SELECT image FROM product_images WHERE product_id = ? ORDER BY sort_order ASC, id ASC", + [productId] + ) + + const imagePaths = images.map((item) => item.image) + if (imagePaths.length) return imagePaths + return fallbackImage ? [fallbackImage] : [] +} + +async function attachProductImages(products) { + return Promise.all( + products.map(async (product) => ({ + ...product, + images: await getProductImages(product.id, product.image) + })) + ) +} + +async function findProductById(id) { + const [rows] = await pool.query( + "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE id = ?", + [id] + ) + + const product = rows[0] + if (!product) return null + + product.images = await getProductImages(product.id, product.image) + return product +} + +async function deleteUploadedImages(product) { + const images = product.images?.length ? product.images : [product.image] + + images.forEach((image) => { + if (image?.startsWith("/uploads/")) { + fs.rm(path.join(uploadsDir, path.basename(image)), { force: true }, () => {}) + } + }) +} + +app.get("/api/health", async (req, res, next) => { + try { + await pool.query("SELECT 1") + res.json({ status: "ok" }) + } catch (error) { + next(error) + } +}) + +app.post("/api/auth/register", async (req, res, next) => { + try { + const { name, email, whatsapp, password } = req.body + + if (!name || !email || !whatsapp || !password) { + res.status(400).json({ message: "Semua field wajib diisi." }) + return + } + + if (password.length < 6) { + res.status(400).json({ message: "Password minimal 6 karakter." }) + return + } + + const passwordHash = await bcrypt.hash(password, 10) + const [result] = await pool.query( + "INSERT INTO users (name, email, whatsapp, password_hash) VALUES (?, ?, ?, ?)", + [name, email.toLowerCase(), whatsapp, passwordHash] + ) + + const user = { id: result.insertId, name, email: email.toLowerCase(), whatsapp, role: "user" } + res.status(201).json({ token: signUser(user), user: publicUser(user) }) + } catch (error) { + if (error.code === "ER_DUP_ENTRY") { + res.status(409).json({ message: "Email sudah terdaftar." }) + return + } + next(error) + } +}) + +app.post("/api/auth/login", async (req, res, next) => { + try { + const { email, password } = req.body + + if (!email || !password) { + res.status(400).json({ message: "Email dan password wajib diisi." }) + return + } + + const [rows] = await pool.query("SELECT * FROM users WHERE email = ?", [email.toLowerCase()]) + const user = rows[0] + + if (!user || !(await bcrypt.compare(password, user.password_hash))) { + res.status(401).json({ message: "Email atau password salah." }) + return + } + + res.json({ token: signUser(user), user: publicUser(user) }) + } catch (error) { + next(error) + } +}) + +app.get("/api/auth/me", auth, async (req, res, next) => { + try { + const [rows] = await pool.query("SELECT id, name, email, whatsapp, role FROM users WHERE id = ?", [req.user.id]) + if (!rows[0]) { + res.status(404).json({ message: "User tidak ditemukan." }) + return + } + res.json({ user: rows[0] }) + } catch (error) { + next(error) + } +}) + +app.get("/api/products", async (req, res, next) => { + try { + const [rows] = await pool.query( + "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products ORDER BY created_at DESC, id DESC" + ) + res.json({ products: await attachProductImages(rows) }) + } catch (error) { + next(error) + } +}) + +app.get("/api/products/mine", auth, async (req, res, next) => { + try { + const [rows] = await pool.query( + "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE user_id = ? ORDER BY created_at DESC, id DESC", + [req.user.id] + ) + res.json({ products: await attachProductImages(rows) }) + } catch (error) { + next(error) + } +}) + +app.get("/api/products/:id", async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + if (!product) { + res.status(404).json({ message: "Produk tidak ditemukan." }) + return + } + res.json({ product }) + } catch (error) { + next(error) + } +}) + +app.post( + "/api/products", + auth, + upload.fields([ + { name: "images", maxCount: maxImageCount }, + { name: "image", maxCount: maxImageCount } + ]), + async (req, res, next) => { + try { + const { title, category, price, condition, location, seller, whatsapp, description } = req.body + const uploadedImages = getUploadedImages(req) + + if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !uploadedImages.length) { + res.status(400).json({ message: "Semua field produk dan minimal 1 foto wajib diisi." }) + return + } + + const mainImagePath = `/uploads/${uploadedImages[0].filename}` + const [result] = await pool.query( + "INSERT INTO products (user_id, title, category, price, `condition`, location, seller, whatsapp, image, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [req.user.id, title, category, Number(price), condition, location, seller, whatsapp, mainImagePath, description] + ) + + const imageRows = uploadedImages.map((file, index) => [result.insertId, `/uploads/${file.filename}`, index]) + await pool.query("INSERT INTO product_images (product_id, image, sort_order) VALUES ?", [imageRows]) + + const product = await findProductById(result.insertId) + res.status(201).json({ product }) + } catch (error) { + next(error) + } + } +) + +app.patch("/api/products/:id/status", auth, async (req, res, next) => { + try { + const status = req.body.status === "Terjual" ? "Terjual" : "Tersedia" + const [result] = await pool.query( + "UPDATE products SET status = ? WHERE id = ? AND user_id = ?", + [status, req.params.id, req.user.id] + ) + + if (!result.affectedRows) { + res.status(404).json({ message: "Produk milik kamu tidak ditemukan." }) + return + } + + const product = await findProductById(req.params.id) + res.json({ product }) + } catch (error) { + next(error) + } +}) + +app.delete("/api/products/:id", auth, async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + if (!product || product.user_id !== req.user.id) { + res.status(404).json({ message: "Produk milik kamu tidak ditemukan." }) + return + } + + await pool.query("DELETE FROM products WHERE id = ? AND user_id = ?", [req.params.id, req.user.id]) + await deleteUploadedImages(product) + + res.json({ message: "Produk berhasil dihapus." }) + } catch (error) { + next(error) + } +}) + +app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + + if (!product) { + res.status(404).json({ message: "Produk tidak ditemukan." }) + return + } + + await pool.query("DELETE FROM products WHERE id = ?", [req.params.id]) + await deleteUploadedImages(product) + + res.json({ message: "Produk berhasil dihapus oleh superadmin." }) + } catch (error) { + next(error) + } +}) + +app.use((error, req, res, next) => { + if (error instanceof multer.MulterError) { + res.status(400).json({ message: `Upload gagal. Ukuran setiap foto maksimal ${maxImageSizeMb} MB dan maksimal ${maxImageCount} foto.` }) + return + } + + if (error.message === "File harus berupa gambar.") { + res.status(400).json({ message: error.message }) + return + } + + console.error(error) + res.status(500).json({ + message: "Terjadi kesalahan pada server.", + detail: process.env.NODE_ENV === "production" ? undefined : error.message + }) +}) + +ensureSchema() + .then(() => { + app.listen(port, () => { + console.log(`SecondTech API berjalan di http://localhost:${port}`) + console.log(`Upload foto: maksimal ${maxImageSizeMb} MB per foto, maksimal ${maxImageCount} foto.`) + }) + }) + .catch((error) => { + console.error("Gagal menyiapkan database:", error) + process.exit(1) + }) diff --git a/backup-before-force-multiphoto-dark-20260519-103129/src/assets/main.css b/backup-before-force-multiphoto-dark-20260519-103129/src/assets/main.css new file mode 100644 index 0000000..003ca64 --- /dev/null +++ b/backup-before-force-multiphoto-dark-20260519-103129/src/assets/main.css @@ -0,0 +1,200 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +* { + scroll-behavior: smooth; +} + +body { + @apply bg-slate-50 text-slate-900 font-sans; +} + +.container-page { + @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8; +} + +.btn-primary { + @apply inline-flex items-center justify-center gap-2 rounded-xl bg-brand-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-700; +} + +.btn-secondary { + @apply inline-flex items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-800 transition hover:bg-slate-100; +} + +.card { + @apply rounded-2xl border border-slate-200 bg-white shadow-sm; +} + +.input { + @apply w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-4 focus:ring-brand-100; +} + +.label { + @apply text-sm font-semibold text-slate-700; +} + +.btn-WA { + @apply inline-flex items-center justify-center gap-2 rounded-xl bg-green-500 px-5 py-3 text-sm font-semibold text-white transition hover:bg-green-600; +} + +/* SecondTech dark mode */ +html.dark body { + background: #121212; + color: #f8fafc; +} + +html.dark header { + background: rgba(18, 18, 18, 0.94); + border-color: rgba(37, 99, 235, 0.34); +} + +html.dark main, +html.dark section { + background: #121212; +} + +html.dark .card { + background: #181818; + border-color: rgba(37, 99, 235, 0.30); + box-shadow: 0 14px 34px rgba(37, 99, 235, 0.20); +} + +html.dark .input { + background: #181818; + border-color: rgba(37, 99, 235, 0.34); + color: #f8fafc; +} + +html.dark .input::placeholder { + color: #94a3b8; +} + +html.dark .btn-secondary { + background: #181818; + border-color: rgba(37, 99, 235, 0.38); + color: #f8fafc; + box-shadow: 0 10px 24px rgba(37, 99, 235, 0.12); +} + +html.dark .btn-primary { + box-shadow: 0 12px 28px rgba(37, 99, 235, 0.24); +} + +html.dark .bg-white, +html.dark .bg-slate-50, +html.dark .bg-slate-100 { + background-color: #181818; +} + +html.dark .text-slate-950, +html.dark .text-slate-900, +html.dark .text-slate-800, +html.dark .text-slate-700 { + color: #f8fafc; +} + +html.dark .text-slate-600, +html.dark .text-slate-500 { + color: #cbd5e1; +} + +html.dark .border-slate-200 { + border-color: rgba(37, 99, 235, 0.30); +} + +/* SecondTech stronger dark mode repair */ +html.dark, +html.dark body { + background: #121212 !important; + color: #f8fafc !important; +} + +html.dark header, +html.dark footer, +html.dark main, +html.dark section { + background-color: #121212 !important; +} + +html.dark header { + border-color: rgba(37, 99, 235, 0.35) !important; +} + +html.dark .card { + background: #181818 !important; + border-color: rgba(37, 99, 235, 0.34) !important; + box-shadow: 0 14px 34px rgba(37, 99, 235, 0.22) !important; +} + +html.dark .input, +html.dark input, +html.dark select, +html.dark textarea { + background-color: #181818 !important; + border-color: rgba(37, 99, 235, 0.38) !important; + color: #f8fafc !important; +} + +html.dark .input::placeholder, +html.dark input::placeholder, +html.dark textarea::placeholder { + color: #94a3b8 !important; +} + +html.dark .btn-secondary { + background: #181818 !important; + border-color: rgba(37, 99, 235, 0.42) !important; + color: #f8fafc !important; + box-shadow: 0 10px 24px rgba(37, 99, 235, 0.14) !important; +} + +html.dark .btn-primary { + box-shadow: 0 12px 28px rgba(37, 99, 235, 0.26) !important; +} + +html.dark .bg-white, +html.dark .bg-slate-50, +html.dark .bg-slate-100, +html.dark .bg-brand-50 { + background-color: #181818 !important; +} + +html.dark .text-slate-950, +html.dark .text-slate-900, +html.dark .text-slate-800, +html.dark .text-slate-700, +html.dark .text-brand-900, +html.dark .text-brand-800, +html.dark .text-brand-700 { + color: #f8fafc !important; +} + +html.dark .text-slate-600, +html.dark .text-slate-500, +html.dark .text-slate-400 { + color: #cbd5e1 !important; +} + +html.dark .nav-link, +html.dark .mobile-link, +html.dark a:not(.btn-primary) { + color: #dbeafe !important; +} + +html.dark .nav-link:hover, +html.dark .mobile-link:hover, +html.dark a:not(.btn-primary):hover { + color: #ffffff !important; +} + +html.dark .border-slate-200, +html.dark .border-brand-600 { + border-color: rgba(37, 99, 235, 0.42) !important; +} + +html.dark .shadow-sm { + box-shadow: 0 14px 34px rgba(37, 99, 235, 0.18) !important; +} diff --git a/backup-before-force-multiphoto-dark-20260519-103129/src/views/ProductDetailView.vue b/backup-before-force-multiphoto-dark-20260519-103129/src/views/ProductDetailView.vue new file mode 100644 index 0000000..f50eaa2 --- /dev/null +++ b/backup-before-force-multiphoto-dark-20260519-103129/src/views/ProductDetailView.vue @@ -0,0 +1,138 @@ + + + diff --git a/backup-before-force-multiphoto-dark-20260519-103129/src/views/SellProductView.vue b/backup-before-force-multiphoto-dark-20260519-103129/src/views/SellProductView.vue new file mode 100644 index 0000000..c31b072 --- /dev/null +++ b/backup-before-force-multiphoto-dark-20260519-103129/src/views/SellProductView.vue @@ -0,0 +1,173 @@ + + + + diff --git a/backup-before-gallery-darkmode-repair-20260519-100158/server/server.js b/backup-before-gallery-darkmode-repair-20260519-100158/server/server.js new file mode 100644 index 0000000..72df172 --- /dev/null +++ b/backup-before-gallery-darkmode-repair-20260519-100158/server/server.js @@ -0,0 +1,393 @@ +import bcrypt from "bcryptjs" +import cors from "cors" +import dotenv from "dotenv" +import express from "express" +import fs from "fs" +import jwt from "jsonwebtoken" +import multer from "multer" +import path from "path" +import { fileURLToPath } from "url" +import { pool } from "./db.js" + +dotenv.config() + +const app = express() +const port = Number(process.env.PORT || 5000) +const jwtSecret = process.env.JWT_SECRET || "secondtech_dev_secret" +const maxImageSizeMb = Number(process.env.MAX_IMAGE_SIZE_MB || 10) +const maxImageCount = Number(process.env.MAX_IMAGE_COUNT || 6) +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const uploadsDir = path.join(__dirname, "uploads") + +if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }) +} + +app.use(cors({ + origin: process.env.CLIENT_URL || "http://localhost:5173", + credentials: true +})) +app.use(express.json()) +app.use("/uploads", express.static(uploadsDir)) + +const storage = multer.diskStorage({ + destination: uploadsDir, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase() + cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`) + } +}) + +const upload = multer({ + storage, + limits: { fileSize: maxImageSizeMb * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + if (!file.mimetype.startsWith("image/")) { + cb(new Error("File harus berupa gambar.")) + return + } + cb(null, true) + } +}) + +async function ensureSchema() { + await pool.query(` + CREATE TABLE IF NOT EXISTS product_images ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + product_id INT UNSIGNED NOT NULL, + image VARCHAR(255) NOT NULL, + sort_order INT UNSIGNED NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_product_images_product + FOREIGN KEY (product_id) REFERENCES products(id) + ON DELETE CASCADE + ) + `) +} + +function signUser(user) { + return jwt.sign({ id: user.id, email: user.email }, jwtSecret, { expiresIn: "7d" }) +} + +function publicUser(user) { + return { + id: user.id, + name: user.name, + email: user.email, + whatsapp: user.whatsapp, + role: user.role || "user" + } +} + +function auth(req, res, next) { + const header = req.headers.authorization || "" + const token = header.startsWith("Bearer ") ? header.slice(7) : "" + + if (!token) { + res.status(401).json({ message: "Silakan login terlebih dahulu." }) + return + } + + try { + req.user = jwt.verify(token, jwtSecret) + next() + } catch { + res.status(401).json({ message: "Sesi login tidak valid." }) + } +} + +async function superadminOnly(req, res, next) { + try { + const [rows] = await pool.query("SELECT role FROM users WHERE id = ?", [req.user.id]) + + if (!rows[0] || rows[0].role !== "superadmin") { + res.status(403).json({ message: "Hanya superadmin yang boleh mengakses fitur ini." }) + return + } + + next() + } catch (error) { + next(error) + } +} + +function getUploadedImages(req) { + if (Array.isArray(req.files)) return req.files + return [ + ...(req.files?.images || []), + ...(req.files?.image || []) + ].slice(0, maxImageCount) +} + +async function getProductImages(productId, fallbackImage) { + const [images] = await pool.query( + "SELECT image FROM product_images WHERE product_id = ? ORDER BY sort_order ASC, id ASC", + [productId] + ) + + return images.length ? images.map((item) => item.image) : [fallbackImage] +} + +async function attachProductImages(products) { + return Promise.all( + products.map(async (product) => ({ + ...product, + images: await getProductImages(product.id, product.image) + })) + ) +} + +async function findProductById(id) { + const [rows] = await pool.query( + "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE id = ?", + [id] + ) + + const product = rows[0] + if (!product) return null + + product.images = await getProductImages(product.id, product.image) + return product +} + +async function deleteUploadedImages(product) { + const images = product.images?.length ? product.images : [product.image] + + images.forEach((image) => { + if (image?.startsWith("/uploads/")) { + fs.rm(path.join(uploadsDir, path.basename(image)), { force: true }, () => {}) + } + }) +} + +app.get("/api/health", async (req, res, next) => { + try { + await pool.query("SELECT 1") + res.json({ status: "ok" }) + } catch (error) { + next(error) + } +}) + +app.post("/api/auth/register", async (req, res, next) => { + try { + const { name, email, whatsapp, password } = req.body + + if (!name || !email || !whatsapp || !password) { + res.status(400).json({ message: "Semua field wajib diisi." }) + return + } + + if (password.length < 6) { + res.status(400).json({ message: "Password minimal 6 karakter." }) + return + } + + const passwordHash = await bcrypt.hash(password, 10) + const [result] = await pool.query( + "INSERT INTO users (name, email, whatsapp, password_hash) VALUES (?, ?, ?, ?)", + [name, email.toLowerCase(), whatsapp, passwordHash] + ) + + const user = { id: result.insertId, name, email: email.toLowerCase(), whatsapp, role: "user" } + res.status(201).json({ token: signUser(user), user: publicUser(user) }) + } catch (error) { + if (error.code === "ER_DUP_ENTRY") { + res.status(409).json({ message: "Email sudah terdaftar." }) + return + } + next(error) + } +}) + +app.post("/api/auth/login", async (req, res, next) => { + try { + const { email, password } = req.body + + if (!email || !password) { + res.status(400).json({ message: "Email dan password wajib diisi." }) + return + } + + const [rows] = await pool.query("SELECT * FROM users WHERE email = ?", [email.toLowerCase()]) + const user = rows[0] + + if (!user || !(await bcrypt.compare(password, user.password_hash))) { + res.status(401).json({ message: "Email atau password salah." }) + return + } + + res.json({ token: signUser(user), user: publicUser(user) }) + } catch (error) { + next(error) + } +}) + +app.get("/api/auth/me", auth, async (req, res, next) => { + try { + const [rows] = await pool.query("SELECT id, name, email, whatsapp, role FROM users WHERE id = ?", [req.user.id]) + if (!rows[0]) { + res.status(404).json({ message: "User tidak ditemukan." }) + return + } + res.json({ user: rows[0] }) + } catch (error) { + next(error) + } +}) + +app.get("/api/products", async (req, res, next) => { + try { + const [rows] = await pool.query( + "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products ORDER BY created_at DESC, id DESC" + ) + res.json({ products: await attachProductImages(rows) }) + } catch (error) { + next(error) + } +}) + +app.get("/api/products/mine", auth, async (req, res, next) => { + try { + const [rows] = await pool.query( + "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE user_id = ? ORDER BY created_at DESC, id DESC", + [req.user.id] + ) + res.json({ products: await attachProductImages(rows) }) + } catch (error) { + next(error) + } +}) + +app.get("/api/products/:id", async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + if (!product) { + res.status(404).json({ message: "Produk tidak ditemukan." }) + return + } + res.json({ product }) + } catch (error) { + next(error) + } +}) + +app.post( + "/api/products", + auth, + upload.fields([ + { name: "images", maxCount: maxImageCount }, + { name: "image", maxCount: maxImageCount } + ]), + async (req, res, next) => { + try { + const { title, category, price, condition, location, seller, whatsapp, description } = req.body + const uploadedImages = getUploadedImages(req) + + if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !uploadedImages.length) { + res.status(400).json({ message: "Semua field produk dan minimal 1 foto wajib diisi." }) + return + } + + const mainImagePath = `/uploads/${uploadedImages[0].filename}` + const [result] = await pool.query( + "INSERT INTO products (user_id, title, category, price, `condition`, location, seller, whatsapp, image, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [req.user.id, title, category, Number(price), condition, location, seller, whatsapp, mainImagePath, description] + ) + + const imageRows = uploadedImages.map((file, index) => [result.insertId, `/uploads/${file.filename}`, index]) + await pool.query("INSERT INTO product_images (product_id, image, sort_order) VALUES ?", [imageRows]) + + const product = await findProductById(result.insertId) + res.status(201).json({ product }) + } catch (error) { + next(error) + } + } +) + +app.patch("/api/products/:id/status", auth, async (req, res, next) => { + try { + const status = req.body.status === "Terjual" ? "Terjual" : "Tersedia" + const [result] = await pool.query( + "UPDATE products SET status = ? WHERE id = ? AND user_id = ?", + [status, req.params.id, req.user.id] + ) + + if (!result.affectedRows) { + res.status(404).json({ message: "Produk milik kamu tidak ditemukan." }) + return + } + + const product = await findProductById(req.params.id) + res.json({ product }) + } catch (error) { + next(error) + } +}) + +app.delete("/api/products/:id", auth, async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + if (!product || product.user_id !== req.user.id) { + res.status(404).json({ message: "Produk milik kamu tidak ditemukan." }) + return + } + + await pool.query("DELETE FROM products WHERE id = ? AND user_id = ?", [req.params.id, req.user.id]) + await deleteUploadedImages(product) + + res.json({ message: "Produk berhasil dihapus." }) + } catch (error) { + next(error) + } +}) + +app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + + if (!product) { + res.status(404).json({ message: "Produk tidak ditemukan." }) + return + } + + await pool.query("DELETE FROM products WHERE id = ?", [req.params.id]) + await deleteUploadedImages(product) + + res.json({ message: "Produk berhasil dihapus oleh superadmin." }) + } catch (error) { + next(error) + } +}) + +app.use((error, req, res, next) => { + if (error instanceof multer.MulterError) { + res.status(400).json({ message: `Upload gagal. Ukuran setiap foto maksimal ${maxImageSizeMb} MB dan maksimal ${maxImageCount} foto.` }) + return + } + + if (error.message === "File harus berupa gambar.") { + res.status(400).json({ message: error.message }) + return + } + + console.error(error) + res.status(500).json({ + message: "Terjadi kesalahan pada server.", + detail: process.env.NODE_ENV === "production" ? undefined : error.message + }) +}) + +ensureSchema() + .then(() => { + app.listen(port, () => { + console.log(`SecondTech API berjalan di http://localhost:${port}`) + console.log(`Upload foto: maksimal ${maxImageSizeMb} MB per foto, maksimal ${maxImageCount} foto.`) + }) + }) + .catch((error) => { + console.error("Gagal menyiapkan database:", error) + process.exit(1) + }) diff --git a/backup-before-gallery-darkmode-repair-20260519-100158/src/assets/main.css b/backup-before-gallery-darkmode-repair-20260519-100158/src/assets/main.css new file mode 100644 index 0000000..00deec0 --- /dev/null +++ b/backup-before-gallery-darkmode-repair-20260519-100158/src/assets/main.css @@ -0,0 +1,106 @@ +@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +* { + scroll-behavior: smooth; +} + +body { + @apply bg-slate-50 text-slate-900 font-sans; +} + +.container-page { + @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8; +} + +.btn-primary { + @apply inline-flex items-center justify-center gap-2 rounded-xl bg-brand-600 px-5 py-3 text-sm font-semibold text-white transition hover:bg-brand-700; +} + +.btn-secondary { + @apply inline-flex items-center justify-center gap-2 rounded-xl border border-slate-200 bg-white px-5 py-3 text-sm font-semibold text-slate-800 transition hover:bg-slate-100; +} + +.card { + @apply rounded-2xl border border-slate-200 bg-white shadow-sm; +} + +.input { + @apply w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm outline-none transition focus:border-brand-500 focus:ring-4 focus:ring-brand-100; +} + +.label { + @apply text-sm font-semibold text-slate-700; +} + +.btn-WA { + @apply inline-flex items-center justify-center gap-2 rounded-xl bg-green-500 px-5 py-3 text-sm font-semibold text-white transition hover:bg-green-600; +} + +/* SecondTech dark mode */ +html.dark body { + background: #121212; + color: #f8fafc; +} + +html.dark header { + background: rgba(18, 18, 18, 0.94); + border-color: rgba(37, 99, 235, 0.34); +} + +html.dark main, +html.dark section { + background: #121212; +} + +html.dark .card { + background: #181818; + border-color: rgba(37, 99, 235, 0.30); + box-shadow: 0 14px 34px rgba(37, 99, 235, 0.20); +} + +html.dark .input { + background: #181818; + border-color: rgba(37, 99, 235, 0.34); + color: #f8fafc; +} + +html.dark .input::placeholder { + color: #94a3b8; +} + +html.dark .btn-secondary { + background: #181818; + border-color: rgba(37, 99, 235, 0.38); + color: #f8fafc; + box-shadow: 0 10px 24px rgba(37, 99, 235, 0.12); +} + +html.dark .btn-primary { + box-shadow: 0 12px 28px rgba(37, 99, 235, 0.24); +} + +html.dark .bg-white, +html.dark .bg-slate-50, +html.dark .bg-slate-100 { + background-color: #181818; +} + +html.dark .text-slate-950, +html.dark .text-slate-900, +html.dark .text-slate-800, +html.dark .text-slate-700 { + color: #f8fafc; +} + +html.dark .text-slate-600, +html.dark .text-slate-500 { + color: #cbd5e1; +} + +html.dark .border-slate-200 { + border-color: rgba(37, 99, 235, 0.30); +} diff --git a/backup-before-gallery-darkmode-repair-20260519-100158/src/components/ProductCard.vue b/backup-before-gallery-darkmode-repair-20260519-100158/src/components/ProductCard.vue new file mode 100644 index 0000000..b70be73 --- /dev/null +++ b/backup-before-gallery-darkmode-repair-20260519-100158/src/components/ProductCard.vue @@ -0,0 +1,66 @@ + + + diff --git a/backup-before-gallery-darkmode-repair-20260519-100158/src/utils.js b/backup-before-gallery-darkmode-repair-20260519-100158/src/utils.js new file mode 100644 index 0000000..725ba6a --- /dev/null +++ b/backup-before-gallery-darkmode-repair-20260519-100158/src/utils.js @@ -0,0 +1,137 @@ +export const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:5000" + +export function formatRupiah(value) { + return new Intl.NumberFormat("id-ID", { + style: "currency", + currency: "IDR", + maximumFractionDigits: 0 + }).format(value) +} + +export function getLocal(key, fallback) { + try { + const value = localStorage.getItem(key) + return value ? JSON.parse(value) : fallback + } catch { + return fallback + } +} + +export function setLocal(key, value) { + localStorage.setItem(key, JSON.stringify(value)) +} + +export function getToken() { + return localStorage.getItem("secondtech_token") +} + +export function getCurrentUser() { + return getLocal("secondtech_user", null) +} + +export function setAuth({ token, user }) { + localStorage.setItem("secondtech_token", token) + + setLocal("secondtech_user", { + id: user.id, + name: user.name, + email: user.email, + whatsapp: user.whatsapp, + role: user.role || "user" + }) +} + +export function clearAuth() { + localStorage.removeItem("secondtech_token") + localStorage.removeItem("secondtech_user") +} + +export function getProductImageUrl(image) { + if (!image) return "" + if (image.startsWith("http://") || image.startsWith("https://") || image.startsWith("data:")) return image + return `${API_BASE_URL}${image}` +} + +async function requestJson(path, options = {}) { + const headers = new Headers(options.headers || {}) + const token = getToken() + + if (token) headers.set("Authorization", `Bearer ${token}`) + if (!(options.body instanceof FormData)) headers.set("Content-Type", "application/json") + + const response = await fetch(`${API_BASE_URL}${path}`, { + ...options, + headers + }) + const data = await response.json().catch(() => ({})) + + if (!response.ok) { + throw new Error(data.message || "Request gagal.") + } + + return data +} + +export function registerUser(payload) { + return requestJson("/api/auth/register", { + method: "POST", + body: JSON.stringify(payload) + }) +} + +export function loginUser(payload) { + return requestJson("/api/auth/login", { + method: "POST", + body: JSON.stringify(payload) + }) +} + +export function fetchCurrentUser() { + return requestJson("/api/auth/me") +} + +export function fetchProducts() { + return requestJson("/api/products") +} + +export function fetchProduct(id) { + return requestJson(`/api/products/${id}`) +} + +export function fetchMyProducts() { + return requestJson("/api/products/mine") +} + +export function createProduct(formData) { + return requestJson("/api/products", { + method: "POST", + body: formData + }) +} + +export function updateProductStatus(id, status) { + return requestJson(`/api/products/${id}/status`, { + method: "PATCH", + body: JSON.stringify({ status }) + }) +} + +export function deleteProductById(id) { + return requestJson(`/api/products/${id}`, { + method: "DELETE" + }) +} + +export function deleteProductAsAdmin(id) { + return requestJson(`/api/admin/products/${id}`, { + method: "DELETE" + }) +} + +export function getSavedProducts() { + return getLocal("secondtech_saved", []) +} + +export function setSavedProducts(ids) { + setLocal("secondtech_saved", ids) +} diff --git a/backup-before-gallery-darkmode-repair-20260519-100158/src/views/ProductDetailView.vue b/backup-before-gallery-darkmode-repair-20260519-100158/src/views/ProductDetailView.vue new file mode 100644 index 0000000..bf34d2c --- /dev/null +++ b/backup-before-gallery-darkmode-repair-20260519-100158/src/views/ProductDetailView.vue @@ -0,0 +1,135 @@ + + + diff --git a/backup-before-gallery-darkmode-repair-20260519-100158/src/views/SellProductView.vue b/backup-before-gallery-darkmode-repair-20260519-100158/src/views/SellProductView.vue new file mode 100644 index 0000000..c31b072 --- /dev/null +++ b/backup-before-gallery-darkmode-repair-20260519-100158/src/views/SellProductView.vue @@ -0,0 +1,173 @@ + + + + diff --git a/backup-before-upload-limit-20260519-093708/SellProductView.vue b/backup-before-upload-limit-20260519-093708/SellProductView.vue new file mode 100644 index 0000000..d927e52 --- /dev/null +++ b/backup-before-upload-limit-20260519-093708/SellProductView.vue @@ -0,0 +1,158 @@ + + + diff --git a/backup-before-upload-limit-20260519-093708/server.js b/backup-before-upload-limit-20260519-093708/server.js new file mode 100644 index 0000000..eae1282 --- /dev/null +++ b/backup-before-upload-limit-20260519-093708/server.js @@ -0,0 +1,348 @@ +import bcrypt from "bcryptjs" +import cors from "cors" +import dotenv from "dotenv" +import express from "express" +import fs from "fs" +import jwt from "jsonwebtoken" +import multer from "multer" +import path from "path" +import { fileURLToPath } from "url" +import { pool } from "./db.js" + +dotenv.config() + +const app = express() +const port = Number(process.env.PORT || 5000) +const jwtSecret = process.env.JWT_SECRET || "secondtech_dev_secret" +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const uploadsDir = path.join(__dirname, "uploads") + +if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }) +} + +app.use(cors({ + origin: process.env.CLIENT_URL || "http://localhost:5173", + credentials: true +})) +app.use(express.json()) +app.use("/uploads", express.static(uploadsDir)) + +const storage = multer.diskStorage({ + destination: uploadsDir, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase() + cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`) + } +}) + +const upload = multer({ + storage, + limits: { fileSize: 3 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + if (!file.mimetype.startsWith("image/")) { + cb(new Error("File harus berupa gambar.")) + return + } + cb(null, true) + } +}) + +function signUser(user) { + return jwt.sign({ id: user.id, email: user.email }, jwtSecret, { expiresIn: "7d" }) +} + +function publicUser(user) { + return { + id: user.id, + name: user.name, + email: user.email, + whatsapp: user.whatsapp, + role: user.role || "user" + } +} + +function auth(req, res, next) { + const header = req.headers.authorization || "" + const token = header.startsWith("Bearer ") ? header.slice(7) : "" + + if (!token) { + res.status(401).json({ message: "Silakan login terlebih dahulu." }) + return + } + + try { + req.user = jwt.verify(token, jwtSecret) + next() + } catch { + res.status(401).json({ message: "Sesi login tidak valid." }) + } +} + +async function superadminOnly(req, res, next) { + try { + const [rows] = await pool.query("SELECT role FROM users WHERE id = ?", [req.user.id]) + + if (!rows[0] || rows[0].role !== "superadmin") { + res.status(403).json({ message: "Hanya superadmin yang boleh mengakses fitur ini." }) + return + } + + next() + } catch (error) { + next(error) + } +} + +async function getProductImages(productId, fallbackImage) { + const [images] = await pool.query( + "SELECT image FROM product_images WHERE product_id = ? ORDER BY sort_order ASC, id ASC", + [productId] + ) + + return images.length ? images.map((item) => item.image) : [fallbackImage] +} + +async function attachProductImages(products) { + return Promise.all( + products.map(async (product) => ({ + ...product, + images: await getProductImages(product.id, product.image) + })) + ) +} + +async function findProductById(id) { + const [rows] = await pool.query( + "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE id = ?", + [id] + ) + + const product = rows[0] + if (!product) return null + + product.images = await getProductImages(product.id, product.image) + return product +} + +async function deleteUploadedImages(product) { + const images = product.images?.length ? product.images : [product.image] + + images.forEach((image) => { + if (image?.startsWith("/uploads/")) { + fs.rm(path.join(uploadsDir, path.basename(image)), { force: true }, () => {}) + } + }) +} + +app.get("/api/health", async (req, res, next) => { + try { + await pool.query("SELECT 1") + res.json({ status: "ok" }) + } catch (error) { + next(error) + } +}) + +app.post("/api/auth/register", async (req, res, next) => { + try { + const { name, email, whatsapp, password } = req.body + + if (!name || !email || !whatsapp || !password) { + res.status(400).json({ message: "Semua field wajib diisi." }) + return + } + + if (password.length < 6) { + res.status(400).json({ message: "Password minimal 6 karakter." }) + return + } + + const passwordHash = await bcrypt.hash(password, 10) + const [result] = await pool.query( + "INSERT INTO users (name, email, whatsapp, password_hash) VALUES (?, ?, ?, ?)", + [name, email.toLowerCase(), whatsapp, passwordHash] + ) + + const user = { id: result.insertId, name, email: email.toLowerCase(), whatsapp, role: "user" } + res.status(201).json({ token: signUser(user), user: publicUser(user) }) + } catch (error) { + if (error.code === "ER_DUP_ENTRY") { + res.status(409).json({ message: "Email sudah terdaftar." }) + return + } + next(error) + } +}) + +app.post("/api/auth/login", async (req, res, next) => { + try { + const { email, password } = req.body + + if (!email || !password) { + res.status(400).json({ message: "Email dan password wajib diisi." }) + return + } + + const [rows] = await pool.query("SELECT * FROM users WHERE email = ?", [email.toLowerCase()]) + const user = rows[0] + + if (!user || !(await bcrypt.compare(password, user.password_hash))) { + res.status(401).json({ message: "Email atau password salah." }) + return + } + + res.json({ token: signUser(user), user: publicUser(user) }) + } catch (error) { + next(error) + } +}) + +app.get("/api/auth/me", auth, async (req, res, next) => { + try { + const [rows] = await pool.query("SELECT id, name, email, whatsapp, role FROM users WHERE id = ?", [req.user.id]) + if (!rows[0]) { + res.status(404).json({ message: "User tidak ditemukan." }) + return + } + res.json({ user: rows[0] }) + } catch (error) { + next(error) + } +}) + +app.get("/api/products", async (req, res, next) => { + try { + const [rows] = await pool.query( + "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products ORDER BY created_at DESC, id DESC" + ) + res.json({ products: await attachProductImages(rows) }) + } catch (error) { + next(error) + } +}) + +app.get("/api/products/mine", auth, async (req, res, next) => { + try { + const [rows] = await pool.query( + "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE user_id = ? ORDER BY created_at DESC, id DESC", + [req.user.id] + ) + res.json({ products: await attachProductImages(rows) }) + } catch (error) { + next(error) + } +}) + +app.get("/api/products/:id", async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + if (!product) { + res.status(404).json({ message: "Produk tidak ditemukan." }) + return + } + res.json({ product }) + } catch (error) { + next(error) + } +}) + +app.post("/api/products", auth, upload.array("images", 6), async (req, res, next) => { + try { + const { title, category, price, condition, location, seller, whatsapp, description } = req.body + + if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !req.files?.length) { + res.status(400).json({ message: "Semua field produk dan minimal 1 foto wajib diisi." }) + return + } + + const mainImagePath = `/uploads/${req.files[0].filename}` + const [result] = await pool.query( + "INSERT INTO products (user_id, title, category, price, `condition`, location, seller, whatsapp, image, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [req.user.id, title, category, Number(price), condition, location, seller, whatsapp, mainImagePath, description] + ) + + const imageRows = req.files.map((file, index) => [result.insertId, `/uploads/${file.filename}`, index]) + await pool.query("INSERT INTO product_images (product_id, image, sort_order) VALUES ?", [imageRows]) + + const product = await findProductById(result.insertId) + res.status(201).json({ product }) + } catch (error) { + next(error) + } +}) + +app.patch("/api/products/:id/status", auth, async (req, res, next) => { + try { + const status = req.body.status === "Terjual" ? "Terjual" : "Tersedia" + const [result] = await pool.query( + "UPDATE products SET status = ? WHERE id = ? AND user_id = ?", + [status, req.params.id, req.user.id] + ) + + if (!result.affectedRows) { + res.status(404).json({ message: "Produk milik kamu tidak ditemukan." }) + return + } + + const product = await findProductById(req.params.id) + res.json({ product }) + } catch (error) { + next(error) + } +}) + +app.delete("/api/products/:id", auth, async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + if (!product || product.user_id !== req.user.id) { + res.status(404).json({ message: "Produk milik kamu tidak ditemukan." }) + return + } + + await pool.query("DELETE FROM products WHERE id = ? AND user_id = ?", [req.params.id, req.user.id]) + await deleteUploadedImages(product) + + res.json({ message: "Produk berhasil dihapus." }) + } catch (error) { + next(error) + } +}) + +app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + + if (!product) { + res.status(404).json({ message: "Produk tidak ditemukan." }) + return + } + + await pool.query("DELETE FROM products WHERE id = ?", [req.params.id]) + await deleteUploadedImages(product) + + res.json({ message: "Produk berhasil dihapus oleh superadmin." }) + } catch (error) { + next(error) + } +}) + +app.use((error, req, res, next) => { + if (error instanceof multer.MulterError) { + res.status(400).json({ message: "Upload gagal. Ukuran setiap foto maksimal 3MB dan maksimal 6 foto." }) + return + } + + if (error.message === "File harus berupa gambar.") { + res.status(400).json({ message: error.message }) + return + } + + console.error(error) + res.status(500).json({ message: "Terjadi kesalahan pada server." }) +}) + +app.listen(port, () => { + console.log(`SecondTech API berjalan di http://localhost:${port}`) +}) diff --git a/backup-before-upload-repair-20260519-095013/server/server.js b/backup-before-upload-repair-20260519-095013/server/server.js new file mode 100644 index 0000000..0abda9f --- /dev/null +++ b/backup-before-upload-repair-20260519-095013/server/server.js @@ -0,0 +1,349 @@ +import bcrypt from "bcryptjs" +import cors from "cors" +import dotenv from "dotenv" +import express from "express" +import fs from "fs" +import jwt from "jsonwebtoken" +import multer from "multer" +import path from "path" +import { fileURLToPath } from "url" +import { pool } from "./db.js" + +dotenv.config() + +const app = express() +const port = Number(process.env.PORT || 5000) +const jwtSecret = process.env.JWT_SECRET || "secondtech_dev_secret" +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const uploadsDir = path.join(__dirname, "uploads") + +if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }) +} + +app.use(cors({ + origin: process.env.CLIENT_URL || "http://localhost:5173", + credentials: true +})) +app.use(express.json()) +app.use("/uploads", express.static(uploadsDir)) + +const storage = multer.diskStorage({ + destination: uploadsDir, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase() + cb(null, `${Date.now()}-${Math.round(Math.random() * 1e9)}${ext}`) + } +}) + +const upload = multer({ + storage, + limits: { fileSize: 10 * 1024 * 1024 }, + fileFilter: (req, file, cb) => { + if (!file.mimetype.startsWith("image/")) { + cb(new Error("File harus berupa gambar.")) + return + } + cb(null, true) + } +}) + +function signUser(user) { + return jwt.sign({ id: user.id, email: user.email }, jwtSecret, { expiresIn: "7d" }) +} + +function publicUser(user) { + return { + id: user.id, + name: user.name, + email: user.email, + whatsapp: user.whatsapp, + role: user.role || "user" + } +} + +function auth(req, res, next) { + const header = req.headers.authorization || "" + const token = header.startsWith("Bearer ") ? header.slice(7) : "" + + if (!token) { + res.status(401).json({ message: "Silakan login terlebih dahulu." }) + return + } + + try { + req.user = jwt.verify(token, jwtSecret) + next() + } catch { + res.status(401).json({ message: "Sesi login tidak valid." }) + } +} + +async function superadminOnly(req, res, next) { + try { + const [rows] = await pool.query("SELECT role FROM users WHERE id = ?", [req.user.id]) + + if (!rows[0] || rows[0].role !== "superadmin") { + res.status(403).json({ message: "Hanya superadmin yang boleh mengakses fitur ini." }) + return + } + + next() + } catch (error) { + next(error) + } +} + +async function getProductImages(productId, fallbackImage) { + const [images] = await pool.query( + "SELECT image FROM product_images WHERE product_id = ? ORDER BY sort_order ASC, id ASC", + [productId] + ) + + return images.length ? images.map((item) => item.image) : [fallbackImage] +} + +async function attachProductImages(products) { + return Promise.all( + products.map(async (product) => ({ + ...product, + images: await getProductImages(product.id, product.image) + })) + ) +} + +async function findProductById(id) { + const [rows] = await pool.query( + "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE id = ?", + [id] + ) + + const product = rows[0] + if (!product) return null + + product.images = await getProductImages(product.id, product.image) + return product +} + +async function deleteUploadedImages(product) { + const images = product.images?.length ? product.images : [product.image] + + images.forEach((image) => { + if (image?.startsWith("/uploads/")) { + fs.rm(path.join(uploadsDir, path.basename(image)), { force: true }, () => {}) + } + }) +} + +app.get("/api/health", async (req, res, next) => { + try { + await pool.query("SELECT 1") + res.json({ status: "ok" }) + } catch (error) { + next(error) + } +}) + +app.post("/api/auth/register", async (req, res, next) => { + try { + const { name, email, whatsapp, password } = req.body + + if (!name || !email || !whatsapp || !password) { + res.status(400).json({ message: "Semua field wajib diisi." }) + return + } + + if (password.length < 6) { + res.status(400).json({ message: "Password minimal 6 karakter." }) + return + } + + const passwordHash = await bcrypt.hash(password, 10) + const [result] = await pool.query( + "INSERT INTO users (name, email, whatsapp, password_hash) VALUES (?, ?, ?, ?)", + [name, email.toLowerCase(), whatsapp, passwordHash] + ) + + const user = { id: result.insertId, name, email: email.toLowerCase(), whatsapp, role: "user" } + res.status(201).json({ token: signUser(user), user: publicUser(user) }) + } catch (error) { + if (error.code === "ER_DUP_ENTRY") { + res.status(409).json({ message: "Email sudah terdaftar." }) + return + } + next(error) + } +}) + +app.post("/api/auth/login", async (req, res, next) => { + try { + const { email, password } = req.body + + if (!email || !password) { + res.status(400).json({ message: "Email dan password wajib diisi." }) + return + } + + const [rows] = await pool.query("SELECT * FROM users WHERE email = ?", [email.toLowerCase()]) + const user = rows[0] + + if (!user || !(await bcrypt.compare(password, user.password_hash))) { + res.status(401).json({ message: "Email atau password salah." }) + return + } + + res.json({ token: signUser(user), user: publicUser(user) }) + } catch (error) { + next(error) + } +}) + +app.get("/api/auth/me", auth, async (req, res, next) => { + try { + const [rows] = await pool.query("SELECT id, name, email, whatsapp, role FROM users WHERE id = ?", [req.user.id]) + if (!rows[0]) { + res.status(404).json({ message: "User tidak ditemukan." }) + return + } + res.json({ user: rows[0] }) + } catch (error) { + next(error) + } +}) + +app.get("/api/products", async (req, res, next) => { + try { + const [rows] = await pool.query( + "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products ORDER BY created_at DESC, id DESC" + ) + res.json({ products: await attachProductImages(rows) }) + } catch (error) { + next(error) + } +}) + +app.get("/api/products/mine", auth, async (req, res, next) => { + try { + const [rows] = await pool.query( + "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE user_id = ? ORDER BY created_at DESC, id DESC", + [req.user.id] + ) + res.json({ products: await attachProductImages(rows) }) + } catch (error) { + next(error) + } +}) + +app.get("/api/products/:id", async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + if (!product) { + res.status(404).json({ message: "Produk tidak ditemukan." }) + return + } + res.json({ product }) + } catch (error) { + next(error) + } +}) + +app.post("/api/products", auth, upload.array("images", 6), async (req, res, next) => { + try { + const { title, category, price, condition, location, seller, whatsapp, description } = req.body + + if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !req.files?.length) { + res.status(400).json({ message: "Semua field produk dan minimal 1 foto wajib diisi." }) + return + } + + const mainImagePath = `/uploads/${req.files[0].filename}` + const [result] = await pool.query( + "INSERT INTO products (user_id, title, category, price, `condition`, location, seller, whatsapp, image, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [req.user.id, title, category, Number(price), condition, location, seller, whatsapp, mainImagePath, description] + ) + + const imageRows = req.files.map((file, index) => [result.insertId, `/uploads/${file.filename}`, index]) + await pool.query("INSERT INTO product_images (product_id, image, sort_order) VALUES ?", [imageRows]) + + const product = await findProductById(result.insertId) + res.status(201).json({ product }) + } catch (error) { + next(error) + } +}) + +app.patch("/api/products/:id/status", auth, async (req, res, next) => { + try { + const status = req.body.status === "Terjual" ? "Terjual" : "Tersedia" + const [result] = await pool.query( + "UPDATE products SET status = ? WHERE id = ? AND user_id = ?", + [status, req.params.id, req.user.id] + ) + + if (!result.affectedRows) { + res.status(404).json({ message: "Produk milik kamu tidak ditemukan." }) + return + } + + const product = await findProductById(req.params.id) + res.json({ product }) + } catch (error) { + next(error) + } +}) + +app.delete("/api/products/:id", auth, async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + if (!product || product.user_id !== req.user.id) { + res.status(404).json({ message: "Produk milik kamu tidak ditemukan." }) + return + } + + await pool.query("DELETE FROM products WHERE id = ? AND user_id = ?", [req.params.id, req.user.id]) + await deleteUploadedImages(product) + + res.json({ message: "Produk berhasil dihapus." }) + } catch (error) { + next(error) + } +}) + +app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + + if (!product) { + res.status(404).json({ message: "Produk tidak ditemukan." }) + return + } + + await pool.query("DELETE FROM products WHERE id = ?", [req.params.id]) + await deleteUploadedImages(product) + + res.json({ message: "Produk berhasil dihapus oleh superadmin." }) + } catch (error) { + next(error) + } +}) + +app.use((error, req, res, next) => { + if (error instanceof multer.MulterError) { + res.status(400).json({ message: "Upload gagal. Ukuran setiap foto maksimal 10 MB dan maksimal 6 foto." }) + return + } + + if (error.message === "File harus berupa gambar.") { + res.status(400).json({ message: error.message }) + return + } + + console.error(error) + res.status(500).json({ message: "Terjadi kesalahan pada server." }) +}) + +app.listen(port, () => { + console.log(`SecondTech API berjalan di http://localhost:${port}`) +}) + diff --git a/backup-before-upload-repair-20260519-095013/src/utils.js b/backup-before-upload-repair-20260519-095013/src/utils.js new file mode 100644 index 0000000..725ba6a --- /dev/null +++ b/backup-before-upload-repair-20260519-095013/src/utils.js @@ -0,0 +1,137 @@ +export const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:5000" + +export function formatRupiah(value) { + return new Intl.NumberFormat("id-ID", { + style: "currency", + currency: "IDR", + maximumFractionDigits: 0 + }).format(value) +} + +export function getLocal(key, fallback) { + try { + const value = localStorage.getItem(key) + return value ? JSON.parse(value) : fallback + } catch { + return fallback + } +} + +export function setLocal(key, value) { + localStorage.setItem(key, JSON.stringify(value)) +} + +export function getToken() { + return localStorage.getItem("secondtech_token") +} + +export function getCurrentUser() { + return getLocal("secondtech_user", null) +} + +export function setAuth({ token, user }) { + localStorage.setItem("secondtech_token", token) + + setLocal("secondtech_user", { + id: user.id, + name: user.name, + email: user.email, + whatsapp: user.whatsapp, + role: user.role || "user" + }) +} + +export function clearAuth() { + localStorage.removeItem("secondtech_token") + localStorage.removeItem("secondtech_user") +} + +export function getProductImageUrl(image) { + if (!image) return "" + if (image.startsWith("http://") || image.startsWith("https://") || image.startsWith("data:")) return image + return `${API_BASE_URL}${image}` +} + +async function requestJson(path, options = {}) { + const headers = new Headers(options.headers || {}) + const token = getToken() + + if (token) headers.set("Authorization", `Bearer ${token}`) + if (!(options.body instanceof FormData)) headers.set("Content-Type", "application/json") + + const response = await fetch(`${API_BASE_URL}${path}`, { + ...options, + headers + }) + const data = await response.json().catch(() => ({})) + + if (!response.ok) { + throw new Error(data.message || "Request gagal.") + } + + return data +} + +export function registerUser(payload) { + return requestJson("/api/auth/register", { + method: "POST", + body: JSON.stringify(payload) + }) +} + +export function loginUser(payload) { + return requestJson("/api/auth/login", { + method: "POST", + body: JSON.stringify(payload) + }) +} + +export function fetchCurrentUser() { + return requestJson("/api/auth/me") +} + +export function fetchProducts() { + return requestJson("/api/products") +} + +export function fetchProduct(id) { + return requestJson(`/api/products/${id}`) +} + +export function fetchMyProducts() { + return requestJson("/api/products/mine") +} + +export function createProduct(formData) { + return requestJson("/api/products", { + method: "POST", + body: formData + }) +} + +export function updateProductStatus(id, status) { + return requestJson(`/api/products/${id}/status`, { + method: "PATCH", + body: JSON.stringify({ status }) + }) +} + +export function deleteProductById(id) { + return requestJson(`/api/products/${id}`, { + method: "DELETE" + }) +} + +export function deleteProductAsAdmin(id) { + return requestJson(`/api/admin/products/${id}`, { + method: "DELETE" + }) +} + +export function getSavedProducts() { + return getLocal("secondtech_saved", []) +} + +export function setSavedProducts(ids) { + setLocal("secondtech_saved", ids) +} diff --git a/backup-before-upload-repair-20260519-095013/src/views/SellProductView.vue b/backup-before-upload-repair-20260519-095013/src/views/SellProductView.vue new file mode 100644 index 0000000..c31b072 --- /dev/null +++ b/backup-before-upload-repair-20260519-095013/src/views/SellProductView.vue @@ -0,0 +1,173 @@ + + + + diff --git a/database.sql b/database.sql index a58e462..f559507 100644 --- a/database.sql +++ b/database.sql @@ -56,3 +56,14 @@ ON DUPLICATE KEY UPDATE image = VALUES(image), description = VALUES(description), status = VALUES(status); + +CREATE TABLE IF NOT EXISTS product_images ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + product_id INT UNSIGNED NOT NULL, + image VARCHAR(255) NOT NULL, + sort_order INT UNSIGNED NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_product_images_product + FOREIGN KEY (product_id) REFERENCES products(id) + ON DELETE CASCADE +); diff --git a/server/server.js b/server/server.js index f937b28..2e968ff 100644 --- a/server/server.js +++ b/server/server.js @@ -14,6 +14,8 @@ dotenv.config() const app = express() const port = Number(process.env.PORT || 5000) const jwtSecret = process.env.JWT_SECRET || "secondtech_dev_secret" +const maxImageSizeMb = Number(process.env.MAX_IMAGE_SIZE_MB || 10) +const maxImageCount = Number(process.env.MAX_IMAGE_COUNT || 6) const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const uploadsDir = path.join(__dirname, "uploads") @@ -39,7 +41,7 @@ const storage = multer.diskStorage({ const upload = multer({ storage, - limits: { fileSize: 3 * 1024 * 1024 }, + limits: { fileSize: maxImageSizeMb * 1024 * 1024, files: maxImageCount }, fileFilter: (req, file, cb) => { if (!file.mimetype.startsWith("image/")) { cb(new Error("File harus berupa gambar.")) @@ -49,6 +51,21 @@ const upload = multer({ } }) +async function ensureSchema() { + await pool.query(` + CREATE TABLE IF NOT EXISTS product_images ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + product_id INT UNSIGNED NOT NULL, + image VARCHAR(255) NOT NULL, + sort_order INT UNSIGNED NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_product_images_product + FOREIGN KEY (product_id) REFERENCES products(id) + ON DELETE CASCADE + ) + `) +} + function signUser(user) { return jwt.sign({ id: user.id, email: user.email }, jwtSecret, { expiresIn: "7d" }) } @@ -80,12 +97,81 @@ function auth(req, res, next) { } } +async function superadminOnly(req, res, next) { + try { + const [rows] = await pool.query("SELECT role FROM users WHERE id = ?", [req.user.id]) + + if (!rows[0] || rows[0].role !== "superadmin") { + res.status(403).json({ message: "Hanya superadmin yang boleh mengakses fitur ini." }) + return + } + + next() + } catch (error) { + next(error) + } +} + +function uniqueImages(files) { + const seen = new Set() + return files.filter((file) => { + const key = `${file.originalname}-${file.size}-${file.mimetype}` + if (seen.has(key)) return false + seen.add(key) + return true + }).slice(0, maxImageCount) +} + +function getUploadedImages(req) { + const files = [ + ...(req.files?.images || []), + ...(req.files?.image || []), + ...(req.files?.photos || []) + ] + return uniqueImages(files) +} + +async function getProductImages(productId, fallbackImage) { + const [images] = await pool.query( + "SELECT image FROM product_images WHERE product_id = ? ORDER BY sort_order ASC, id ASC", + [productId] + ) + + const imagePaths = images.map((item) => item.image).filter(Boolean) + if (imagePaths.length) return imagePaths + return fallbackImage ? [fallbackImage] : [] +} + +async function attachProductImages(products) { + return Promise.all( + products.map(async (product) => ({ + ...product, + images: await getProductImages(product.id, product.image) + })) + ) +} + async function findProductById(id) { const [rows] = await pool.query( "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE id = ?", [id] ) - return rows[0] + + const product = rows[0] + if (!product) return null + + product.images = await getProductImages(product.id, product.image) + return product +} + +async function deleteUploadedImages(product) { + const images = product.images?.length ? product.images : [product.image] + + images.forEach((image) => { + if (image?.startsWith("/uploads/")) { + fs.rm(path.join(uploadsDir, path.basename(image)), { force: true }, () => {}) + } + }) } app.get("/api/health", async (req, res, next) => { @@ -97,6 +183,24 @@ app.get("/api/health", async (req, res, next) => { } }) +app.get("/api/debug/products/:id/images", async (req, res, next) => { + try { + const product = await findProductById(req.params.id) + if (!product) { + res.status(404).json({ message: "Produk tidak ditemukan." }) + return + } + res.json({ + product_id: Number(req.params.id), + main_image: product.image, + image_count: product.images.length, + images: product.images + }) + } catch (error) { + next(error) + } +}) + app.post("/api/auth/register", async (req, res, next) => { try { const { name, email, whatsapp, password } = req.body @@ -169,7 +273,7 @@ app.get("/api/products", async (req, res, next) => { const [rows] = await pool.query( "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products ORDER BY created_at DESC, id DESC" ) - res.json({ products: rows }) + res.json({ products: await attachProductImages(rows) }) } catch (error) { next(error) } @@ -181,7 +285,7 @@ app.get("/api/products/mine", auth, async (req, res, next) => { "SELECT id, user_id, title, category, price, `condition`, location, seller, whatsapp, image, description, status, created_at FROM products WHERE user_id = ? ORDER BY created_at DESC, id DESC", [req.user.id] ) - res.json({ products: rows }) + res.json({ products: await attachProductImages(rows) }) } catch (error) { next(error) } @@ -200,27 +304,46 @@ app.get("/api/products/:id", async (req, res, next) => { } }) -app.post("/api/products", auth, upload.single("image"), async (req, res, next) => { - try { - const { title, category, price, condition, location, seller, whatsapp, description } = req.body +app.post( + "/api/products", + auth, + upload.fields([ + { name: "images", maxCount: maxImageCount }, + { name: "image", maxCount: maxImageCount }, + { name: "photos", maxCount: maxImageCount } + ]), + async (req, res, next) => { + try { + const { title, category, price, condition, location, seller, whatsapp, description } = req.body + const uploadedImages = getUploadedImages(req) - if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !req.file) { - res.status(400).json({ message: "Semua field produk dan foto wajib diisi." }) - return + console.log("Upload produk:", { + title, + imageCount: uploadedImages.length, + files: uploadedImages.map((file) => file.originalname) + }) + + if (!title || !category || !price || !condition || !location || !seller || !whatsapp || !description || !uploadedImages.length) { + res.status(400).json({ message: "Semua field produk dan minimal 1 foto wajib diisi." }) + return + } + + const mainImagePath = `/uploads/${uploadedImages[0].filename}` + const [result] = await pool.query( + "INSERT INTO products (user_id, title, category, price, `condition`, location, seller, whatsapp, image, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + [req.user.id, title, category, Number(price), condition, location, seller, whatsapp, mainImagePath, description] + ) + + const imageRows = uploadedImages.map((file, index) => [result.insertId, `/uploads/${file.filename}`, index]) + await pool.query("INSERT INTO product_images (product_id, image, sort_order) VALUES ?", [imageRows]) + + const product = await findProductById(result.insertId) + res.status(201).json({ product }) + } catch (error) { + next(error) } - - const imagePath = `/uploads/${req.file.filename}` - const [result] = await pool.query( - "INSERT INTO products (user_id, title, category, price, `condition`, location, seller, whatsapp, image, description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - [req.user.id, title, category, Number(price), condition, location, seller, whatsapp, imagePath, description] - ) - - const product = await findProductById(result.insertId) - res.status(201).json({ product }) - } catch (error) { - next(error) } -}) +) app.patch("/api/products/:id/status", auth, async (req, res, next) => { try { @@ -251,10 +374,7 @@ app.delete("/api/products/:id", auth, async (req, res, next) => { } await pool.query("DELETE FROM products WHERE id = ? AND user_id = ?", [req.params.id, req.user.id]) - - if (product.image.startsWith("/uploads/")) { - fs.rm(path.join(uploadsDir, path.basename(product.image)), { force: true }, () => {}) - } + await deleteUploadedImages(product) res.json({ message: "Produk berhasil dihapus." }) } catch (error) { @@ -272,10 +392,7 @@ app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, nex } await pool.query("DELETE FROM products WHERE id = ?", [req.params.id]) - - if (product.image.startsWith("/uploads/")) { - fs.rm(path.join(uploadsDir, path.basename(product.image)), { force: true }, () => {}) - } + await deleteUploadedImages(product) res.json({ message: "Produk berhasil dihapus oleh superadmin." }) } catch (error) { @@ -283,10 +400,9 @@ app.delete("/api/admin/products/:id", auth, superadminOnly, async (req, res, nex } }) - app.use((error, req, res, next) => { if (error instanceof multer.MulterError) { - res.status(400).json({ message: "Upload gagal. Ukuran foto maksimal 3MB." }) + res.status(400).json({ message: `Upload gagal. Ukuran setiap foto maksimal ${maxImageSizeMb} MB dan maksimal ${maxImageCount} foto.` }) return } @@ -296,28 +412,20 @@ app.use((error, req, res, next) => { } console.error(error) - res.status(500).json({ message: "Terjadi kesalahan pada server." }) + res.status(500).json({ + message: "Terjadi kesalahan pada server.", + detail: process.env.NODE_ENV === "production" ? undefined : error.message + }) }) -app.listen(port, () => { - console.log(`SecondTech API berjalan di http://localhost:${port}`) -}) - - -async function superadminOnly(req, res, next) { - try { - const [rows] = await pool.query( - "SELECT role FROM users WHERE id = ?", - [req.user.id] - ) - - if (!rows[0] || rows[0].role !== "superadmin") { - res.status(403).json({ message: "Hanya superadmin yang boleh mengakses fitur ini." }) - return - } - - next() - } catch (error) { - next(error) - } -} +ensureSchema() + .then(() => { + app.listen(port, () => { + console.log(`SecondTech API berjalan di http://localhost:${port}`) + console.log(`Upload foto: maksimal ${maxImageSizeMb} MB per foto, maksimal ${maxImageCount} foto.`) + }) + }) + .catch((error) => { + console.error("Gagal menyiapkan database:", error) + process.exit(1) + }) diff --git a/server/uploads/1779161590246-513432766.jpg b/server/uploads/1779161590246-513432766.jpg new file mode 100644 index 0000000..831745a Binary files /dev/null and b/server/uploads/1779161590246-513432766.jpg differ diff --git a/server/uploads/1779161590263-738209266.jpg b/server/uploads/1779161590263-738209266.jpg new file mode 100644 index 0000000..949c145 Binary files /dev/null and b/server/uploads/1779161590263-738209266.jpg differ diff --git a/server/uploads/1779161590265-849421370.jpg b/server/uploads/1779161590265-849421370.jpg new file mode 100644 index 0000000..39ff011 Binary files /dev/null and b/server/uploads/1779161590265-849421370.jpg differ diff --git a/src/assets/main.css b/src/assets/main.css index a5213cc..8e420f6 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -39,3 +39,461 @@ body { .btn-WA { @apply inline-flex items-center justify-center gap-2 rounded-xl bg-green-500 px-5 py-3 text-sm font-semibold text-white transition hover:bg-green-600; } + +/* SecondTech dark mode */ +html.dark body { + background: #121212; + color: #f8fafc; +} + +html.dark header { + background: rgba(18, 18, 18, 0.94); + border-color: rgba(37, 99, 235, 0.34); +} + +html.dark main, +html.dark section { + background: #121212; +} + +html.dark .card { + background: #181818; + border-color: rgba(37, 99, 235, 0.30); + box-shadow: 0 14px 34px rgba(37, 99, 235, 0.20); +} + +html.dark .input { + background: #181818; + border-color: rgba(37, 99, 235, 0.34); + color: #f8fafc; +} + +html.dark .input::placeholder { + color: #94a3b8; +} + +html.dark .btn-secondary { + background: #181818; + border-color: rgba(37, 99, 235, 0.38); + color: #f8fafc; + box-shadow: 0 10px 24px rgba(37, 99, 235, 0.12); +} + +html.dark .btn-primary { + box-shadow: 0 12px 28px rgba(37, 99, 235, 0.24); +} + +html.dark .bg-white, +html.dark .bg-slate-50, +html.dark .bg-slate-100 { + background-color: #181818; +} + +html.dark .text-slate-950, +html.dark .text-slate-900, +html.dark .text-slate-800, +html.dark .text-slate-700 { + color: #f8fafc; +} + +html.dark .text-slate-600, +html.dark .text-slate-500 { + color: #cbd5e1; +} + +html.dark .border-slate-200 { + border-color: rgba(37, 99, 235, 0.30); +} + +/* SecondTech stronger dark mode repair */ +html.dark, +html.dark body { + background: #121212 !important; + color: #f8fafc !important; +} + +html.dark header, +html.dark footer, +html.dark main, +html.dark section { + background-color: #121212 !important; +} + +html.dark header { + border-color: rgba(37, 99, 235, 0.35) !important; +} + +html.dark .card { + background: #181818 !important; + border-color: rgba(37, 99, 235, 0.34) !important; + box-shadow: 0 14px 34px rgba(37, 99, 235, 0.22) !important; +} + +html.dark .input, +html.dark input, +html.dark select, +html.dark textarea { + background-color: #181818 !important; + border-color: rgba(37, 99, 235, 0.38) !important; + color: #f8fafc !important; +} + +html.dark .input::placeholder, +html.dark input::placeholder, +html.dark textarea::placeholder { + color: #94a3b8 !important; +} + +html.dark .btn-secondary { + background: #181818 !important; + border-color: rgba(37, 99, 235, 0.42) !important; + color: #f8fafc !important; + box-shadow: 0 10px 24px rgba(37, 99, 235, 0.14) !important; +} + +html.dark .btn-primary { + box-shadow: 0 12px 28px rgba(37, 99, 235, 0.26) !important; +} + +html.dark .bg-white, +html.dark .bg-slate-50, +html.dark .bg-slate-100, +html.dark .bg-brand-50 { + background-color: #181818 !important; +} + +html.dark .text-slate-950, +html.dark .text-slate-900, +html.dark .text-slate-800, +html.dark .text-slate-700, +html.dark .text-brand-900, +html.dark .text-brand-800, +html.dark .text-brand-700 { + color: #f8fafc !important; +} + +html.dark .text-slate-600, +html.dark .text-slate-500, +html.dark .text-slate-400 { + color: #cbd5e1 !important; +} + +html.dark .nav-link, +html.dark .mobile-link, +html.dark a:not(.btn-primary) { + color: #dbeafe !important; +} + +html.dark .nav-link:hover, +html.dark .mobile-link:hover, +html.dark a:not(.btn-primary):hover { + color: #ffffff !important; +} + +html.dark .border-slate-200, +html.dark .border-brand-600 { + border-color: rgba(37, 99, 235, 0.42) !important; +} + +html.dark .shadow-sm { + box-shadow: 0 14px 34px rgba(37, 99, 235, 0.18) !important; +} + +/* SecondTech final dark mode repair */ +html.dark, +html.dark body { + background: #121212 !important; + color: #f8fafc !important; +} + +html.dark header, +html.dark footer, +html.dark main, +html.dark section { + background-color: #121212 !important; +} + +html.dark header { + border-color: rgba(37, 99, 235, 0.35) !important; +} + +html.dark .card { + background: #181818 !important; + border-color: rgba(37, 99, 235, 0.34) !important; + box-shadow: 0 14px 34px rgba(37, 99, 235, 0.22) !important; +} + +html.dark .input, +html.dark input, +html.dark select, +html.dark textarea { + background-color: #181818 !important; + border-color: rgba(37, 99, 235, 0.38) !important; + color: #f8fafc !important; +} + +html.dark .input::placeholder, +html.dark input::placeholder, +html.dark textarea::placeholder { + color: #94a3b8 !important; +} + +html.dark .btn-secondary { + background: #181818 !important; + border-color: rgba(37, 99, 235, 0.42) !important; + color: #f8fafc !important; + box-shadow: 0 10px 24px rgba(37, 99, 235, 0.14) !important; +} + +html.dark .btn-primary { + box-shadow: 0 12px 28px rgba(37, 99, 235, 0.26) !important; +} + +html.dark .bg-white, +html.dark .bg-slate-50, +html.dark .bg-slate-100, +html.dark .bg-brand-50, +html.dark .bg-emerald-50, +html.dark .bg-red-50 { + background-color: #181818 !important; +} + +html.dark .text-zinc-950, +html.dark .text-zinc-900, +html.dark .text-zinc-800, +html.dark .text-slate-950, +html.dark .text-slate-900, +html.dark .text-slate-800, +html.dark .text-slate-700, +html.dark .text-brand-900, +html.dark .text-brand-800, +html.dark .text-brand-700, +html.dark .text-emerald-700, +html.dark .text-red-700 { + color: #f8fafc !important; +} + +html.dark .text-slate-600, +html.dark .text-slate-500, +html.dark .text-slate-400, +html.dark .text-zinc-600, +html.dark .text-zinc-500, +html.dark .text-zinc-400 { + color: #cbd5e1 !important; +} + +html.dark .nav-link, +html.dark .mobile-link, +html.dark a:not(.btn-primary) { + color: #dbeafe !important; +} + +html.dark .nav-link:hover, +html.dark .mobile-link:hover, +html.dark a:not(.btn-primary):hover { + color: #ffffff !important; +} + +html.dark .border-slate-200, +html.dark .border-brand-600 { + border-color: rgba(37, 99, 235, 0.42) !important; +} + +html.dark .shadow-sm { + box-shadow: 0 14px 34px rgba(37, 99, 235, 0.18) !important; +} + +/* SecondTech final dark mode override */ +:root { + --hero-title-color: #0f1729; +} + +html.dark { + --hero-title-color: #f8fafc; + color-scheme: dark; +} + +html.dark, +html.dark body { + background: #121212 !important; + color: #f8fafc !important; +} + +html.dark header, +html.dark footer, +html.dark main, +html.dark section { + background-color: #121212 !important; +} + +html.dark header { + border-color: rgba(37, 99, 235, 0.36) !important; +} + +html.dark .card { + background: #181818 !important; + border-color: rgba(37, 99, 235, 0.34) !important; + box-shadow: 0 14px 34px rgba(37, 99, 235, 0.22) !important; +} + +html.dark .input, +html.dark input, +html.dark select, +html.dark textarea { + background-color: #181818 !important; + border-color: rgba(37, 99, 235, 0.38) !important; + color: #f8fafc !important; +} + +html.dark .input::placeholder, +html.dark input::placeholder, +html.dark textarea::placeholder { + color: #94a3b8 !important; +} + +html.dark .btn-secondary { + background: #181818 !important; + border-color: rgba(37, 99, 235, 0.42) !important; + color: #f8fafc !important; + box-shadow: 0 10px 24px rgba(37, 99, 235, 0.14) !important; +} + +html.dark .btn-primary { + box-shadow: 0 12px 28px rgba(37, 99, 235, 0.26) !important; +} + +html.dark .bg-white, +html.dark .bg-slate-50, +html.dark .bg-slate-100, +html.dark .bg-brand-50, +html.dark .bg-emerald-50, +html.dark .bg-red-50 { + background-color: #181818 !important; +} + +html.dark .bg-white\/90 { + background-color: rgba(24, 24, 24, 0.9) !important; + color: #f8fafc !important; + border: 1px solid rgba(37, 99, 235, 0.35) !important; +} + +html.dark .text-zinc-950, +html.dark .text-zinc-900, +html.dark .text-zinc-800, +html.dark .text-slate-950, +html.dark .text-slate-900, +html.dark .text-slate-800, +html.dark .text-slate-700, +html.dark .text-brand-950, +html.dark .text-brand-900, +html.dark .text-brand-800, +html.dark .text-brand-700, +html.dark .text-emerald-700, +html.dark .text-red-700 { + color: #f8fafc !important; +} + +html.dark .text-slate-600, +html.dark .text-slate-500, +html.dark .text-slate-400, +html.dark .text-zinc-600, +html.dark .text-zinc-500, +html.dark .text-zinc-400 { + color: #cbd5e1 !important; +} + +html.dark .label, +html.dark label, +html.dark h1, +html.dark h2, +html.dark h3, +html.dark h4, +html.dark p, +html.dark span { + color: inherit; +} + +html.dark .nav-link, +html.dark .mobile-link, +html.dark a:not(.btn-primary) { + color: #dbeafe !important; +} + +html.dark .nav-link:hover, +html.dark .mobile-link:hover, +html.dark a:not(.btn-primary):hover { + color: #ffffff !important; +} + +html.dark .border-slate-200, +html.dark .border-brand-600 { + border-color: rgba(37, 99, 235, 0.42) !important; +} + +html.dark .shadow-sm { + box-shadow: 0 14px 34px rgba(37, 99, 235, 0.18) !important; +} + +/* Sidebar active links are generated by scoped component CSS, so override directly. */ +html.dark .side-link { + color: #e2e8f0 !important; +} + +html.dark .side-link.router-link-active { + background: rgba(37, 99, 235, 0.18) !important; + border: 1px solid rgba(37, 99, 235, 0.40) !important; + color: #ffffff !important; + box-shadow: 0 12px 28px rgba(37, 99, 235, 0.16) !important; +} + +html.dark .side-link.router-link-active svg, +html.dark .side-link svg { + color: currentColor !important; +} + +html.dark .side-link.admin-link, +html.dark .side-link.admin-link.router-link-active { + background: rgba(37, 99, 235, 0.18) !important; + border: 1px solid rgba(37, 99, 235, 0.40) !important; + color: #ffffff !important; +} + +/* Product cards: category badge and save button use bg-white/90. */ +html.dark button.bg-white\/90, +html.dark span.bg-white\/90 { + background-color: rgba(24, 24, 24, 0.90) !important; + color: #f8fafc !important; + border: 1px solid rgba(37, 99, 235, 0.35) !important; +} + +html.dark button.bg-white\/90:hover { + background-color: #2563eb !important; + color: #ffffff !important; +} + +html.dark .rounded-full.bg-brand-50, +html.dark .rounded-full.bg-emerald-50, +html.dark .rounded-full.bg-red-50 { + background-color: rgba(37, 99, 235, 0.16) !important; + color: #f8fafc !important; + border: 1px solid rgba(37, 99, 235, 0.35) !important; +} + +/* Dark mode sidebar hover fix */ +html.dark .side-link:hover { + background: rgba(37, 99, 235, 0.16) !important; + border-color: rgba(37, 99, 235, 0.38) !important; + color: #ffffff !important; +} + +html.dark .side-link:hover svg { + color: #ffffff !important; +} + +html.dark .side-link.router-link-active, +html.dark .side-link.router-link-active:hover { + background: rgba(37, 99, 235, 0.22) !important; + border: 1px solid rgba(37, 99, 235, 0.45) !important; + color: #ffffff !important; + box-shadow: 0 12px 28px rgba(37, 99, 235, 0.16) !important; +} diff --git a/src/components/Navbar.vue b/src/components/Navbar.vue index f043072..6a9df3c 100644 --- a/src/components/Navbar.vue +++ b/src/components/Navbar.vue @@ -2,12 +2,8 @@
-
- SecondTech Logo +
+
SecondTech @@ -18,41 +14,24 @@ Jual Barang Dashboard Tersimpan - - - Admin Marketplace - + Admin Marketplace @@ -68,37 +47,26 @@ Jual Barang Dashboard Tersimpan + Admin Marketplace - - Admin Marketplace - +

{{ currentUser.name }}

{{ currentUser.email }}

-

- Superadmin -

+

Superadmin

- - +
- - Login - - - Register - + Login + Register
@@ -108,17 +76,16 @@