10 Commits

Author SHA1 Message Date
Grey b20b46aa0f test 2026-05-19 10:59:03 +07:00
Grey 6c36f7565e fitur fitur darkmode dan foto foto 2026-05-19 10:47:55 +07:00
Grey 7e1cb94689 memperbaiki bug 2026-05-19 08:55:59 +07:00
Grey 5b40053903 mempercanti style animasi home 2026-05-19 08:49:40 +07:00
Grey 000807d646 Merge branch 'fitur-jualbarang' into dev 2026-05-15 19:19:44 +07:00
miko f118ca8489 mengubah deskripsi tulisan jualbarang 2026-05-15 18:51:20 +07:00
Grey ee74ab393c Merge branch 'fitur-home' into dev 2026-05-15 18:11:41 +07:00
Grey 0f66595775 Merge branch 'fitur-navbar' into dev 2026-05-15 18:09:00 +07:00
Grey d855f2e75a style:mengubah icon home view 2026-05-15 18:01:55 +07:00
Grey e359365139 feat:menambahkan fitur superadmin 2026-05-15 00:25:37 +07:00
24 changed files with 1573 additions and 130 deletions
+10
View File
@@ -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
);
+12
View File
@@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS users (
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
);
@@ -55,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
);
+8 -6
View File
@@ -15,17 +15,19 @@
"cors": "latest",
"dotenv": "latest",
"express": "latest",
"gsap": "^3.15.0",
"jsonwebtoken": "latest",
"lucide-vue-next": "latest",
"motion-v": "^1.10.3",
"multer": "latest",
"mysql2": "latest",
"vite": "latest",
"vue": "latest",
"vue-router": "latest",
"lucide-vue-next": "latest",
"multer": "latest",
"mysql2": "latest"
"vue-router": "latest"
},
"devDependencies": {
"tailwindcss": "^3.4.17",
"autoprefixer": "latest",
"postcss": "latest",
"autoprefixer": "latest"
"tailwindcss": "^3.4.17"
}
}
+98 -2
View File
@@ -23,12 +23,18 @@ importers:
express:
specifier: latest
version: 5.2.1
gsap:
specifier: ^3.15.0
version: 3.15.0
jsonwebtoken:
specifier: latest
version: 9.0.3
lucide-vue-next:
specifier: latest
version: 1.0.0(vue@3.5.34)
motion-v:
specifier: ^1.10.3
version: 1.10.3(@vueuse/core@14.3.0(vue@3.5.34))(vue@3.5.34)
multer:
specifier: latest
version: 2.1.1
@@ -235,6 +241,9 @@ packages:
'@types/node@25.7.0':
resolution: {integrity: sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==}
'@types/web-bluetooth@0.0.21':
resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==}
'@vitejs/plugin-vue@6.0.6':
resolution: {integrity: sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -289,6 +298,19 @@ packages:
'@vue/shared@3.5.34':
resolution: {integrity: sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==}
'@vueuse/core@14.3.0':
resolution: {integrity: sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==}
peerDependencies:
vue: ^3.5.0
'@vueuse/metadata@14.3.0':
resolution: {integrity: sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==}
'@vueuse/shared@14.3.0':
resolution: {integrity: sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==}
peerDependencies:
vue: ^3.5.0
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -554,6 +576,20 @@ packages:
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
framer-motion@12.39.0:
resolution: {integrity: sha512-+vnLfzrv0MzjLzNl+nvNvR7jdg3q4cxxjz/YvzfifHl0TREtL00cs1RoMTxs+1PzLiEqZGV6gYsBY0oEAYZ24w==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
@@ -589,6 +625,9 @@ packages:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
gsap@3.15.0:
resolution: {integrity: sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==}
has-symbols@1.1.0:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
@@ -597,6 +636,9 @@ packages:
resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
engines: {node: '>= 0.4'}
hey-listen@1.0.8:
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
@@ -833,6 +875,18 @@ packages:
mlly@1.8.2:
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
motion-dom@12.39.0:
resolution: {integrity: sha512-Xn7aAcGDhco/JZTXOub64UmaYn73C6J1Po7Fk+8EvkJsNGTqfhon6UJY53vJKXW5v5Zl8HrYsVxv6oPXeGoGLQ==}
motion-utils@12.39.0:
resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
motion-v@1.10.3:
resolution: {integrity: sha512-9Ewo/wwGv7FO3PqYJpllBF/Efc7tbeM1iinVrM73s0RUQrnXHwMZCaRX98u4lu0PQCrZghPPfCsQ14pWKIEbnQ==}
peerDependencies:
'@vueuse/core': '>=10.0.0'
vue: '>=3.0.0'
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1398,6 +1452,8 @@ snapshots:
dependencies:
undici-types: 7.21.0
'@types/web-bluetooth@0.0.21': {}
'@vitejs/plugin-vue@6.0.6(vite@8.0.12(@types/node@25.7.0)(jiti@1.21.7)(yaml@2.9.0))(vue@3.5.34)':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.13
@@ -1481,6 +1537,19 @@ snapshots:
'@vue/shared@3.5.34': {}
'@vueuse/core@14.3.0(vue@3.5.34)':
dependencies:
'@types/web-bluetooth': 0.0.21
'@vueuse/metadata': 14.3.0
'@vueuse/shared': 14.3.0(vue@3.5.34)
vue: 3.5.34
'@vueuse/metadata@14.3.0': {}
'@vueuse/shared@14.3.0(vue@3.5.34)':
dependencies:
vue: 3.5.34
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -1744,6 +1813,12 @@ snapshots:
fraction.js@5.3.4: {}
framer-motion@12.39.0:
dependencies:
motion-dom: 12.39.0
motion-utils: 12.39.0
tslib: 2.8.1
fresh@2.0.0: {}
fsevents@2.3.3:
@@ -1783,12 +1858,16 @@ snapshots:
gopd@1.2.0: {}
gsap@3.15.0: {}
has-symbols@1.1.0: {}
hasown@2.0.3:
dependencies:
function-bind: 1.1.2
hey-listen@1.0.8: {}
hookable@5.5.3: {}
http-errors@2.0.1:
@@ -1980,6 +2059,24 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.4
motion-dom@12.39.0:
dependencies:
motion-utils: 12.39.0
motion-utils@12.39.0: {}
motion-v@1.10.3(@vueuse/core@14.3.0(vue@3.5.34))(vue@3.5.34):
dependencies:
'@vueuse/core': 14.3.0(vue@3.5.34)
framer-motion: 12.39.0
hey-listen: 1.0.8
motion-dom: 12.39.0
vue: 3.5.34
transitivePeerDependencies:
- '@emotion/is-prop-valid'
- react
- react-dom
ms@2.1.3: {}
muggle-string@0.4.1: {}
@@ -2323,8 +2420,7 @@ snapshots:
ts-interface-checker@0.1.13: {}
tslib@2.8.1:
optional: true
tslib@2.8.1: {}
type-is@1.6.18:
dependencies:
+171 -21
View File
@@ -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" })
}
@@ -58,7 +75,8 @@ function publicUser(user) {
id: user.id,
name: user.name,
email: user.email,
whatsapp: user.whatsapp
whatsapp: user.whatsapp,
role: user.role || "user"
}
}
@@ -79,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) => {
@@ -96,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
@@ -116,7 +221,7 @@ app.post("/api/auth/register", async (req, res, next) => {
[name, email.toLowerCase(), whatsapp, passwordHash]
)
const user = { id: result.insertId, name, email: email.toLowerCase(), whatsapp }
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") {
@@ -152,7 +257,7 @@ app.post("/api/auth/login", async (req, res, next) => {
app.get("/api/auth/me", auth, async (req, res, next) => {
try {
const [rows] = await pool.query("SELECT id, name, email, whatsapp FROM users WHERE id = ?", [req.user.id])
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
@@ -168,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)
}
@@ -180,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)
}
@@ -199,27 +304,46 @@ app.get("/api/products/:id", async (req, res, next) => {
}
})
app.post("/api/products", auth, upload.single("image"), async (req, res, next) => {
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." })
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 imagePath = `/uploads/${req.file.filename}`
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, imagePath, description]
[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 {
@@ -250,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) {
@@ -261,9 +382,27 @@ app.delete("/api/products/:id", auth, async (req, res, next) => {
}
})
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 foto maksimal 3MB." })
res.status(400).json({ message: `Upload gagal. Ukuran setiap foto maksimal ${maxImageSizeMb} MB dan maksimal ${maxImageCount} foto.` })
return
}
@@ -273,9 +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, () => {
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)
})

Before

Width:  |  Height:  |  Size: 826 KiB

After

Width:  |  Height:  |  Size: 826 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 KiB

+458
View File
@@ -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;
}
+78 -4
View File
@@ -1,8 +1,25 @@
<template>
<aside class="card p-4">
<div class="mb-6">
<p class="text-xs font-bold uppercase tracking-wider text-slate-500">Menu User</p>
<p class="text-xs font-bold uppercase tracking-wider text-slate-500">
Menu User
</p>
<h2 class="mt-1 text-lg font-extrabold">Dashboard</h2>
<div v-if="currentUser" class="mt-4 rounded-xl bg-slate-50 p-3">
<p class="text-sm font-bold text-slate-800">
{{ currentUser.name }}
</p>
<p class="mt-1 text-xs text-slate-500">
{{ currentUser.email }}
</p>
<p
v-if="isSuperadmin"
class="mt-2 inline-flex rounded-full bg-red-50 px-3 py-1 text-xs font-bold text-red-700"
>
Superadmin
</p>
</div>
</div>
<nav class="grid gap-2">
@@ -10,18 +27,31 @@
<LayoutDashboard size="18" />
Barang Saya
</RouterLink>
<RouterLink class="side-link" to="/jual">
<PlusCircle size="18" />
Jual Barang
</RouterLink>
<RouterLink class="side-link" to="/marketplace">
<Store size="18" />
Marketplace
</RouterLink>
<RouterLink class="side-link" to="/tersimpan">
<Heart size="18" />
Barang Tersimpan
</RouterLink>
<RouterLink
v-if="isSuperadmin"
class="side-link admin-link"
to="/admin/products"
>
<ShieldCheck size="18" />
Admin Marketplace
</RouterLink>
<button class="side-link text-left" type="button" @click="logout">
<LogOut size="18" />
Logout
@@ -31,22 +61,66 @@
</template>
<script setup>
import { useRouter } from "vue-router"
import { Heart, LayoutDashboard, LogOut, PlusCircle, Store } from "lucide-vue-next"
import { clearAuth } from "../utils"
import { computed, onMounted, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import {
Heart,
LayoutDashboard,
LogOut,
PlusCircle,
ShieldCheck,
Store
} from "lucide-vue-next"
import { clearAuth, fetchCurrentUser, getCurrentUser, getToken, setAuth } from "../utils"
const router = useRouter()
const route = useRoute()
const currentUser = ref(getCurrentUser())
const isSuperadmin = computed(() => {
return currentUser.value?.role === "superadmin"
})
watch(
() => route.fullPath,
() => {
currentUser.value = getCurrentUser()
}
)
async function refreshCurrentUser() {
if (!getToken()) return
try {
const data = await fetchCurrentUser()
setAuth({
token: getToken(),
user: data.user
})
currentUser.value = getCurrentUser()
} catch {
clearAuth()
currentUser.value = null
}
}
function logout() {
clearAuth()
router.push("/login")
}
onMounted(refreshCurrentUser)
</script>
<style scoped>
.side-link {
@apply flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm font-semibold text-slate-700 transition hover:bg-slate-100;
}
.admin-link {
@apply bg-red-50 text-red-700 hover:bg-red-100;
}
.router-link-active {
@apply bg-brand-50 text-brand-700;
}
+67 -16
View File
@@ -2,12 +2,8 @@
<header class="sticky top-0 z-50 border-b border-slate-200 bg-white/90 backdrop-blur">
<div class="container-page flex h-16 items-center justify-between">
<RouterLink to="/" class="flex items-center gap-2">
<div class="flex items-center justify-center">
<img
src="/round-logo-sija.png"
alt="SecondTech Logo"
class="h-10 w-10 object-contain"
/>
<div class="flex h-9 w-9 items-center justify-center rounded-xl bg-brand-600 text-white">
<Cpu size="20" />
</div>
<span class="text-lg font-extrabold tracking-tight">SecondTech</span>
</RouterLink>
@@ -18,13 +14,21 @@
<RouterLink class="nav-link" to="/jual">Jual Barang</RouterLink>
<RouterLink class="nav-link" to="/dashboard">Dashboard</RouterLink>
<RouterLink class="nav-link" to="/tersimpan">Tersimpan</RouterLink>
<RouterLink v-if="isSuperadmin" class="nav-link" to="/admin/products">Admin Marketplace</RouterLink>
</nav>
<div class="hidden items-center gap-3 md:flex">
<button type="button" class="btn-secondary !px-3 !py-2" @click="toggleTheme">
<Sun v-if="darkMode" size="18" />
<Moon v-else size="18" />
</button>
<template v-if="currentUser">
<span class="text-sm font-bold text-slate-700">{{ currentUser.name }}</span>
<span v-if="isSuperadmin" class="rounded-full bg-red-50 px-3 py-1 text-xs font-bold text-red-700">Superadmin</span>
<button class="btn-secondary !px-4 !py-2" @click="logout">Logout</button>
</template>
<template v-else>
<RouterLink to="/login" class="btn-secondary !px-4 !py-2">Login</RouterLink>
<RouterLink to="/register" class="btn-primary !px-4 !py-2">Register</RouterLink>
@@ -38,17 +42,31 @@
<div v-if="open" class="border-t border-slate-200 bg-white md:hidden">
<div class="container-page grid gap-2 py-4">
<RouterLink class="mobile-link" to="/" @click="open=false">Home</RouterLink>
<RouterLink class="mobile-link" to="/marketplace" @click="open=false">Marketplace</RouterLink>
<RouterLink class="mobile-link" to="/jual" @click="open=false">Jual Barang</RouterLink>
<RouterLink class="mobile-link" to="/dashboard" @click="open=false">Dashboard</RouterLink>
<RouterLink class="mobile-link" to="/tersimpan" @click="open=false">Tersimpan</RouterLink>
<RouterLink class="mobile-link" to="/" @click="open = false">Home</RouterLink>
<RouterLink class="mobile-link" to="/marketplace" @click="open = false">Marketplace</RouterLink>
<RouterLink class="mobile-link" to="/jual" @click="open = false">Jual Barang</RouterLink>
<RouterLink class="mobile-link" to="/dashboard" @click="open = false">Dashboard</RouterLink>
<RouterLink class="mobile-link" to="/tersimpan" @click="open = false">Tersimpan</RouterLink>
<RouterLink v-if="isSuperadmin" class="mobile-link" to="/admin/products" @click="open = false">Admin Marketplace</RouterLink>
<button type="button" class="btn-secondary mt-2 !py-2 text-center" @click="toggleTheme">
<Sun v-if="darkMode" size="18" />
<Moon v-else size="18" />
{{ darkMode ? "Light Mode" : "Dark Mode" }}
</button>
<div v-if="currentUser" class="mt-2 grid gap-2">
<div class="rounded-xl bg-slate-50 px-3 py-2">
<p class="text-sm font-bold text-slate-800">{{ currentUser.name }}</p>
<p class="text-xs text-slate-500">{{ currentUser.email }}</p>
<p v-if="isSuperadmin" class="mt-1 text-xs font-bold text-red-700">Superadmin</p>
</div>
<button class="btn-secondary !py-2 text-center" @click="logout">Logout</button>
</div>
<div v-else class="mt-2 grid grid-cols-2 gap-2">
<RouterLink to="/login" class="btn-secondary !py-2 text-center" @click="open=false">Login</RouterLink>
<RouterLink to="/register" class="btn-primary !py-2 text-center" @click="open=false">Register</RouterLink>
<RouterLink to="/login" class="btn-secondary !py-2 text-center" @click="open = false">Login</RouterLink>
<RouterLink to="/register" class="btn-primary !py-2 text-center" @click="open = false">Register</RouterLink>
</div>
</div>
</div>
@@ -56,15 +74,18 @@
</template>
<script setup>
import { ref, watch } from "vue"
import { computed, onMounted, ref, watch } from "vue"
import { useRoute, useRouter } from "vue-router"
import { Cpu, Menu } from "lucide-vue-next"
import { clearAuth, getCurrentUser } from "../utils"
import { Cpu, Menu, Moon, Sun } from "lucide-vue-next"
import { clearAuth, fetchCurrentUser, getCurrentUser, getToken, setAuth } from "../utils"
const open = ref(false)
const route = useRoute()
const router = useRouter()
const currentUser = ref(getCurrentUser())
const darkMode = ref(localStorage.getItem("secondtech_theme") === "dark")
const isSuperadmin = computed(() => currentUser.value?.role === "superadmin")
watch(
() => route.fullPath,
@@ -73,21 +94,51 @@ watch(
}
)
function applyTheme() {
document.documentElement.classList.toggle("dark", darkMode.value)
localStorage.setItem("secondtech_theme", darkMode.value ? "dark" : "light")
}
function toggleTheme() {
darkMode.value = !darkMode.value
applyTheme()
}
async function refreshCurrentUser() {
if (!getToken()) return
try {
const data = await fetchCurrentUser()
setAuth({ token: getToken(), user: data.user })
currentUser.value = getCurrentUser()
} catch {
clearAuth()
currentUser.value = null
}
}
function logout() {
clearAuth()
currentUser.value = null
open.value = false
router.push("/login")
}
onMounted(() => {
applyTheme()
refreshCurrentUser()
})
</script>
<style scoped>
.nav-link {
@apply text-sm font-semibold text-slate-600 transition hover:text-brand-700;
}
.router-link-active {
@apply text-brand-700;
}
.mobile-link {
@apply rounded-xl px-3 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-100;
}
+12
View File
@@ -108,3 +108,15 @@ export const categories = [
"Server",
"Access Point"
]
export const categoryOptions = [
{ 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" }
]
+4
View File
@@ -3,4 +3,8 @@ import App from "./App.vue"
import router from "./router"
import "./assets/main.css"
if (localStorage.getItem("secondtech_theme") === "dark") {
document.documentElement.classList.add("dark")
}
createApp(App).use(router).mount("#app")
+14 -1
View File
@@ -8,7 +8,8 @@ import DashboardView from "../views/DashboardView.vue"
import SavedView from "../views/SavedView.vue"
import LoginView from "../views/LoginView.vue"
import RegisterView from "../views/RegisterView.vue"
import { getToken } from "../utils"
import AdminProductsView from "../views/AdminProductsView.vue"
import { getCurrentUser, getToken } from "../utils"
const routes = [
{ path: "/", name: "home", component: HomeView },
@@ -17,6 +18,12 @@ const routes = [
{ path: "/jual", name: "sell-product", component: SellProductView, meta: { requiresAuth: true } },
{ path: "/dashboard", name: "dashboard", component: DashboardView, meta: { requiresAuth: true } },
{ path: "/tersimpan", name: "saved", component: SavedView, meta: { requiresAuth: true } },
{
path: "/admin/products",
name: "admin-products",
component: AdminProductsView,
meta: { requiresAuth: true, requiresSuperadmin: true }
},
{ path: "/login", name: "login", component: LoginView },
{ path: "/register", name: "register", component: RegisterView }
]
@@ -30,6 +37,12 @@ router.beforeEach((to) => {
if (to.meta.requiresAuth && !getToken()) {
return "/login"
}
const user = getCurrentUser()
if (to.meta.requiresSuperadmin && user?.role !== "superadmin") {
return "/dashboard"
}
})
export default router
+18 -1
View File
@@ -31,7 +31,14 @@ export function getCurrentUser() {
export function setAuth({ token, user }) {
localStorage.setItem("secondtech_token", token)
setLocal("secondtech_user", user)
setLocal("secondtech_user", {
id: user.id,
name: user.name,
email: user.email,
whatsapp: user.whatsapp,
role: user.role || "user"
})
}
export function clearAuth() {
@@ -79,6 +86,10 @@ export function loginUser(payload) {
})
}
export function fetchCurrentUser() {
return requestJson("/api/auth/me")
}
export function fetchProducts() {
return requestJson("/api/products")
}
@@ -111,6 +122,12 @@ export function deleteProductById(id) {
})
}
export function deleteProductAsAdmin(id) {
return requestJson(`/api/admin/products/${id}`, {
method: "DELETE"
})
}
export function getSavedProducts() {
return getLocal("secondtech_saved", [])
}
+113
View File
@@ -0,0 +1,113 @@
<template>
<section class="container-page py-10">
<div class="mb-8">
<h1 class="text-3xl font-extrabold">Admin Marketplace</h1>
<p class="mt-2 text-slate-600">Kelola semua produk yang tampil di marketplace.</p>
</div>
<p v-if="errorMessage" class="mb-5 rounded-xl bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
{{ errorMessage }}
</p>
<div v-if="loading" class="card p-10 text-center">
<h2 class="text-xl font-extrabold">Memuat produk...</h2>
</div>
<div v-else-if="products.length" class="grid gap-4">
<div
v-for="product in products"
:key="product.id"
class="card grid gap-4 p-4 md:grid-cols-[140px_1fr_auto] md:items-center"
>
<img
:src="getProductImageUrl(product.image)"
:alt="product.title"
class="aspect-[4/3] w-full rounded-xl object-cover md:w-36"
/>
<div>
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full bg-brand-50 px-3 py-1 text-xs font-bold text-brand-700">
{{ product.category }}
</span>
<span class="rounded-full bg-emerald-50 px-3 py-1 text-xs font-bold text-emerald-700">
{{ product.status }}
</span>
</div>
<h3 class="mt-3 font-extrabold">{{ product.title }}</h3>
<p class="mt-1 text-sm text-slate-500">
{{ product.seller }} {{ product.location }} {{ product.condition }}
</p>
<p class="mt-2 font-extrabold text-brand-700">
{{ formatRupiah(product.price) }}
</p>
</div>
<div class="flex flex-wrap gap-2 md:flex-col">
<RouterLink :to="`/produk/${product.id}`" class="btn-secondary !px-3 !py-2">
Detail
</RouterLink>
<button
class="rounded-xl bg-red-50 px-3 py-2 text-sm font-bold text-red-700 hover:bg-red-100"
@click="deleteAsAdmin(product.id)"
>
Hapus
</button>
</div>
</div>
</div>
<div v-else class="card p-10 text-center">
<h2 class="text-xl font-extrabold">Belum ada produk</h2>
</div>
</section>
</template>
<script setup>
import { onMounted, ref } from "vue"
import { API_BASE_URL, fetchProducts, formatRupiah, getProductImageUrl, getToken } from "../utils"
const products = ref([])
const loading = ref(true)
const errorMessage = ref("")
async function loadProducts() {
loading.value = true
errorMessage.value = ""
try {
const data = await fetchProducts()
products.value = data.products
} catch (error) {
errorMessage.value = error.message
} finally {
loading.value = false
}
}
async function deleteAsAdmin(id) {
if (!confirm("Yakin ingin menghapus produk ini dari marketplace?")) return
try {
const response = await fetch(`${API_BASE_URL}/api/admin/products/${id}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${getToken()}`
}
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(data.message || "Gagal menghapus produk.")
}
products.value = products.value.filter((product) => product.id !== id)
} catch (error) {
errorMessage.value = error.message
}
}
onMounted(loadProducts)
</script>
+59 -35
View File
@@ -2,15 +2,31 @@
<section class="bg-gradient-to-br from-brand-50 via-white to-slate-100">
<div class="container-page grid min-h-[560px] items-center gap-10 py-16 lg:grid-cols-2">
<div>
<span class="rounded-full bg-brand-100 px-4 py-2 text-sm font-bold text-brand-700">
Marketplace Barang Bekas Teknologi
</span>
<h1 class="mt-6 text-4xl font-extrabold tracking-tight text-slate-950 sm:text-5xl lg:text-6xl">
Jual beli perangkat Teknologi bekas.
</h1>
<p class="mt-6 max-w-xl text-lg leading-8 text-slate-600">
Temukan laptop bekas, PC, monitor, keyboard, router, switch, access point, dan server bekas untuk berbagai kebutuhan.
</p>
<ShinyText
text="SecondTech"
className="text-1xl font-extrabold tracking-tight sm:text-1xl lg:text-2xl"
:color="'#1c4ed9'"
:shineColor="'#e0e7ff'"
:spread="45"
:speed="1"
:delay="2"
:direction="'left'"
:pauseOnHover="true"
:yoyo="true"
/>
<div>
<TextType
as="h2"
className="mt-6 text-3xl font-extrabold tracking-tight text-zinc-950 sm:text-6xl lg:text-5xl"
:text="['Menjual Barang Bekas, yang kamu butuhkan, di satu tempat.']"
:typingSpeed="75"
:pauseDuration="1500"
:showCursor="false"
:textColors="['var(--hero-title-color)']"
cursorCharacter="|"
/>
</div>
<p class="mt-6 max-w-xl text-lg leading-8 text-slate-600">Temukan laptop bekas, PC, monitor, keyboard, router, switch, access point, dan server bekas untuk berbagai kebutuhan.</p>
<div class="mt-8 flex flex-wrap gap-3">
<RouterLink to="/jual" class="btn-primary">Jual Barang Sekarang</RouterLink>
<RouterLink to="/marketplace" class="btn-secondary">Lihat Marketplace</RouterLink>
@@ -19,11 +35,7 @@
<div class="relative">
<div class="absolute -inset-4 rounded-[2rem] bg-brand-100 blur-2xl"></div>
<img
class="relative aspect-[4/3] w-full rounded-[2rem] object-cover shadow-2xl"
src="https://images.unsplash.com/photo-1516321318423-f06f85e504b3?q=80&w=1200&auto=format&fit=crop"
alt="Tech marketplace"
/>
<img class="relative aspect-[4/3] w-full rounded-[2rem] object-cover shadow-2xl" src="https://images.unsplash.com/photo-1516321318423-f06f85e504b3?q=80&w=1200&auto=format&fit=crop" alt="Tech marketplace" />
</div>
</div>
</section>
@@ -38,11 +50,12 @@
</div>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
<div v-for="cat in categoryList" :key="cat" class="card p-5 text-center transition hover:-translate-y-1 hover:shadow-md">
<div v-for="cat in categoryList" :key="cat.name" class="card p-5 text-center transition hover:-translate-y-1 hover:shadow-md">
<div class="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-50 text-brand-700">
<Cpu size="22" />
<component :is="iconMap[cat.icon]" :size="22" />
</div>
<p class="font-bold">{{ cat }}</p>
<p class="font-bold">{{ cat.name }}</p>
</div>
</div>
</section>
@@ -57,35 +70,46 @@
</div>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<ProductCard
v-for="product in latestProducts"
:key="product.id"
:product="product"
:saved="saved.includes(product.id)"
@toggle-save="toggleSave"
/>
<ProductCard v-for="product in latestProducts" :key="product.id" :product="product" :saved="saved.includes(product.id)" @toggle-save="toggleSave" />
</div>
</div>
</section>
</template>
<script setup>
import { computed, ref } from "vue"
import { Cpu } from "lucide-vue-next"
import ProductCard from "../components/ProductCard.vue"
import { products, categories } from "../data/products"
import { getSavedProducts, setSavedProducts } from "../utils"
import { computed, ref } from 'vue';
import ProductCard from '../components/ProductCard.vue';
import { getSavedProducts, setSavedProducts } from '../utils';
import { Boxes, Laptop, PcCase, Monitor, Keyboard, Router, Network, Server, Wifi } from 'lucide-vue-next';
const saved = ref(getSavedProducts())
const categoryList = categories.filter((c) => c !== "Semua").slice(0, 6)
const latestProducts = computed(() => products.slice(0, 4))
import { products, categoryOptions } from '../data/products';
import ShinyText from '../vuebits/ShinyText/ShinyText.vue';
import TextType from '../vuebits/TextType/TextType.vue';
const saved = ref(getSavedProducts());
const categoryList = categoryOptions
.filter((category) => category.name !== 'Semua')
.slice(0, 6);
const latestProducts = computed(() => products.slice(0, 4));
const iconMap = {
Boxes,
Laptop,
PcCase,
Monitor,
Keyboard,
Router,
Network,
Server,
Wifi,
};
function toggleSave(id) {
if (saved.value.includes(id)) {
saved.value = saved.value.filter((item) => item !== id)
saved.value = saved.value.filter((item) => item !== id);
} else {
saved.value.push(id)
saved.value.push(id);
}
setSavedProducts(saved.value)
setSavedProducts(saved.value);
}
</script>
-4
View File
@@ -17,10 +17,6 @@
<input v-model="password" type="password" class="input mt-2" placeholder="••••••••" required />
</div>
<button class="btn-primary w-full" :disabled="loading">
<svg v-if="loading" class="mr-3 h-5 w-5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ loading ? "Memproses..." : "Masuk" }}
</button>
</form>
+39 -6
View File
@@ -1,7 +1,7 @@
<template>
<section class="container-page py-10">
<RouterLink to="/marketplace" class="mb-6 inline-flex text-sm font-bold text-brand-700">
Kembali ke Marketplace
<- Kembali ke Marketplace
</RouterLink>
<div v-if="loading" class="card p-10 text-center">
@@ -9,8 +9,23 @@
</div>
<div v-else-if="product" class="grid gap-8 lg:grid-cols-2">
<div class="card overflow-hidden">
<img :src="getProductImageUrl(product.image)" :alt="product.title" class="aspect-[4/3] w-full object-cover" />
<div class="grid gap-4 sm:grid-cols-[88px_1fr]">
<div class="order-2 flex gap-3 overflow-x-auto sm:order-1 sm:grid sm:max-h-[520px] sm:overflow-y-auto">
<button
v-for="(image, index) in productImages"
:key="`${image}-${index}`"
type="button"
class="h-20 w-20 shrink-0 overflow-hidden rounded-xl border-2 bg-white"
:class="selectedImage === image ? 'border-brand-600' : 'border-slate-200'"
@click="selectedImage = image"
>
<img :src="getProductImageUrl(image)" :alt="`${product.title} ${index + 1}`" class="h-full w-full object-cover" />
</button>
</div>
<div class="card order-1 overflow-hidden sm:order-2">
<img :src="getProductImageUrl(selectedImage)" :alt="product.title" class="aspect-[4/3] w-full object-cover" />
</div>
</div>
<div>
@@ -45,7 +60,7 @@
</div>
<div class="mt-8 flex flex-wrap gap-3">
<a :href="waLink" target="_blank" class="btn-WA">Hubungi via WhatsApp</a>
<a :href="waLink" target="_blank" class="btn-primary">Hubungi via WhatsApp</a>
<button class="btn-secondary" @click="toggleSave">
{{ saved ? "Hapus dari Tersimpan" : "Simpan Barang" }}
</button>
@@ -61,7 +76,7 @@
</template>
<script setup>
import { computed, onMounted, ref } from "vue"
import { computed, onMounted, ref, watch } from "vue"
import { useRoute } from "vue-router"
import { products as defaultProducts } from "../data/products"
import { fetchProduct, formatRupiah, getProductImageUrl, getSavedProducts, setSavedProducts } from "../utils"
@@ -70,6 +85,23 @@ const route = useRoute()
const id = Number(route.params.id)
const product = ref(null)
const loading = ref(true)
const selectedImage = ref("")
const productImages = computed(() => {
if (!product.value) return []
const images = product.value.images?.length ? product.value.images : [product.value.image]
return [...new Set(images.filter(Boolean))]
})
watch(
productImages,
(images) => {
if (!images.includes(selectedImage.value)) {
selectedImage.value = images[0] || ""
}
},
{ immediate: true }
)
const savedIds = ref(getSavedProducts())
const saved = computed(() => savedIds.value.includes(id))
@@ -95,7 +127,8 @@ async function loadProduct() {
const data = await fetchProduct(id)
product.value = data.product
} catch {
product.value = defaultProducts.find((item) => Number(item.id) === id) || null
const fallbackProduct = defaultProducts.find((item) => Number(item.id) === id) || null
product.value = fallbackProduct ? { ...fallbackProduct, images: [fallbackProduct.image] } : null
} finally {
loading.value = false
}
-4
View File
@@ -25,10 +25,6 @@
<input v-model="form.password" type="password" class="input mt-2" placeholder="••••••••" required />
</div>
<button class="btn-primary w-full" :disabled="loading">
<svg v-if="loading" class="mr-3 h-5 w-5 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{{ loading ? "Memproses..." : "Daftar" }}
</button>
</form>
+86 -17
View File
@@ -2,7 +2,7 @@
<section class="container-page py-10">
<div class="mb-8">
<h1 class="text-3xl font-extrabold">Jual Barang</h1>
<p class="mt-2 text-slate-600">Posting barang ke marketplace dengan foto asli dari perangkatmu.</p>
<p class="mt-2 text-slate-600">Posting barang ke marketplace dengan beberapa foto asli dari perangkatmu.</p>
</div>
<div class="grid gap-8 lg:grid-cols-[1fr_360px]">
@@ -19,7 +19,7 @@
<div class="grid gap-5 sm:grid-cols-2">
<div>
<label class="label">Kategori</label>
<select v-model="form.category" class="input mt-2">
<select v-model="form.category" class="input mt-2" required>
<option v-for="cat in realCategories" :key="cat" :value="cat">{{ cat }}</option>
</select>
</div>
@@ -58,9 +58,32 @@
<div>
<label class="label">Foto Barang</label>
<input type="file" accept="image/*" class="input mt-2" required @change="handleImageChange" />
<img v-if="previewUrl" :src="previewUrl" alt="Preview foto barang" class="mt-4 aspect-[4/3] w-full max-w-sm rounded-xl object-cover" />
<p class="mt-2 text-xs text-slate-500">Upload JPG, PNG, atau WebP. Maksimal 3MB.</p>
<input
ref="fileInput"
type="file"
accept="image/*"
multiple
class="input mt-2"
@change="handleImageChange"
/>
<div v-if="selectedImages.length" class="mt-3 flex items-center justify-between gap-3 rounded-xl bg-brand-50 px-4 py-3 text-sm font-bold text-brand-700">
<span>{{ selectedImages.length }} foto dipilih</span>
<button type="button" class="text-red-700" @click="clearImages">Hapus semua</button>
</div>
<div v-if="previewUrls.length" class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
<div v-for="(preview, index) in previewUrls" :key="preview.url" class="overflow-hidden rounded-xl border border-slate-200">
<img :src="preview.url" :alt="`Preview foto barang ${index + 1}`" class="aspect-[4/3] w-full object-cover" />
<button type="button" class="w-full bg-red-50 px-3 py-2 text-xs font-bold text-red-700" @click="removeImage(index)">
Hapus foto
</button>
</div>
</div>
<p class="mt-2 text-xs text-slate-500">
Maksimal 6 foto, 10 MB per foto. Kamu boleh pilih beberapa sekaligus atau tambah satu per satu.
</p>
</div>
<div>
@@ -76,10 +99,10 @@
<div class="card h-fit p-6">
<h2 class="text-lg font-extrabold">Tips Isi Produk</h2>
<ul class="mt-4 grid gap-3 text-sm leading-6 text-slate-600">
<li> Pakai judul yang jelas, misal MikroTik RB941 Bekas Normal.</li>
<li> Jelaskan minus barang kalau ada.</li>
<li> Pakai nomor WhatsApp aktif.</li>
<li> Pakai foto yang terang dan jelas.</li>
<li>- Pakai judul yang jelas, misal "MikroTik RB941 Bekas Normal".</li>
<li>- Jelaskan minus barang kalau ada.</li>
<li>- Pakai nomor WhatsApp aktif.</li>
<li>- Upload beberapa foto dari sisi yang berbeda.</li>
</ul>
</div>
</div>
@@ -94,6 +117,10 @@ import { createProduct, getCurrentUser } from "../utils"
const router = useRouter()
const realCategories = computed(() => categories.filter((cat) => cat !== "Semua"))
const fileInput = ref(null)
const MAX_IMAGES = 6
const MAX_IMAGE_SIZE_MB = 10
const MAX_IMAGE_SIZE = MAX_IMAGE_SIZE_MB * 1024 * 1024
const form = reactive({
title: "",
@@ -105,8 +132,8 @@ const form = reactive({
whatsapp: "",
description: ""
})
const selectedImage = ref(null)
const previewUrl = ref("")
const selectedImages = ref([])
const previewUrls = ref([])
const loading = ref(false)
const errorMessage = ref("")
@@ -116,15 +143,55 @@ if (currentUser) {
form.whatsapp = currentUser.whatsapp || ""
}
function imageKey(file) {
return `${file.name}-${file.size}-${file.lastModified}`
}
function rebuildPreviews() {
previewUrls.value.forEach((preview) => URL.revokeObjectURL(preview.url))
previewUrls.value = selectedImages.value.map((file) => ({
key: imageKey(file),
url: URL.createObjectURL(file)
}))
}
function handleImageChange(event) {
const file = event.target.files?.[0]
selectedImage.value = file || null
previewUrl.value = file ? URL.createObjectURL(file) : ""
const incomingFiles = Array.from(event.target.files || [])
const oversizedFile = incomingFiles.find((file) => file.size > MAX_IMAGE_SIZE)
if (oversizedFile) {
errorMessage.value = `Foto "${oversizedFile.name}" lebih dari ${MAX_IMAGE_SIZE_MB} MB. Pilih foto yang lebih kecil.`
event.target.value = ""
return
}
const merged = [...selectedImages.value]
incomingFiles.forEach((file) => {
if (!merged.some((item) => imageKey(item) === imageKey(file)) && merged.length < MAX_IMAGES) {
merged.push(file)
}
})
selectedImages.value = merged
rebuildPreviews()
errorMessage.value = ""
event.target.value = ""
}
function removeImage(index) {
selectedImages.value = selectedImages.value.filter((_, itemIndex) => itemIndex !== index)
rebuildPreviews()
}
function clearImages() {
selectedImages.value = []
rebuildPreviews()
if (fileInput.value) fileInput.value.value = ""
}
async function submitProduct() {
if (!selectedImage.value) {
errorMessage.value = "Foto barang wajib diupload."
if (!selectedImages.value.length) {
errorMessage.value = "Minimal 1 foto barang wajib diupload."
return
}
@@ -135,7 +202,9 @@ async function submitProduct() {
Object.entries(form).forEach(([key, value]) => {
payload.append(key, value)
})
payload.append("image", selectedImage.value)
selectedImages.value.forEach((image) => {
payload.append("images", image)
})
try {
await createProduct(payload)
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { Motion, useAnimationFrame, useMotionValue, useTransform } from 'motion-v';
import { computed, ref, watch } from 'vue';
interface ShinyTextProps {
text: string;
disabled?: boolean;
speed?: number;
className?: string;
color?: string;
shineColor?: string;
spread?: number;
yoyo?: boolean;
pauseOnHover?: boolean;
direction?: 'left' | 'right';
delay?: number;
}
const props = withDefaults(defineProps<ShinyTextProps>(), {
disabled: false,
speed: 2,
className: '',
color: '#b5b5b5',
shineColor: '#ffffff',
spread: 120,
yoyo: false,
pauseOnHover: false,
direction: 'left',
delay: 0
});
const isPaused = ref(false);
const progress = useMotionValue(0);
const elapsedRef = ref(0);
const lastTimeRef = ref<number | null>(null);
const directionRef = ref(props.direction === 'left' ? 1 : -1);
const animationDuration = computed(() => props.speed * 1000);
const delayDuration = computed(() => props.delay * 1000);
useAnimationFrame(time => {
if (props.disabled || isPaused.value) {
lastTimeRef.value = null;
return;
}
if (lastTimeRef.value === null) {
lastTimeRef.value = time;
return;
}
const deltaTime = time - lastTimeRef.value;
lastTimeRef.value = time;
elapsedRef.value += deltaTime;
// Animation goes from 0 to 100
if (props.yoyo) {
const cycleDuration = animationDuration.value + delayDuration.value;
const fullCycle = cycleDuration * 2;
const cycleTime = elapsedRef.value % fullCycle;
if (cycleTime < animationDuration.value) {
// Forward animation: 0 -> 100
const p = (cycleTime / animationDuration.value) * 100;
progress.set(directionRef.value === 1 ? p : 100 - p);
} else if (cycleTime < cycleDuration) {
// Delay at end
progress.set(directionRef.value === 1 ? 100 : 0);
} else if (cycleTime < cycleDuration + animationDuration.value) {
// Reverse animation: 100 -> 0
const reverseTime = cycleTime - cycleDuration;
const p = 100 - (reverseTime / animationDuration.value) * 100;
progress.set(directionRef.value === 1 ? p : 100 - p);
} else {
// Delay at start
progress.set(directionRef.value === 1 ? 0 : 100);
}
} else {
const cycleDuration = animationDuration.value + delayDuration.value;
const cycleTime = elapsedRef.value % cycleDuration;
if (cycleTime < animationDuration.value) {
// Animation phase: 0 -> 100
const p = (cycleTime / animationDuration.value) * 100;
progress.set(directionRef.value === 1 ? p : 100 - p);
} else {
// Delay phase - hold at end (shine off-screen)
progress.set(directionRef.value === 1 ? 100 : 0);
}
}
});
watch(
() => props.direction,
() => {
directionRef.value = props.direction === 'left' ? 1 : -1;
elapsedRef.value = 0;
progress.set(0);
},
{
immediate: true
}
);
const backgroundPosition = useTransform(progress, p => `${150 - p * 2}% center`);
const handleMouseEnter = () => {
if (props.pauseOnHover) isPaused.value = true;
};
const handleMouseLeave = () => {
if (props.pauseOnHover) isPaused.value = false;
};
const gradientStyle = computed(() => ({
backgroundImage: `linear-gradient(${props.spread}deg, ${props.color} 0%, ${props.color} 35%, ${props.shineColor} 50%, ${props.color} 65%, ${props.color} 100%)`,
backgroundSize: '200% auto',
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}));
</script>
<template>
<Motion
tag="span"
:class="['inline-block', className]"
:style="{ ...gradientStyle, backgroundPosition }"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
{{ text }}
</Motion>
</template>
+176
View File
@@ -0,0 +1,176 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch, computed, useTemplateRef } from 'vue';
import { gsap } from 'gsap';
interface TextTypeProps {
className?: string;
showCursor?: boolean;
hideCursorWhileTyping?: boolean;
cursorCharacter?: string;
cursorBlinkDuration?: number;
cursorClassName?: string;
text: string | string[];
as?: string;
typingSpeed?: number;
initialDelay?: number;
pauseDuration?: number;
deletingSpeed?: number;
loop?: boolean;
textColors?: string[];
variableSpeed?: { min: number; max: number };
onSentenceComplete?: (sentence: string, index: number) => void;
startOnVisible?: boolean;
reverseMode?: boolean;
}
const props = withDefaults(defineProps<TextTypeProps>(), {
as: 'div',
typingSpeed: 50,
initialDelay: 0,
pauseDuration: 2000,
deletingSpeed: 30,
loop: true,
className: '',
showCursor: true,
hideCursorWhileTyping: false,
cursorCharacter: '|',
cursorBlinkDuration: 0.5,
textColors: () => [],
startOnVisible: false,
reverseMode: false
});
const displayedText = ref('');
const currentCharIndex = ref(0);
const isDeleting = ref(false);
const currentTextIndex = ref(0);
const isVisible = ref(!props.startOnVisible);
const cursorRef = useTemplateRef('cursorRef');
const containerRef = useTemplateRef('containerRef');
const textArray = computed(() => (Array.isArray(props.text) ? props.text : [props.text]));
const getRandomSpeed = () => {
if (!props.variableSpeed) return props.typingSpeed;
const { min, max } = props.variableSpeed;
return Math.random() * (max - min) + min;
};
const getCurrentTextColor = () => {
if (!props.textColors.length) return '#ffffff';
return props.textColors[currentTextIndex.value % props.textColors.length];
};
let timeout: ReturnType<typeof setTimeout> | null = null;
const clearTimeoutIfNeeded = () => {
if (timeout) clearTimeout(timeout);
};
const executeTypingAnimation = () => {
const currentText = textArray.value[currentTextIndex.value];
const processedText = props.reverseMode ? currentText.split('').reverse().join('') : currentText;
if (isDeleting.value) {
if (displayedText.value === '') {
isDeleting.value = false;
if (currentTextIndex.value === textArray.value.length - 1 && !props.loop) return;
props.onSentenceComplete?.(textArray.value[currentTextIndex.value], currentTextIndex.value);
currentTextIndex.value = (currentTextIndex.value + 1) % textArray.value.length;
currentCharIndex.value = 0;
timeout = setTimeout(() => {}, props.pauseDuration);
} else {
timeout = setTimeout(() => {
displayedText.value = displayedText.value.slice(0, -1);
}, props.deletingSpeed);
}
} else {
if (currentCharIndex.value < processedText.length) {
timeout = setTimeout(
() => {
displayedText.value += processedText[currentCharIndex.value];
currentCharIndex.value += 1;
},
props.variableSpeed ? getRandomSpeed() : props.typingSpeed
);
} else if (textArray.value.length > 1) {
timeout = setTimeout(() => {
isDeleting.value = true;
}, props.pauseDuration);
}
}
};
watch(
[displayedText, currentCharIndex, isDeleting, isVisible],
() => {
if (!isVisible.value) return;
clearTimeoutIfNeeded();
if (currentCharIndex.value === 0 && !isDeleting.value && displayedText.value === '') {
timeout = setTimeout(() => {
executeTypingAnimation();
}, props.initialDelay);
} else {
executeTypingAnimation();
}
},
{ immediate: true }
);
onMounted(() => {
if (props.showCursor && cursorRef.value) {
gsap.set(cursorRef.value, { opacity: 1 });
gsap.to(cursorRef.value, {
opacity: 0,
duration: props.cursorBlinkDuration,
repeat: -1,
yoyo: true,
ease: 'power2.inOut'
});
}
if (props.startOnVisible && containerRef.value) {
const observer = new IntersectionObserver(
entries => {
entries.forEach(entry => {
if (entry.isIntersecting) isVisible.value = true;
});
},
{ threshold: 0.1 }
);
if (containerRef.value instanceof Element) {
observer.observe(containerRef.value);
}
onBeforeUnmount(() => observer.disconnect());
}
});
onBeforeUnmount(() => {
clearTimeoutIfNeeded();
});
</script>
<template>
<component
:is="as"
ref="containerRef"
:class="`inline-block whitespace-pre-wrap tracking-tight ${className}`"
v-bind="$attrs"
>
<span class="inline" :style="{ color: getCurrentTextColor() }">
{{ displayedText }}
</span>
<span
v-if="showCursor"
ref="cursorRef"
:class="`ml-1 inline-block opacity-100 ${
hideCursorWhileTyping && (currentCharIndex < textArray[currentTextIndex].length || isDeleting) ? 'hidden' : ''
} ${cursorClassName}`"
>
{{ cursorCharacter }}
</span>
</component>
</template>
+2
View File
@@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
content: [
"./index.html",
"./src/**/*.{vue,js}"
@@ -23,3 +24,4 @@ export default {
},
plugins: []
}