feat: Add folder management, visual thumbnails in dashboard, and full-page editor
This commit is contained in:
@@ -0,0 +1,60 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
const { verifyToken } = require('../middleware/auth');
|
||||||
|
|
||||||
|
const pgPool = new Pool({
|
||||||
|
host: process.env.PGHOST || 'postgres',
|
||||||
|
port: process.env.PGPORT || 5432,
|
||||||
|
user: process.env.PGUSER || 'postgres',
|
||||||
|
password: process.env.PGPASSWORD || 'postgres',
|
||||||
|
database: process.env.PGDATABASE || 'ckqr',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all folders for user
|
||||||
|
router.get('/', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pgPool.query(
|
||||||
|
'SELECT * FROM folders WHERE user_id = $1 ORDER BY created_at DESC',
|
||||||
|
[req.user.id]
|
||||||
|
);
|
||||||
|
res.json({ folders: result.rows });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error fetching folders' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a new folder
|
||||||
|
router.post('/', verifyToken, async (req, res) => {
|
||||||
|
const { name } = req.body;
|
||||||
|
if (!name) return res.status(400).json({ error: 'Folder name is required' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pgPool.query(
|
||||||
|
'INSERT INTO folders (user_id, name) VALUES ($1, $2) RETURNING *',
|
||||||
|
[req.user.id, name]
|
||||||
|
);
|
||||||
|
res.json({ message: 'Folder created', folder: result.rows[0] });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error creating folder' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete a folder
|
||||||
|
router.delete('/:id', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pgPool.query(
|
||||||
|
'DELETE FROM folders WHERE id = $1 AND user_id = $2 RETURNING id',
|
||||||
|
[req.params.id, req.user.id]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Folder not found or unauthorized' });
|
||||||
|
res.json({ message: 'Folder deleted' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Error deleting folder' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
+41
-15
@@ -16,14 +16,23 @@ const pgPool = new Pool({
|
|||||||
// Get all QRs for user
|
// Get all QRs for user
|
||||||
router.get('/', verifyToken, async (req, res) => {
|
router.get('/', verifyToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await pgPool.query(
|
const { folderId } = req.query;
|
||||||
`SELECT q.*,
|
let queryStr = `SELECT q.*,
|
||||||
COALESCE((SELECT COUNT(*) FROM qr_scans s WHERE s.qr_code_id = q.id), 0) as scans
|
COALESCE((SELECT COUNT(*) FROM qr_scans s WHERE s.qr_code_id = q.id), 0) as scans
|
||||||
FROM qr_codes q
|
FROM qr_codes q
|
||||||
WHERE q.user_id = $1
|
WHERE q.user_id = $1`;
|
||||||
ORDER BY q.created_at DESC`,
|
let params = [req.user.id];
|
||||||
[req.user.id]
|
|
||||||
);
|
if (folderId === 'default') {
|
||||||
|
queryStr += ` AND q.folder_id IS NULL`;
|
||||||
|
} else if (folderId) {
|
||||||
|
queryStr += ` AND q.folder_id = $2`;
|
||||||
|
params.push(folderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryStr += ` ORDER BY q.created_at DESC`;
|
||||||
|
|
||||||
|
const result = await pgPool.query(queryStr, params);
|
||||||
res.json({ qr_codes: result.rows });
|
res.json({ qr_codes: result.rows });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -31,9 +40,24 @@ router.get('/', verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get single QR code by ID
|
||||||
|
router.get('/:id', verifyToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pgPool.query(
|
||||||
|
`SELECT * FROM qr_codes WHERE id = $1 AND user_id = $2`,
|
||||||
|
[req.params.id, req.user.id]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'QR Code not found' });
|
||||||
|
res.json({ qr_code: result.rows[0] });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Generate QR Code
|
// Generate QR Code
|
||||||
router.post('/generate', verifyToken, async (req, res) => {
|
router.post('/generate', verifyToken, async (req, res) => {
|
||||||
const { name, type, dataType, destinationUrl, designData, customSlug } = req.body;
|
const { name, type, dataType, destinationUrl, designData, customSlug, folderId } = req.body;
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
if (!type || !dataType) {
|
if (!type || !dataType) {
|
||||||
@@ -63,9 +87,9 @@ router.post('/generate', verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await pgPool.query(
|
const result = await pgPool.query(
|
||||||
`INSERT INTO qr_codes (user_id, name, type, data_type, destination_url, short_url_id, design_data)
|
`INSERT INTO qr_codes (user_id, name, type, data_type, destination_url, short_url_id, design_data, folder_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||||
[userId, name || 'Untitled QR', type, dataType, destinationUrl, shortUrlId, designData || {}]
|
[userId, name || 'Untitled QR', type, dataType, destinationUrl, shortUrlId, designData || {}, folderId || null]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate Image (Data URI for simple response, could be saved to S3 later)
|
// Generate Image (Data URI for simple response, could be saved to S3 later)
|
||||||
@@ -82,9 +106,9 @@ router.post('/generate', verifyToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update QR Code (Editable Link)
|
// Update QR Code (Editable Link / Move Folder / Update Design)
|
||||||
router.put('/:id', verifyToken, async (req, res) => {
|
router.put('/:id', verifyToken, async (req, res) => {
|
||||||
const { name, destinationUrl, shortUrlId } = req.body;
|
const { name, destinationUrl, shortUrlId, designData, folderId } = req.body;
|
||||||
const qrId = req.params.id;
|
const qrId = req.params.id;
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
@@ -111,9 +135,11 @@ router.put('/:id', verifyToken, async (req, res) => {
|
|||||||
`UPDATE qr_codes
|
`UPDATE qr_codes
|
||||||
SET name = COALESCE($1, name),
|
SET name = COALESCE($1, name),
|
||||||
destination_url = COALESCE($2, destination_url),
|
destination_url = COALESCE($2, destination_url),
|
||||||
short_url_id = COALESCE($3, short_url_id)
|
short_url_id = COALESCE($3, short_url_id),
|
||||||
WHERE id = $4 RETURNING *`,
|
design_data = COALESCE($4, design_data),
|
||||||
[name, destinationUrl, newShortUrlId, qrId]
|
folder_id = $5
|
||||||
|
WHERE id = $6 RETURNING *`,
|
||||||
|
[name, destinationUrl, newShortUrlId, designData || qr.design_data, folderId !== undefined ? (folderId === 'default' ? null : folderId) : qr.folder_id, qrId]
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({ message: 'QR Code updated', qrRecord: updated.rows[0] });
|
res.json({ message: 'QR Code updated', qrRecord: updated.rows[0] });
|
||||||
|
|||||||
@@ -28,12 +28,14 @@ const redirectRoutes = require('./routes/redirect');
|
|||||||
const analyticsRoutes = require('./routes/analytics');
|
const analyticsRoutes = require('./routes/analytics');
|
||||||
const adminRoutes = require('./routes/admin');
|
const adminRoutes = require('./routes/admin');
|
||||||
const paymentsRoutes = require('./routes/payments');
|
const paymentsRoutes = require('./routes/payments');
|
||||||
|
const foldersRoutes = require('./routes/folders');
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/qr', qrRoutes);
|
app.use('/api/qr', qrRoutes);
|
||||||
app.use('/api/analytics', analyticsRoutes);
|
app.use('/api/analytics', analyticsRoutes);
|
||||||
app.use('/api/admin', adminRoutes);
|
app.use('/api/admin', adminRoutes);
|
||||||
app.use('/api/payments', paymentsRoutes);
|
app.use('/api/payments', paymentsRoutes);
|
||||||
|
app.use('/api/folders', foldersRoutes);
|
||||||
|
|
||||||
// Support Traefik stripped prefixes
|
// Support Traefik stripped prefixes
|
||||||
app.use('/auth', authRoutes);
|
app.use('/auth', authRoutes);
|
||||||
@@ -41,6 +43,7 @@ app.use('/qr', qrRoutes);
|
|||||||
app.use('/analytics', analyticsRoutes);
|
app.use('/analytics', analyticsRoutes);
|
||||||
app.use('/admin', adminRoutes);
|
app.use('/admin', adminRoutes);
|
||||||
app.use('/payments', paymentsRoutes);
|
app.use('/payments', paymentsRoutes);
|
||||||
|
app.use('/folders', foldersRoutes);
|
||||||
|
|
||||||
app.use('/l', redirectRoutes);
|
app.use('/l', redirectRoutes);
|
||||||
|
|
||||||
@@ -112,7 +115,16 @@ app.get('/api/health', healthCheck);
|
|||||||
app.get('/health', healthCheck);
|
app.get('/health', healthCheck);
|
||||||
|
|
||||||
// Auto-migrate schema
|
// Auto-migrate schema
|
||||||
|
pgPool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS folders (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);`).catch(console.error);
|
||||||
|
|
||||||
pgPool.query(`ALTER TABLE qr_codes ADD COLUMN IF NOT EXISTS name VARCHAR(255);`).catch(console.error);
|
pgPool.query(`ALTER TABLE qr_codes ADD COLUMN IF NOT EXISTS name VARCHAR(255);`).catch(console.error);
|
||||||
|
pgPool.query(`ALTER TABLE qr_codes ADD COLUMN IF NOT EXISTS folder_id UUID REFERENCES folders(id) ON DELETE SET NULL;`).catch(console.error);
|
||||||
pgPool.query(`
|
pgPool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS qr_scans (
|
CREATE TABLE IF NOT EXISTS qr_scans (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
'use client';
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import type { Options } from 'qr-code-styling';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
const LivePreview = dynamic(() => import('@/components/qr-editor/LivePreview'), { ssr: false });
|
||||||
|
import DesignPanel from '@/components/qr-editor/DesignPanel';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
export default function EditQRPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const qrId = params.id as string;
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [destinationUrl, setDestinationUrl] = useState('https://ck-qr.com');
|
||||||
|
const [qrName, setQrName] = useState('');
|
||||||
|
const [qrType, setQrType] = useState<'dynamic' | 'static'>('dynamic');
|
||||||
|
const [customSlug, setCustomSlug] = useState('');
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [userRole, setUserRole] = useState<string>('free');
|
||||||
|
const baseUrl = typeof window !== 'undefined' ? (process.env.NEXT_PUBLIC_APP_URL || 'https://qr.houseofwebsites.ai') : 'https://qr.houseofwebsites.ai';
|
||||||
|
|
||||||
|
const [qrOptions, setQrOptions] = useState<Options>({
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
type: 'svg',
|
||||||
|
data: $baseUrl/l/preview,
|
||||||
|
margin: 10,
|
||||||
|
qrOptions: { typeNumber: 0, mode: 'Byte', errorCorrectionLevel: 'Q' },
|
||||||
|
imageOptions: { hideBackgroundDots: true, imageSize: 0.4, margin: 5 },
|
||||||
|
dotsOptions: { color: '#4f46e5', type: 'rounded' },
|
||||||
|
backgroundOptions: { color: '#ffffff' },
|
||||||
|
cornersSquareOptions: { color: '#4f46e5', type: 'extra-rounded' }
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch user role
|
||||||
|
api.get('/auth/me').then(res => {
|
||||||
|
if (res.data?.user?.role) {
|
||||||
|
setUserRole(res.data.user.role);
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Fetch existing QR Data
|
||||||
|
api.get(/qr/ + qrId).then(res => {
|
||||||
|
const qr = res.data.qr_code;
|
||||||
|
setQrName(qr.name);
|
||||||
|
setDestinationUrl(qr.destination_url);
|
||||||
|
setQrType(qr.type);
|
||||||
|
setCustomSlug(qr.short_url_id);
|
||||||
|
|
||||||
|
if (qr.design_data) {
|
||||||
|
setQrOptions({
|
||||||
|
...qr.design_data,
|
||||||
|
data: qr.type === 'static' ? qr.destination_url : $baseUrl/l/ + qr.short_url_id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
alert('Failed to load QR code');
|
||||||
|
router.push('/dashboard');
|
||||||
|
});
|
||||||
|
}, [qrId, baseUrl, router]);
|
||||||
|
|
||||||
|
// Keep QR data in sync with custom slug (only if dynamic)
|
||||||
|
const handleSlugChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (qrType === 'static') return;
|
||||||
|
const val = e.target.value.replace(/[^a-zA-Z0-9-_]/g, '');
|
||||||
|
setCustomSlug(val);
|
||||||
|
setQrOptions(prev => ({ ...prev, data: $baseUrl/l/ + val }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setIsSaving(true);
|
||||||
|
await api.put(/qr/ + qrId, {
|
||||||
|
name: qrName,
|
||||||
|
destinationUrl: destinationUrl,
|
||||||
|
shortUrlId: qrType === 'dynamic' ? customSlug : undefined,
|
||||||
|
designData: qrOptions
|
||||||
|
});
|
||||||
|
|
||||||
|
alert('QR Code Successfully Updated!');
|
||||||
|
router.push('/dashboard');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('Failed to update QR Code.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = async (extension: 'png' | 'svg' = 'png') => {
|
||||||
|
try {
|
||||||
|
const QRCodeStyling = (await import('qr-code-styling')).default;
|
||||||
|
const qrCode = new QRCodeStyling(qrOptions);
|
||||||
|
qrCode.download({ name: qrName || 'QR-Code', extension });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Download failed', err);
|
||||||
|
alert('Failed to download QR code');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="p-12 text-center text-gray-500 font-sans">Loading Editor...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-8 max-w-7xl mx-auto h-full font-sans">
|
||||||
|
<div className="flex justify-between items-center mb-8">
|
||||||
|
<div>
|
||||||
|
<Link href="/dashboard" className="text-sm font-bold text-indigo-600 hover:underline mb-2 inline-block">
|
||||||
|
← Back to Dashboard
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-800">Edit QR Code</h1>
|
||||||
|
<p className="text-gray-500 mt-1">Update your QR destination and visual design</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload('png')}
|
||||||
|
className="bg-white border border-gray-300 text-gray-700 px-6 py-2.5 rounded-lg hover:bg-gray-50 transition-colors font-semibold shadow-sm"
|
||||||
|
>
|
||||||
|
Download PNG
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload('svg')}
|
||||||
|
disabled={userRole === 'free' || userRole === 'free_trial'}
|
||||||
|
className={px-6 py-2.5 rounded-lg font-semibold shadow-sm transition-colors border border-gray-300 }
|
||||||
|
title={userRole === 'free' || userRole === 'free_trial' ? 'SVG export is for Pro users' : 'Download SVG'}
|
||||||
|
>
|
||||||
|
{userRole === 'free' || userRole === 'free_trial' ? 'Download SVG (Pro)' : 'Download SVG'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="bg-indigo-600 text-white px-6 py-2.5 rounded-lg hover:bg-indigo-700 transition-colors font-semibold shadow-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 h-[calc(100vh-180px)]">
|
||||||
|
{/* Left Column - Controls */}
|
||||||
|
<div className="lg:col-span-7 xl:col-span-8 space-y-6 overflow-y-auto pr-2 pb-10">
|
||||||
|
|
||||||
|
{/* Data Input Panel */}
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
<h2 className="text-xl font-bold text-gray-800 border-b pb-4 mb-6">QR Content</h2>
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">QR Type</label>
|
||||||
|
<div className="p-3 bg-gray-50 rounded-lg border border-gray-200 text-gray-600 font-medium text-sm">
|
||||||
|
{qrType === 'dynamic' ? 'Dynamic (Trackable & Editable)' : 'Static (Permanent - Cannot change URL)'}
|
||||||
|
<span className="ml-2 text-xs text-gray-400 font-normal">(Type cannot be changed after creation)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Internal Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={qrName}
|
||||||
|
onChange={e => setQrName(e.target.value)}
|
||||||
|
placeholder="e.g. Front Door Menu"
|
||||||
|
className="w-full border-gray-300 rounded-lg shadow-sm p-3 border focus:ring-indigo-500 focus:border-indigo-500 text-gray-900 bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Destination URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={destinationUrl}
|
||||||
|
onChange={e => {
|
||||||
|
setDestinationUrl(e.target.value);
|
||||||
|
if (qrType === 'static') {
|
||||||
|
setQrOptions(prev => ({ ...prev, data: e.target.value }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={qrType === 'static'}
|
||||||
|
className={w-full rounded-lg shadow-sm p-3 border focus:ring-indigo-500 focus:border-indigo-500 text-gray-900 }
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{qrType === 'dynamic' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Custom Short Link</label>
|
||||||
|
<div className="flex flex-col sm:flex-row shadow-sm rounded-lg overflow-hidden border border-gray-300 focus-within:ring-2 focus-within:ring-indigo-500">
|
||||||
|
<span className="inline-flex items-center px-4 bg-gray-50 text-gray-500 border-r border-gray-300 text-sm whitespace-nowrap">
|
||||||
|
{baseUrl.replace(/^https?:\/\//, '')}/l/
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customSlug}
|
||||||
|
onChange={handleSlugChange}
|
||||||
|
className="flex-1 w-full p-3 focus:outline-none text-gray-900 bg-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Design Panel */}
|
||||||
|
<DesignPanel options={qrOptions} onChange={setQrOptions} />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Live Preview Sticky */}
|
||||||
|
<div className="lg:col-span-5 xl:col-span-4 relative hidden lg:block">
|
||||||
|
<div className="sticky top-0 pt-4">
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 flex flex-col items-center">
|
||||||
|
<h3 className="font-semibold text-gray-700 mb-4 self-start">Live Preview</h3>
|
||||||
|
<LivePreview options={qrOptions} />
|
||||||
|
<p className="text-xs text-gray-400 mt-4 text-center">Scan to preview the destination</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+158
-114
@@ -1,7 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
|
import LiveThumbnail from '@/components/qr-editor/LiveThumbnail';
|
||||||
|
|
||||||
interface QRCode {
|
interface QRCode {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,25 +13,48 @@ interface QRCode {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
scans: number;
|
scans: number;
|
||||||
design_data: any;
|
design_data: any;
|
||||||
|
folder_id: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Folder {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
const router = useRouter();
|
||||||
const [qrs, setQrs] = useState<QRCode[]>([]);
|
const [qrs, setQrs] = useState<QRCode[]>([]);
|
||||||
|
const [folders, setFolders] = useState<Folder[]>([]);
|
||||||
|
const [selectedFolder, setSelectedFolder] = useState<string | 'all' | 'default'>('all');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [userRole, setUserRole] = useState<string>('free');
|
const [userRole, setUserRole] = useState<string>('free');
|
||||||
|
|
||||||
// Edit Modal State
|
// Folder Creation State
|
||||||
const [editingQr, setEditingQr] = useState<QRCode | null>(null);
|
const [showFolderModal, setShowFolderModal] = useState(false);
|
||||||
const [editName, setEditName] = useState('');
|
const [newFolderName, setNewFolderName] = useState('');
|
||||||
const [editUrl, setEditUrl] = useState('');
|
|
||||||
const [editSlug, setEditSlug] = useState('');
|
// Move Folder State
|
||||||
const [editError, setEditError] = useState('');
|
const [moveQrId, setMoveQrId] = useState<string | null>(null);
|
||||||
const [editLoading, setEditLoading] = useState(false);
|
|
||||||
|
const fetchFolders = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/folders');
|
||||||
|
setFolders(res.data.folders || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to fetch folders", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchQRs = async () => {
|
const fetchQRs = async () => {
|
||||||
try {
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
let url = '/qr';
|
||||||
|
if (selectedFolder !== 'all') {
|
||||||
|
url += ?folderId= + selectedFolder;
|
||||||
|
}
|
||||||
|
|
||||||
const [res, authRes] = await Promise.all([
|
const [res, authRes] = await Promise.all([
|
||||||
api.get('/qr'),
|
api.get(url),
|
||||||
api.get('/auth/me').catch(() => null)
|
api.get('/auth/me').catch(() => null)
|
||||||
]);
|
]);
|
||||||
setQrs(res.data.qr_codes || []);
|
setQrs(res.data.qr_codes || []);
|
||||||
@@ -44,43 +69,54 @@ export default function DashboardPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchQRs();
|
fetchFolders();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openEditModal = (qr: QRCode) => {
|
useEffect(() => {
|
||||||
setEditingQr(qr);
|
fetchQRs();
|
||||||
setEditName(qr.name);
|
}, [selectedFolder]);
|
||||||
setEditUrl(qr.destination_url);
|
|
||||||
setEditSlug(qr.short_url_id);
|
const handleCreateFolder = async (e: React.FormEvent) => {
|
||||||
setEditError('');
|
e.preventDefault();
|
||||||
|
if (!newFolderName.trim()) return;
|
||||||
|
try {
|
||||||
|
const res = await api.post('/folders', { name: newFolderName });
|
||||||
|
setFolders([res.data.folder, ...folders]);
|
||||||
|
setShowFolderModal(false);
|
||||||
|
setNewFolderName('');
|
||||||
|
setSelectedFolder(res.data.folder.id);
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to create folder');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditSubmit = async (e: React.FormEvent) => {
|
const handleDeleteFolder = async (id: string) => {
|
||||||
e.preventDefault();
|
if (!confirm('Are you sure? QRs in this folder will be moved to the default folder.')) return;
|
||||||
if (!editingQr) return;
|
|
||||||
setEditLoading(true);
|
|
||||||
setEditError('');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.put(`/qr/${editingQr.id}`, {
|
await api.delete(/folders/ + id);
|
||||||
name: editName,
|
setFolders(folders.filter(f => f.id !== id));
|
||||||
destinationUrl: editUrl,
|
if (selectedFolder === id) setSelectedFolder('all');
|
||||||
shortUrlId: editSlug
|
fetchQRs(); // refresh QRs just in case
|
||||||
});
|
} catch (err) {
|
||||||
// Refresh list
|
alert('Failed to delete folder');
|
||||||
await fetchQRs();
|
}
|
||||||
setEditingQr(null);
|
};
|
||||||
} catch (err: any) {
|
|
||||||
setEditError(err.response?.data?.error || 'Failed to update QR code');
|
const handleMoveQr = async (folderId: string | 'default') => {
|
||||||
} finally {
|
if (!moveQrId) return;
|
||||||
setEditLoading(false);
|
try {
|
||||||
|
await api.put(/qr/ + moveQrId, { folderId });
|
||||||
|
setMoveQrId(null);
|
||||||
|
fetchQRs();
|
||||||
|
} catch (err) {
|
||||||
|
alert('Failed to move QR code');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!confirm('Are you sure you want to delete this QR code? The link will immediately stop working.')) return;
|
if (!confirm('Are you sure you want to delete this QR code? The link will immediately stop working.')) return;
|
||||||
try {
|
try {
|
||||||
await api.delete(`/qr/${id}`);
|
await api.delete(/qr/ + id);
|
||||||
setQrs(qrs.filter(qr => qr.id !== id));
|
setQrs(qrs.filter(qr => qr.id !== id));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Failed to delete QR code');
|
alert('Failed to delete QR code');
|
||||||
@@ -91,7 +127,7 @@ export default function DashboardPage() {
|
|||||||
try {
|
try {
|
||||||
const QRCodeStyling = (await import('qr-code-styling')).default;
|
const QRCodeStyling = (await import('qr-code-styling')).default;
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://qr.houseofwebsites.ai';
|
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://qr.houseofwebsites.ai';
|
||||||
const shortUrl = `${baseUrl}/l/${qr.short_url_id}`;
|
const shortUrl = baseUrl + '/l/' + qr.short_url_id;
|
||||||
|
|
||||||
const qrCode = new QRCodeStyling({
|
const qrCode = new QRCodeStyling({
|
||||||
...qr.design_data,
|
...qr.design_data,
|
||||||
@@ -104,12 +140,55 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalScans = qrs.reduce((sum, qr) => sum + Number(qr.scans), 0);
|
|
||||||
const baseUrl = typeof window !== 'undefined' ? window.location.origin : (process.env.NEXT_PUBLIC_APP_URL || 'https://qr.houseofwebsites.ai');
|
const baseUrl = typeof window !== 'undefined' ? window.location.origin : (process.env.NEXT_PUBLIC_APP_URL || 'https://qr.houseofwebsites.ai');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8 max-w-7xl mx-auto font-sans relative">
|
<div className="p-8 max-w-7xl mx-auto font-sans relative flex gap-8 flex-col md:flex-row">
|
||||||
|
|
||||||
|
{/* Sidebar Folders */}
|
||||||
|
<div className="w-full md:w-64 flex-shrink-0">
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-4 sticky top-8">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 mb-4 px-2">Folders</h2>
|
||||||
|
<div className="space-y-1 mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedFolder('all')}
|
||||||
|
className={w-full text-left px-3 py-2 rounded-lg transition-colors text-sm font-medium }
|
||||||
|
>
|
||||||
|
All QR Codes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedFolder('default')}
|
||||||
|
className={w-full text-left px-3 py-2 rounded-lg transition-colors text-sm font-medium }
|
||||||
|
>
|
||||||
|
Default Folder
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{folders.map(folder => (
|
||||||
|
<div key={folder.id} className="flex group">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedFolder(folder.id)}
|
||||||
|
className={lex-1 text-left px-3 py-2 rounded-l-lg transition-colors text-sm font-medium truncate }
|
||||||
|
>
|
||||||
|
{folder.name}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDeleteFolder(folder.id)} className={px-2 rounded-r-lg text-gray-400 hover:text-red-600 transition-colors }>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFolderModal(true)}
|
||||||
|
className="w-full flex items-center justify-center gap-2 bg-gray-50 border border-gray-200 text-gray-700 py-2 rounded-lg hover:bg-gray-100 transition-colors text-sm font-bold"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
|
||||||
|
New Folder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex justify-between items-center mb-8 bg-white p-6 rounded-2xl shadow-sm border border-gray-200">
|
<div className="flex justify-between items-center mb-8 bg-white p-6 rounded-2xl shadow-sm border border-gray-200">
|
||||||
<div>
|
<div>
|
||||||
@@ -127,17 +206,6 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Verification Warning (Mock ME-QR style) */}
|
|
||||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 mb-8 rounded-r-xl flex justify-between items-center shadow-sm">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-red-800 font-bold">WARNING!</h3>
|
|
||||||
<p className="text-red-700 text-sm">Your email should be verified to prevent link expiration.</p>
|
|
||||||
</div>
|
|
||||||
<button className="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-6 rounded-lg transition-colors">
|
|
||||||
Verify Email
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* List View */}
|
{/* List View */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -148,31 +216,31 @@ export default function DashboardPage() {
|
|||||||
<svg className="w-8 h-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
|
<svg className="w-8 h-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" /></svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-gray-900 mb-2">No QR codes yet</h3>
|
<h3 className="text-xl font-bold text-gray-900 mb-2">No QR codes yet</h3>
|
||||||
<p className="text-gray-500 mb-6 max-w-sm mx-auto">Create your first dynamic link to generate a trackable QR code.</p>
|
<p className="text-gray-500 mb-6 max-w-sm mx-auto">Create your first dynamic link to generate a trackable QR code in this folder.</p>
|
||||||
<Link href="/dashboard/create" className="inline-block bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3 px-8 rounded-xl transition-colors shadow-sm">Create New QR Code</Link>
|
<Link href="/dashboard/create" className="inline-block bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3 px-8 rounded-xl transition-colors shadow-sm">Create New QR Code</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
qrs.map((qr) => (
|
qrs.map((qr) => (
|
||||||
<div key={qr.id} className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 flex flex-col md:flex-row items-center gap-8 hover:shadow-md transition-shadow relative group">
|
<div key={qr.id} className="bg-white rounded-2xl shadow-sm border border-gray-200 p-6 flex flex-col md:flex-row items-center gap-8 hover:shadow-md transition-shadow relative group">
|
||||||
|
|
||||||
{/* QR Image Placeholder (In real app, fetch from backend) */}
|
{/* QR Image Custom Thumbnail */}
|
||||||
<div className="w-32 h-32 bg-gray-100 rounded-xl border border-gray-200 flex-shrink-0 flex items-center justify-center p-2">
|
<div className="w-32 h-32 flex-shrink-0 flex items-center justify-center relative border border-gray-100 rounded-xl overflow-hidden bg-gray-50">
|
||||||
<img src={`https://api.qrserver.com/v1/create-qr-code/?size=150x150&data=${encodeURIComponent(baseUrl + '/l/' + qr.short_url_id)}`} alt="QR" className="w-full h-full object-contain" />
|
<LiveThumbnail options={{ ...qr.design_data, data: baseUrl + '/l/' + qr.short_url_id }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Area */}
|
{/* Info Area */}
|
||||||
<div className="flex-1 min-w-0 w-full">
|
<div className="flex-1 min-w-0 w-full">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
<h3 className="text-xl font-bold text-gray-900 truncate">{qr.name}</h3>
|
<h3 className="text-xl font-bold text-gray-900 truncate">{qr.name}</h3>
|
||||||
<button onClick={() => openEditModal(qr)} className="text-gray-400 hover:text-indigo-600 transition-colors">
|
<Link href={/dashboard/edit/ + qr.id} className="text-gray-400 hover:text-indigo-600 transition-colors" title="Edit QR settings">
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" /></svg>
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<a href={`${baseUrl}/l/${qr.short_url_id}`} target="_blank" rel="noopener noreferrer" className="text-indigo-600 font-bold hover:underline flex items-center gap-1 text-sm bg-indigo-50 inline-flex px-3 py-1.5 rounded-lg border border-indigo-100">
|
<a href={${baseUrl}/l/} target="_blank" rel="noopener noreferrer" className="text-indigo-600 font-bold hover:underline flex items-center gap-1 text-sm bg-indigo-50 inline-flex px-3 py-1.5 rounded-lg border border-indigo-100 truncate max-w-full">
|
||||||
{baseUrl}/l/{qr.short_url_id}
|
{baseUrl}/l/{qr.short_url_id}
|
||||||
<svg className="w-3 h-3 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
|
<svg className="w-3 h-3 ml-1 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -205,11 +273,7 @@ export default function DashboardPage() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleDownload(qr, 'svg')}
|
onClick={() => handleDownload(qr, 'svg')}
|
||||||
disabled={userRole === 'free' || userRole === 'free_trial'}
|
disabled={userRole === 'free' || userRole === 'free_trial'}
|
||||||
className={`flex-1 font-bold py-2 px-2 rounded-lg transition-colors flex justify-center items-center text-sm shadow-sm ${
|
className={lex-1 font-bold py-2 px-2 rounded-lg transition-colors flex justify-center items-center text-sm shadow-sm }
|
||||||
userRole === 'free' || userRole === 'free_trial'
|
|
||||||
? 'bg-gray-200 text-gray-500 cursor-not-allowed'
|
|
||||||
: 'bg-green-500 hover:bg-green-600 text-white'
|
|
||||||
}`}
|
|
||||||
title={userRole === 'free' || userRole === 'free_trial' ? 'SVG export is for Pro users' : 'Download SVG'}
|
title={userRole === 'free' || userRole === 'free_trial' ? 'SVG export is for Pro users' : 'Download SVG'}
|
||||||
>
|
>
|
||||||
{userRole === 'free' || userRole === 'free_trial' ? 'SVG (Pro)' : 'SVG'}
|
{userRole === 'free' || userRole === 'free_trial' ? 'SVG (Pro)' : 'SVG'}
|
||||||
@@ -217,9 +281,26 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button onClick={() => openEditModal(qr)} className="flex-1 bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 font-semibold py-2 px-3 rounded-lg transition-colors text-sm">
|
<Link href={/dashboard/edit/ + qr.id} className="flex-1 bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 font-semibold py-2 px-3 rounded-lg transition-colors text-sm text-center">
|
||||||
Edit Link
|
Edit Link
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Move Folder Dropdown Toggle */}
|
||||||
|
<div className="relative">
|
||||||
|
<button onClick={() => setMoveQrId(moveQrId === qr.id ? null : qr.id)} className="bg-white border border-gray-300 hover:bg-gray-50 text-gray-700 font-semibold py-2 px-3 rounded-lg transition-colors h-full">
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
|
||||||
</button>
|
</button>
|
||||||
|
{moveQrId === qr.id && (
|
||||||
|
<div className="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-xl border border-gray-100 py-2 z-10">
|
||||||
|
<div className="px-3 py-1 text-xs font-bold text-gray-400 uppercase tracking-wider">Move to Folder</div>
|
||||||
|
<button onClick={() => handleMoveQr('default')} className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-indigo-50 hover:text-indigo-700">Default Folder</button>
|
||||||
|
{folders.map(f => (
|
||||||
|
<button key={f.id} onClick={() => handleMoveQr(f.id)} className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-indigo-50 hover:text-indigo-700">{f.name}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button onClick={() => handleDelete(qr.id)} className="bg-white border border-gray-300 hover:bg-red-50 hover:text-red-600 hover:border-red-200 text-gray-700 font-semibold py-2 px-3 rounded-lg transition-colors">
|
<button onClick={() => handleDelete(qr.id)} className="bg-white border border-gray-300 hover:bg-red-50 hover:text-red-600 hover:border-red-200 text-gray-700 font-semibold py-2 px-3 rounded-lg transition-colors">
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -231,81 +312,44 @@ export default function DashboardPage() {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Edit Modal Overlay */}
|
{/* Folder Creation Modal */}
|
||||||
{editingQr && (
|
{showFolderModal && (
|
||||||
<div className="fixed inset-0 bg-gray-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-gray-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||||
<div className="bg-white rounded-2xl shadow-xl border border-gray-200 w-full max-w-lg overflow-hidden transform transition-all">
|
<div className="bg-white rounded-2xl shadow-xl border border-gray-200 w-full max-w-md overflow-hidden transform transition-all">
|
||||||
<div className="px-6 py-5 border-b border-gray-100 flex justify-between items-center bg-gray-50">
|
<div className="px-6 py-5 border-b border-gray-100 flex justify-between items-center bg-gray-50">
|
||||||
<h3 className="text-lg font-bold text-gray-900">Edit Dynamic Link</h3>
|
<h3 className="text-lg font-bold text-gray-900">Create Folder</h3>
|
||||||
<button onClick={() => setEditingQr(null)} className="text-gray-400 hover:text-gray-600 transition-colors">
|
<button onClick={() => setShowFolderModal(false)} className="text-gray-400 hover:text-gray-600 transition-colors">
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleEditSubmit} className="p-6 space-y-5">
|
<form onSubmit={handleCreateFolder} className="p-6 space-y-5">
|
||||||
{editError && (
|
|
||||||
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm border border-red-100 font-medium">
|
|
||||||
{editError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-bold text-gray-700 mb-2">QR Title (Internal)</label>
|
<label className="block text-sm font-bold text-gray-700 mb-2">Folder Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={editName}
|
value={newFolderName}
|
||||||
onChange={e => setEditName(e.target.value)}
|
onChange={e => setNewFolderName(e.target.value)}
|
||||||
className="w-full bg-white border border-gray-300 rounded-xl px-4 py-3 text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all shadow-sm"
|
className="w-full bg-white border border-gray-300 rounded-xl px-4 py-3 text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all shadow-sm"
|
||||||
|
placeholder="e.g., Summer Campaign"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-bold text-gray-700 mb-2">Destination URL</label>
|
|
||||||
<input
|
|
||||||
type="url"
|
|
||||||
required
|
|
||||||
value={editUrl}
|
|
||||||
onChange={e => setEditUrl(e.target.value)}
|
|
||||||
className="w-full bg-white border border-gray-300 rounded-xl px-4 py-3 text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all shadow-sm"
|
|
||||||
placeholder="https://yourwebsite.com/new-campaign"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-2">Where should scanners be instantly redirected?</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-bold text-gray-700 mb-2">Custom Short Slug</label>
|
|
||||||
<div className="flex flex-col sm:flex-row shadow-sm rounded-xl overflow-hidden border border-gray-300 focus-within:ring-2 focus-within:ring-indigo-500 focus-within:border-transparent transition-all">
|
|
||||||
<span className="inline-flex items-center px-4 bg-gray-50 text-gray-500 font-medium text-sm sm:border-r border-gray-300">
|
|
||||||
{baseUrl.replace(/^https?:\/\//, '')}/l/
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
value={editSlug}
|
|
||||||
onChange={e => setEditSlug(e.target.value.replace(/[^a-zA-Z0-9-_]/g, ''))}
|
|
||||||
className="flex-1 bg-white px-4 py-3 text-gray-900 focus:outline-none font-bold"
|
|
||||||
placeholder="my-custom-slug"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-500 mt-2">Only letters, numbers, hyphens, and underscores.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-4 flex gap-3 border-t border-gray-100">
|
<div className="pt-4 flex gap-3 border-t border-gray-100">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setEditingQr(null)}
|
onClick={() => setShowFolderModal(false)}
|
||||||
className="flex-1 bg-white border border-gray-300 text-gray-700 font-bold py-3 px-4 rounded-xl hover:bg-gray-50 transition-colors"
|
className="flex-1 bg-white border border-gray-300 text-gray-700 font-bold py-3 px-4 rounded-xl hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={editLoading}
|
className="flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3 px-4 rounded-xl transition-colors shadow-sm"
|
||||||
className="flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3 px-4 rounded-xl transition-colors shadow-sm disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
{editLoading ? 'Saving...' : 'Save Changes'}
|
Create Folder
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
'use client';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import QRCodeStyling, { Options } from 'qr-code-styling';
|
||||||
|
|
||||||
|
interface LiveThumbnailProps {
|
||||||
|
options: Options;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LiveThumbnail({ options }: LiveThumbnailProps) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Clone options and override size for thumbnail
|
||||||
|
const thumbOptions: Options = {
|
||||||
|
...options,
|
||||||
|
width: 150,
|
||||||
|
height: 150,
|
||||||
|
margin: 5
|
||||||
|
};
|
||||||
|
|
||||||
|
const [qrCode] = useState<QRCodeStyling>(new QRCodeStyling(thumbOptions));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) {
|
||||||
|
qrCode.append(ref.current);
|
||||||
|
}
|
||||||
|
}, [qrCode, ref]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!qrCode) return;
|
||||||
|
// ensure size remains thumbnail size
|
||||||
|
qrCode.update({ ...options, width: 150, height: 150, margin: 5 });
|
||||||
|
}, [qrCode, options]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center overflow-hidden">
|
||||||
|
<div ref={ref} className="transform scale-90 origin-center" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user