feat: Add folder management, visual thumbnails in dashboard, and full-page editor

This commit is contained in:
Mohan Ki
2026-07-27 18:45:37 +05:30
parent 8b95c0d77b
commit 3a61488d26
6 changed files with 626 additions and 215 deletions
+60
View File
@@ -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
View File
@@ -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] });
+12
View File
@@ -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(),
+230
View File
@@ -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">
&larr; 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>
);
}
+238 -194
View File
@@ -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,208 +140,216 @@ 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">
{/* Header */} {/* Sidebar Folders */}
<div className="flex justify-between items-center mb-8 bg-white p-6 rounded-2xl shadow-sm border border-gray-200"> <div className="w-full md:w-64 flex-shrink-0">
<div> <div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-4 sticky top-8">
<h1 className="text-3xl font-extrabold text-gray-900 tracking-tight">My QR Codes</h1> <h2 className="text-lg font-bold text-gray-900 mb-4 px-2">Folders</h2>
<p className="text-gray-500 mt-1">Manage, track, and dynamically route your active links.</p> <div className="space-y-1 mb-6">
</div> <button
<div className="flex gap-4"> onClick={() => setSelectedFolder('all')}
<Link className={w-full text-left px-3 py-2 rounded-lg transition-colors text-sm font-medium }
href="/dashboard/analytics" >
className="bg-white border border-gray-300 text-gray-700 px-5 py-2.5 rounded-lg hover:bg-gray-50 transition-colors font-semibold flex items-center gap-2" 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-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg> <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>
Analytics New Folder
</Link> </button>
</div> </div>
</div> </div>
{/* Verification Warning (Mock ME-QR style) */} <div className="flex-1 min-w-0">
<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"> {/* Header */}
<div> <div className="flex justify-between items-center mb-8 bg-white p-6 rounded-2xl shadow-sm border border-gray-200">
<h3 className="text-red-800 font-bold">WARNING!</h3> <div>
<p className="text-red-700 text-sm">Your email should be verified to prevent link expiration.</p> <h1 className="text-3xl font-extrabold text-gray-900 tracking-tight">My QR Codes</h1>
</div> <p className="text-gray-500 mt-1">Manage, track, and dynamically route your active links.</p>
<button className="bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-6 rounded-lg transition-colors"> </div>
Verify Email <div className="flex gap-4">
</button> <Link
</div> href="/dashboard/analytics"
className="bg-white border border-gray-300 text-gray-700 px-5 py-2.5 rounded-lg hover:bg-gray-50 transition-colors font-semibold flex items-center gap-2"
{/* List View */} >
<div className="space-y-4"> <svg className="w-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /></svg>
{loading ? ( Analytics
<div className="p-12 text-center text-gray-500 bg-white rounded-2xl shadow-sm border border-gray-200">Loading your dynamic links...</div> </Link>
) : qrs.length === 0 ? (
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-16 text-center">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<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>
<p className="text-gray-500 mb-6 max-w-sm mx-auto">Create your first dynamic link to generate a trackable QR code.</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>
</div> </div>
) : (
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">
{/* QR Image Placeholder (In real app, fetch from backend) */} {/* List View */}
<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="space-y-4">
<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" /> {loading ? (
<div className="p-12 text-center text-gray-500 bg-white rounded-2xl shadow-sm border border-gray-200">Loading your dynamic links...</div>
) : qrs.length === 0 ? (
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 p-16 text-center">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<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>
<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>
</div>
) : (
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">
{/* Info Area */} {/* QR Image Custom Thumbnail */}
<div className="flex-1 min-w-0 w-full"> <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">
<div className="flex items-center gap-3 mb-2"> <LiveThumbnail options={{ ...qr.design_data, data: baseUrl + '/l/' + qr.short_url_id }} />
<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">
<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>
</div> </div>
<div className="mb-4"> {/* Info Area */}
<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"> <div className="flex-1 min-w-0 w-full">
{baseUrl}/l/{qr.short_url_id} <div className="flex items-center gap-3 mb-2">
<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> <h3 className="text-xl font-bold text-gray-900 truncate">{qr.name}</h3>
</a> <Link href={/dashboard/edit/ + qr.id} className="text-gray-400 hover:text-indigo-600 transition-colors" title="Edit QR settings">
</div> <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>
</Link>
<div className="flex items-center gap-6 text-sm text-gray-500">
<div className="flex items-center gap-1">
<span className="font-semibold text-gray-700">Type:</span> Link
</div> </div>
<div className="flex items-center gap-1">
<span className="font-semibold text-gray-700">Created:</span> {new Date(qr.created_at).toLocaleDateString()} <div className="mb-4">
<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}
<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>
</div>
<div className="flex items-center gap-6 text-sm text-gray-500">
<div className="flex items-center gap-1">
<span className="font-semibold text-gray-700">Type:</span> Link
</div>
<div className="flex items-center gap-1">
<span className="font-semibold text-gray-700">Created:</span> {new Date(qr.created_at).toLocaleDateString()}
</div>
</div> </div>
</div> </div>
{/* Metrics & Actions */}
<div className="flex flex-col sm:flex-row items-center gap-8 w-full md:w-auto">
<div className="text-center md:text-right">
<div className="text-3xl font-black text-gray-900">{qr.scans.toLocaleString()}</div>
<div className="text-sm text-gray-500 uppercase tracking-wider font-semibold">Scans</div>
</div>
<div className="flex flex-col gap-2 w-full sm:w-48">
<div className="flex gap-2 w-full">
<button
onClick={() => handleDownload(qr, 'png')}
className="flex-1 bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-2 rounded-lg transition-colors flex justify-center items-center text-sm shadow-sm"
title="Download PNG"
>
PNG
</button>
<button
onClick={() => handleDownload(qr, 'svg')}
disabled={userRole === 'free' || userRole === 'free_trial'}
className={ lex-1 font-bold py-2 px-2 rounded-lg transition-colors flex justify-center items-center text-sm shadow-sm }
title={userRole === 'free' || userRole === 'free_trial' ? 'SVG export is for Pro users' : 'Download SVG'}
>
{userRole === 'free' || userRole === 'free_trial' ? 'SVG (Pro)' : 'SVG'}
</button>
</div>
<div className="flex gap-2">
<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
</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>
{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">
<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>
</div>
</div>
</div>
</div> </div>
))
{/* Metrics & Actions */} )}
<div className="flex flex-col sm:flex-row items-center gap-8 w-full md:w-auto"> </div>
<div className="text-center md:text-right">
<div className="text-3xl font-black text-gray-900">{qr.scans.toLocaleString()}</div>
<div className="text-sm text-gray-500 uppercase tracking-wider font-semibold">Scans</div>
</div>
<div className="flex flex-col gap-2 w-full sm:w-48">
<div className="flex gap-2 w-full">
<button
onClick={() => handleDownload(qr, 'png')}
className="flex-1 bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-2 rounded-lg transition-colors flex justify-center items-center text-sm shadow-sm"
title="Download PNG"
>
PNG
</button>
<button
onClick={() => handleDownload(qr, 'svg')}
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 ${
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'}
>
{userRole === 'free' || userRole === 'free_trial' ? 'SVG (Pro)' : 'SVG'}
</button>
</div>
<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">
Edit Link
</button>
<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>
</button>
</div>
</div>
</div>
</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>
);
}