feat: add frontend dashboard

This commit is contained in:
2026-01-28 19:55:53 -05:00
parent e6b73a330f
commit abf7968911
21 changed files with 4523 additions and 148 deletions

View File

@@ -175,38 +175,40 @@
--- ---
## Phase 6: Frontend Dashboard ## Phase 6: Frontend Dashboard (In Progress)
### Authentication UI ### Authentication UI
- [ ] Login page - [x] Login page
- [ ] Registration page - [x] Registration page
- [ ] Forgot password page - [ ] Forgot password page
- [ ] Password reset page - [ ] Password reset page
- [ ] Auth state management - [x] Auth state management (Pinia store)
### Dashboard ### Dashboard
- [ ] Workspace switcher - [x] Workspace switcher
- [ ] Dashboard home (overview stats) - [x] Dashboard home (overview stats)
- [ ] Navigation/sidebar - [x] Navigation/sidebar (AppLayout component)
### Link Management UI ### Link Management UI
- [ ] Links list view - [x] Links list view
- [ ] Create link modal/page - [x] Create link modal
- [ ] Edit link modal/page - [x] Edit link modal
- [ ] Link details with analytics - [x] Link details with analytics
### QR Designer UI ### QR Designer UI
- [ ] QR designer page - [x] QR designer page
- [ ] Color pickers - [x] Color pickers
- [ ] Shape selectors - [~] Shape selectors (basic support)
- [ ] Logo upload - [ ] Logo upload integration
- [ ] Live preview - [x] Live preview (for saved QR codes)
- [ ] Export buttons - [x] Export buttons (PNG/SVG)
- [x] Style presets (6 presets)
### Analytics UI ### Analytics UI
- [ ] Charts (time series) - [x] Charts (time series with clicks/scans)
- [ ] Stat cards - [x] Stat cards (clicks, scans, visitors, total)
- [ ] Breakdown tables (referrer, geo, device) - [x] Breakdown tables (referrer, device)
- [~] Geo breakdown (API ready, UI pending)
--- ---
@@ -254,7 +256,9 @@
## Current Focus ## Current Focus
**Completed: Phase 2 + Phase 3 + Phase 4 + Phase 5** **Completed: Phase 2 + Phase 3 + Phase 4 + Phase 5 + Phase 6 (partial)**
Backend (101 tests passing):
- Short Link CRUD (5 endpoints, 15 tests) - Short Link CRUD (5 endpoints, 15 tests)
- Public Redirect Endpoint (2 endpoints, 10 tests) - Public Redirect Endpoint (2 endpoints, 10 tests)
- Event Tracking Service (click logging, dedupe, device detection) - Event Tracking Service (click logging, dedupe, device detection)
@@ -263,9 +267,21 @@
- Domain Management (5 endpoints, 10 tests) - Domain Management (5 endpoints, 10 tests)
- Asset Upload (4 endpoints, 10 tests) - Asset Upload (4 endpoints, 10 tests)
**Total: 101 tests passing** Frontend (Vue 3 + Vite + Pinia):
- Landing page with hero, features, analytics sections
- Login/Register pages with auth state management
- Dashboard with stats grid, activity chart, top links, device/referrer breakdowns
- Links page with CRUD modals, copy-to-clipboard, analytics link
- Link detail page with per-link analytics
- QR Codes list with preview thumbnails, export buttons
- QR Designer with color pickers, error correction, quiet zone, 6 presets
- Analytics page with time series chart, period selector, breakdowns
**Next up: Phase 6 - Frontend Dashboard** or **Phase 7 - Production Readiness** **Next up:**
- Complete forgot/reset password pages
- Add geo breakdown to analytics
- Logo upload integration in QR designer
- Phase 7 - Production Readiness (CORS, rate limiting, email)
Completed: Completed:
1. ~~Create short link endpoint with auto-slug generation~~ 1. ~~Create short link endpoint with auto-slug generation~~
@@ -277,6 +293,7 @@ Completed:
7. ~~QR code generation and designer~~ 7. ~~QR code generation and designer~~
8. ~~Domain management (add, list, get, delete, verify)~~ 8. ~~Domain management (add, list, get, delete, verify)~~
9. ~~Asset upload for QR logos~~ 9. ~~Asset upload for QR logos~~
10. ~~Frontend dashboard with auth, links, QR, analytics~~
--- ---

View File

@@ -11,6 +11,21 @@ using Microsoft.IdentityModel.Tokens;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// Add cors
if (builder.Environment.IsDevelopment())
{
builder.Services.AddCors(options =>
{
options.AddDefaultPolicy(policy =>
{
policy.SetIsOriginAllowed(origin => new Uri(origin).IsLoopback)
.AllowAnyHeader()
.AllowAnyMethod();
});
});
}
// Add services to the container. // Add services to the container.
builder.Services.AddDbContext<AppDbContext>(options => builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("PostgresConnection"))); options.UseNpgsql(builder.Configuration.GetConnectionString("PostgresConnection")));
@@ -46,15 +61,14 @@ builder.Services.AddOpenApi();
var app = builder.Build(); var app = builder.Build();
app.UseCors();
// Configure the HTTP request pipeline. // Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())
{ {
app.MapOpenApi().CacheOutput(); app.MapOpenApi().CacheOutput();
app.UseSwaggerUI(options => app.UseSwaggerUI(options => { options.SwaggerEndpoint("/openapi/v1.json", "v1"); });
{
options.SwaggerEndpoint("/openapi/v1.json", "v1");
});
} }
app.UseHttpsRedirection(); app.UseHttpsRedirection();
@@ -64,4 +78,4 @@ app.UseAuthorization();
app.UseFastEndpoints(); app.UseFastEndpoints();
app.Run(); app.Run();

View File

@@ -8,7 +8,10 @@
"name": "trakqr-frontend", "name": "trakqr-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"vue": "^3.4.21" "@vueuse/core": "^14.1.0",
"pinia": "^3.0.4",
"vue": "^3.4.21",
"vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
@@ -722,6 +725,11 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true "dev": true
}, },
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="
},
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "5.2.4", "version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@@ -781,6 +789,36 @@
"@vue/shared": "3.5.26" "@vue/shared": "3.5.26"
} }
}, },
"node_modules/@vue/devtools-api": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
"dependencies": {
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/@vue/devtools-kit": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
"dependencies": {
"@vue/devtools-shared": "^7.7.9",
"birpc": "^2.3.0",
"hookable": "^5.5.3",
"mitt": "^3.0.1",
"perfect-debounce": "^1.0.0",
"speakingurl": "^14.0.1",
"superjson": "^2.2.2"
}
},
"node_modules/@vue/devtools-shared": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
"dependencies": {
"rfdc": "^1.4.1"
}
},
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.26", "version": "3.5.26",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.26.tgz",
@@ -826,6 +864,63 @@
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.26.tgz",
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==" "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A=="
}, },
"node_modules/@vueuse/core": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.1.0.tgz",
"integrity": "sha512-rgBinKs07hAYyPF834mDTigH7BtPqvZ3Pryuzt1SD/lg5wEcWqvwzXXYGEDb2/cP0Sj5zSvHl3WkmMELr5kfWw==",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "14.1.0",
"@vueuse/shared": "14.1.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.1.0.tgz",
"integrity": "sha512-7hK4g015rWn2PhKcZ99NyT+ZD9sbwm7SGvp7k+k+rKGWnLjS/oQozoIZzWfCewSUeBmnJkIb+CNr7Zc/EyRnnA==",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.1.0.tgz",
"integrity": "sha512-EcKxtYvn6gx1F8z9J5/rsg3+lTQnvOruQd8fUecW99DCK04BkWD7z5KQ/wTAx+DazyoEE9dJt/zV8OIEQbM6kw==",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/birpc": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/copy-anything": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
"dependencies": {
"is-what": "^5.2.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -899,6 +994,22 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/hookable": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="
},
"node_modules/is-what": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -907,6 +1018,11 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -924,11 +1040,36 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
}, },
"node_modules/pinia": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.5.0",
"vue": "^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -956,6 +1097,11 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.54.0", "version": "4.54.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
@@ -1005,6 +1151,25 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/speakingurl": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/superjson": {
"version": "2.2.6",
"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
"dependencies": {
"copy-anything": "^4"
},
"engines": {
"node": ">=16"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -1083,6 +1248,25 @@
"optional": true "optional": true
} }
} }
},
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/vue-router/node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
} }
} }
} }

View File

@@ -9,7 +9,10 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"vue": "^3.4.21" "@vueuse/core": "^14.1.0",
"pinia": "^3.0.4",
"vue": "^3.4.21",
"vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",

View File

@@ -1,115 +1,3 @@
<template> <template>
<div class="page"> <router-view />
<header class="topbar">
<div class="brand">
<span class="brand-mark">TQ</span>
<span class="brand-name">TrakQR</span>
</div>
<nav class="nav">
<a href="#features">Features</a>
<a href="#analytics">Analytics</a>
<a href="#designer">Designer</a>
</nav>
<button class="cta">Get Started</button>
</header>
<main>
<section class="hero">
<div class="hero-copy">
<p class="eyebrow">QR-first link intelligence</p>
<h1>Design bold QR codes. Track every scan.</h1>
<p class="subhead">
Build branded short links, craft beautiful QR designs, and turn scans into
actionable analytics for your projects.
</p>
<div class="hero-actions">
<button class="cta">Start free</button>
<button class="ghost">View demo</button>
</div>
<div class="hero-metrics">
<div>
<p class="metric">10k+</p>
<p class="label">Events per month on free tier</p>
</div>
<div>
<p class="metric">3</p>
<p class="label">Designer presets included</p>
</div>
</div>
</div>
<div class="hero-panel">
<div class="panel-header">
<div>
<p class="panel-title">Live QR Preview</p>
<p class="panel-sub">Updated from your short link</p>
</div>
<span class="status">Active</span>
</div>
<div class="qr-preview">
<div class="qr-grid"></div>
</div>
<div class="panel-footer">
<div>
<p class="label">https://tq.link/menu</p>
<p class="hint">Scan-ready, export PNG or SVG</p>
</div>
<button class="ghost small">Export</button>
</div>
</div>
</section>
<section id="features" class="feature-grid">
<article class="feature">
<h3>Branded short links</h3>
<p>Custom slugs, expiring links, and password protection built in.</p>
</article>
<article class="feature">
<h3>Designer-grade QR</h3>
<p>Control shapes, colors, quiet zones, and add logos with precision.</p>
</article>
<article class="feature">
<h3>Trustworthy analytics</h3>
<p>See scans, clicks, referrers, and device mix across every project.</p>
</article>
</section>
<section id="analytics" class="split">
<div>
<h2>Analytics that guide your next campaign.</h2>
<p>
Track events by link and by QR design. Spot spikes in real time and
attribute every scan with confidence.
</p>
<ul class="list">
<li>Live timelines with 24h, 7d, 30d filters</li>
<li>Device and geo snapshots for quick decisions</li>
<li>Workspace and project rollups</li>
</ul>
</div>
<div class="chart-card">
<div class="chart-header">
<p class="panel-title">Events last 7 days</p>
<p class="panel-sub">Scans + clicks</p>
</div>
<div class="chart">
<span style="height: 30%"></span>
<span style="height: 45%"></span>
<span style="height: 65%"></span>
<span style="height: 50%"></span>
<span style="height: 80%"></span>
<span style="height: 60%"></span>
<span style="height: 70%"></span>
</div>
</div>
</section>
<section id="designer" class="callout">
<div>
<h2>Launch your first QR in minutes.</h2>
<p>Start with presets or build your own design system.</p>
</div>
<button class="cta">Create a project</button>
</section>
</main>
</div>
</template> </template>

View File

@@ -0,0 +1,214 @@
const API_BASE = 'https://localhost:42001';
class ApiClient {
constructor() {
this.token = localStorage.getItem('token');
}
setToken(token) {
this.token = token;
if (token) {
localStorage.setItem('token', token);
} else {
localStorage.removeItem('token');
}
}
async request(method, path, body = null) {
const headers = {
'Content-Type': 'application/json',
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
const options = { method, headers };
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(`${API_BASE}${path}`, options);
if (response.status === 401) {
this.setToken(null);
window.location.href = '/login';
throw new Error('Unauthorized');
}
const text = await response.text();
const data = text ? JSON.parse(text) : null;
if (!response.ok) {
throw new Error(data?.message || `HTTP ${response.status}`);
}
return data;
}
async upload(path, file) {
const headers = {};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE}${path}`, {
method: 'POST',
headers,
body: formData,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data?.message || `HTTP ${response.status}`);
}
return data;
}
// Auth
register(email, password) {
return this.request('POST', '/auth/register', { email, password });
}
login(email, password) {
return this.request('POST', '/auth/login', { email, password });
}
// Workspaces
listWorkspaces() {
return this.request('GET', '/workspaces');
}
createWorkspace(name) {
return this.request('POST', '/workspaces', { name });
}
getWorkspace(id) {
return this.request('GET', `/workspaces/${id}`);
}
updateWorkspace(id, name) {
return this.request('PUT', `/workspaces/${id}`, { name });
}
deleteWorkspace(id) {
return this.request('DELETE', `/workspaces/${id}`);
}
// Projects
listProjects(workspaceId) {
return this.request('GET', `/workspaces/${workspaceId}/projects`);
}
createProject(workspaceId, name, description = '') {
return this.request('POST', `/workspaces/${workspaceId}/projects`, { name, description });
}
getProject(workspaceId, id) {
return this.request('GET', `/workspaces/${workspaceId}/projects/${id}`);
}
updateProject(workspaceId, id, data) {
return this.request('PUT', `/workspaces/${workspaceId}/projects/${id}`, data);
}
deleteProject(workspaceId, id) {
return this.request('DELETE', `/workspaces/${workspaceId}/projects/${id}`);
}
// Links
listLinks(workspaceId, params = {}) {
const query = new URLSearchParams(params).toString();
const path = `/workspaces/${workspaceId}/links${query ? `?${query}` : ''}`;
return this.request('GET', path);
}
createLink(workspaceId, data) {
return this.request('POST', `/workspaces/${workspaceId}/links`, data);
}
getLink(workspaceId, id) {
return this.request('GET', `/workspaces/${workspaceId}/links/${id}`);
}
updateLink(workspaceId, id, data) {
return this.request('PUT', `/workspaces/${workspaceId}/links/${id}`, data);
}
deleteLink(workspaceId, id) {
return this.request('DELETE', `/workspaces/${workspaceId}/links/${id}`);
}
getLinkAnalytics(workspaceId, linkId, period = '7d') {
return this.request('GET', `/workspaces/${workspaceId}/links/${linkId}/analytics?period=${period}`);
}
// QR Codes
listQRCodes(workspaceId) {
return this.request('GET', `/workspaces/${workspaceId}/qrcodes`);
}
createQRCode(workspaceId, data) {
return this.request('POST', `/workspaces/${workspaceId}/qrcodes`, data);
}
getQRCode(workspaceId, id) {
return this.request('GET', `/workspaces/${workspaceId}/qrcodes/${id}`);
}
updateQRCode(workspaceId, id, data) {
return this.request('PUT', `/workspaces/${workspaceId}/qrcodes/${id}`, data);
}
deleteQRCode(workspaceId, id) {
return this.request('DELETE', `/workspaces/${workspaceId}/qrcodes/${id}`);
}
getQRCodePreview(workspaceId, id) {
return this.request('GET', `/workspaces/${workspaceId}/qrcodes/${id}/preview`);
}
getQRCodeExportUrl(workspaceId, id, format = 'png', size = 512) {
return `${API_BASE}/workspaces/${workspaceId}/qrcodes/${id}/export?format=${format}&size=${size}`;
}
// Analytics
getWorkspaceAnalytics(workspaceId, period = '7d') {
return this.request('GET', `/workspaces/${workspaceId}/analytics?period=${period}`);
}
// Domains
listDomains(workspaceId) {
return this.request('GET', `/workspaces/${workspaceId}/domains`);
}
addDomain(workspaceId, hostname) {
return this.request('POST', `/workspaces/${workspaceId}/domains`, { hostname });
}
deleteDomain(workspaceId, id) {
return this.request('DELETE', `/workspaces/${workspaceId}/domains/${id}`);
}
verifyDomain(workspaceId, id) {
return this.request('POST', `/workspaces/${workspaceId}/domains/${id}/verify`, {});
}
// Assets
listAssets(workspaceId) {
return this.request('GET', `/workspaces/${workspaceId}/assets`);
}
uploadAsset(workspaceId, file) {
return this.upload(`/workspaces/${workspaceId}/assets`, file);
}
deleteAsset(workspaceId, id) {
return this.request('DELETE', `/workspaces/${workspaceId}/assets/${id}`);
}
}
export const api = new ApiClient();

View File

@@ -0,0 +1,230 @@
<template>
<div class="app-layout">
<aside class="sidebar">
<div class="sidebar-header">
<div class="brand">
<span class="brand-mark">TQ</span>
<span class="brand-name">TrakQR</span>
</div>
</div>
<div class="workspace-selector" v-if="workspaceStore.currentWorkspace">
<select
:value="workspaceStore.currentWorkspace?.id"
@change="onWorkspaceChange"
class="workspace-select"
>
<option
v-for="ws in workspaceStore.workspaces"
:key="ws.id"
:value="ws.id"
>
{{ ws.name }}
</option>
</select>
</div>
<nav class="sidebar-nav">
<router-link to="/dashboard" class="nav-item" :class="{ active: $route.name === 'dashboard' }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
Dashboard
</router-link>
<router-link to="/links" class="nav-item" :class="{ active: $route.name === 'links' || $route.name === 'link-detail' }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
Links
</router-link>
<router-link to="/qrcodes" class="nav-item" :class="{ active: $route.name?.startsWith('qrcode') }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="6" height="6"/>
<rect x="15" y="3" width="6" height="6"/>
<rect x="3" y="15" width="6" height="6"/>
<rect x="15" y="15" width="6" height="6"/>
<rect x="9" y="9" width="6" height="6"/>
</svg>
QR Codes
</router-link>
<router-link to="/analytics" class="nav-item" :class="{ active: $route.name === 'analytics' }">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 20V10"/>
<path d="M12 20V4"/>
<path d="M6 20v-6"/>
</svg>
Analytics
</router-link>
</nav>
<div class="sidebar-footer">
<button @click="logout" class="logout-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16,17 21,12 16,7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
Logout
</button>
</div>
</aside>
<main class="main-content">
<slot></slot>
</main>
</div>
</template>
<script setup>
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../../stores/auth';
import { useWorkspaceStore } from '../../stores/workspace';
const router = useRouter();
const authStore = useAuthStore();
const workspaceStore = useWorkspaceStore();
onMounted(async () => {
authStore.checkAuth();
await workspaceStore.fetchWorkspaces();
});
const onWorkspaceChange = (e) => {
const workspace = workspaceStore.workspaces.find(w => w.id === e.target.value);
if (workspace) {
workspaceStore.setCurrentWorkspace(workspace);
}
};
const logout = () => {
authStore.logout();
router.push('/login');
};
</script>
<style scoped>
.app-layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 260px;
background: var(--surface);
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
padding: 24px 16px;
}
.sidebar-header {
margin-bottom: 24px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
font-weight: 600;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 10px;
background: var(--ink);
color: #fff4ec;
font-weight: 700;
font-size: 0.9rem;
}
.brand-name {
font-size: 1.1rem;
}
.workspace-selector {
margin-bottom: 24px;
}
.workspace-select {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--bg);
font-size: 0.9rem;
font-family: inherit;
cursor: pointer;
}
.sidebar-nav {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 10px;
color: var(--muted);
font-weight: 500;
transition: all 0.15s ease;
}
.nav-item:hover {
background: var(--bg);
color: var(--ink);
}
.nav-item.active {
background: var(--accent);
color: white;
}
.sidebar-footer {
margin-top: auto;
padding-top: 16px;
border-top: 1px solid var(--line);
}
.logout-btn {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px 14px;
border: none;
border-radius: 10px;
background: transparent;
color: var(--muted);
font-size: 0.95rem;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
}
.logout-btn:hover {
background: rgba(255, 0, 0, 0.08);
color: #dc2626;
}
.main-content {
flex: 1;
background: var(--bg);
padding: 32px;
overflow-y: auto;
}
</style>

View File

@@ -1,5 +1,13 @@
import { createApp } from "vue"; import { createApp } from 'vue';
import App from "./App.vue"; import { createPinia } from 'pinia';
import "./style.css"; import App from './App.vue';
import router from './router';
import './style.css';
createApp(App).mount("#app"); const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
app.mount('#app');

View File

@@ -0,0 +1,94 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '../stores/auth';
// Views
import Landing from '../views/Landing.vue';
import Login from '../views/auth/Login.vue';
import Register from '../views/auth/Register.vue';
import Dashboard from '../views/dashboard/Dashboard.vue';
import Links from '../views/links/Links.vue';
import LinkDetail from '../views/links/LinkDetail.vue';
import QRCodes from '../views/qrcodes/QRCodes.vue';
import QRCodeDesigner from '../views/qrcodes/QRCodeDesigner.vue';
import Analytics from '../views/analytics/Analytics.vue';
const routes = [
{
path: '/',
name: 'landing',
component: Landing,
},
{
path: '/login',
name: 'login',
component: Login,
meta: { guest: true },
},
{
path: '/register',
name: 'register',
component: Register,
meta: { guest: true },
},
{
path: '/dashboard',
name: 'dashboard',
component: Dashboard,
meta: { requiresAuth: true },
},
{
path: '/links',
name: 'links',
component: Links,
meta: { requiresAuth: true },
},
{
path: '/links/:id',
name: 'link-detail',
component: LinkDetail,
meta: { requiresAuth: true },
},
{
path: '/qrcodes',
name: 'qrcodes',
component: QRCodes,
meta: { requiresAuth: true },
},
{
path: '/qrcodes/new',
name: 'qrcode-new',
component: QRCodeDesigner,
meta: { requiresAuth: true },
},
{
path: '/qrcodes/:id',
name: 'qrcode-edit',
component: QRCodeDesigner,
meta: { requiresAuth: true },
},
{
path: '/analytics',
name: 'analytics',
component: Analytics,
meta: { requiresAuth: true },
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to, from, next) => {
const authStore = useAuthStore();
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
next({ name: 'login', query: { redirect: to.fullPath } });
} else if (to.meta.guest && authStore.isAuthenticated) {
next({ name: 'dashboard' });
} else {
next();
}
});
export default router;

View File

@@ -0,0 +1,65 @@
import { defineStore } from 'pinia';
import { api } from '../api/client';
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
token: localStorage.getItem('token'),
loading: false,
error: null,
}),
getters: {
isAuthenticated: (state) => !!state.token,
},
actions: {
async register(email, password) {
this.loading = true;
this.error = null;
try {
const response = await api.register(email, password);
this.token = response.token;
this.user = { email: response.email };
api.setToken(response.token);
return true;
} catch (err) {
this.error = err.message;
return false;
} finally {
this.loading = false;
}
},
async login(email, password) {
this.loading = true;
this.error = null;
try {
const response = await api.login(email, password);
this.token = response.token;
this.user = { email: response.email };
api.setToken(response.token);
return true;
} catch (err) {
this.error = err.message;
return false;
} finally {
this.loading = false;
}
},
logout() {
this.token = null;
this.user = null;
api.setToken(null);
},
checkAuth() {
if (this.token) {
api.setToken(this.token);
return true;
}
return false;
},
},
});

View File

@@ -0,0 +1,197 @@
import { defineStore } from 'pinia';
import { api } from '../api/client';
export const useWorkspaceStore = defineStore('workspace', {
state: () => ({
workspaces: [],
currentWorkspace: null,
projects: [],
links: [],
qrcodes: [],
analytics: null,
loading: false,
error: null,
}),
getters: {
currentWorkspaceId: (state) => state.currentWorkspace?.id,
},
actions: {
async fetchWorkspaces() {
this.loading = true;
try {
const response = await api.listWorkspaces();
this.workspaces = response.workspaces;
if (!this.currentWorkspace && this.workspaces.length > 0) {
this.currentWorkspace = this.workspaces[0];
}
} catch (err) {
this.error = err.message;
} finally {
this.loading = false;
}
},
setCurrentWorkspace(workspace) {
this.currentWorkspace = workspace;
this.projects = [];
this.links = [];
this.qrcodes = [];
this.analytics = null;
},
async createWorkspace(name) {
try {
const workspace = await api.createWorkspace(name);
this.workspaces.push(workspace);
return workspace;
} catch (err) {
this.error = err.message;
throw err;
}
},
// Projects
async fetchProjects() {
if (!this.currentWorkspaceId) return;
try {
const response = await api.listProjects(this.currentWorkspaceId);
this.projects = response.projects;
} catch (err) {
this.error = err.message;
}
},
async createProject(name, description = '') {
if (!this.currentWorkspaceId) return;
try {
const project = await api.createProject(this.currentWorkspaceId, name, description);
this.projects.push(project);
return project;
} catch (err) {
this.error = err.message;
throw err;
}
},
async deleteProject(id) {
if (!this.currentWorkspaceId) return;
try {
await api.deleteProject(this.currentWorkspaceId, id);
this.projects = this.projects.filter(p => p.id !== id);
} catch (err) {
this.error = err.message;
throw err;
}
},
// Links
async fetchLinks(params = {}) {
if (!this.currentWorkspaceId) return;
try {
const response = await api.listLinks(this.currentWorkspaceId, params);
this.links = response.links;
} catch (err) {
this.error = err.message;
}
},
async createLink(data) {
if (!this.currentWorkspaceId) return;
try {
const link = await api.createLink(this.currentWorkspaceId, data);
this.links.unshift(link);
return link;
} catch (err) {
this.error = err.message;
throw err;
}
},
async updateLink(id, data) {
if (!this.currentWorkspaceId) return;
try {
const link = await api.updateLink(this.currentWorkspaceId, id, data);
const index = this.links.findIndex(l => l.id === id);
if (index !== -1) {
this.links[index] = link;
}
return link;
} catch (err) {
this.error = err.message;
throw err;
}
},
async deleteLink(id) {
if (!this.currentWorkspaceId) return;
try {
await api.deleteLink(this.currentWorkspaceId, id);
this.links = this.links.filter(l => l.id !== id);
} catch (err) {
this.error = err.message;
throw err;
}
},
// QR Codes
async fetchQRCodes() {
if (!this.currentWorkspaceId) return;
try {
const response = await api.listQRCodes(this.currentWorkspaceId);
this.qrcodes = response.qrCodes;
} catch (err) {
this.error = err.message;
}
},
async createQRCode(data) {
if (!this.currentWorkspaceId) return;
try {
const qrcode = await api.createQRCode(this.currentWorkspaceId, data);
this.qrcodes.unshift(qrcode);
return qrcode;
} catch (err) {
this.error = err.message;
throw err;
}
},
async updateQRCode(id, data) {
if (!this.currentWorkspaceId) return;
try {
const qrcode = await api.updateQRCode(this.currentWorkspaceId, id, data);
const index = this.qrcodes.findIndex(q => q.id === id);
if (index !== -1) {
this.qrcodes[index] = qrcode;
}
return qrcode;
} catch (err) {
this.error = err.message;
throw err;
}
},
async deleteQRCode(id) {
if (!this.currentWorkspaceId) return;
try {
await api.deleteQRCode(this.currentWorkspaceId, id);
this.qrcodes = this.qrcodes.filter(q => q.id !== id);
} catch (err) {
this.error = err.message;
throw err;
}
},
// Analytics
async fetchAnalytics(period = '7d') {
if (!this.currentWorkspaceId) return;
try {
this.analytics = await api.getWorkspaceAnalytics(this.currentWorkspaceId, period);
} catch (err) {
this.error = err.message;
}
},
},
});

View File

@@ -0,0 +1,126 @@
<template>
<div class="page">
<header class="topbar">
<div class="brand">
<span class="brand-mark">TQ</span>
<span class="brand-name">TrakQR</span>
</div>
<nav class="nav">
<a href="#features">Features</a>
<a href="#analytics">Analytics</a>
<a href="#designer">Designer</a>
</nav>
<div class="auth-buttons">
<router-link to="/login" class="ghost">Sign in</router-link>
<router-link to="/register" class="cta">Get Started</router-link>
</div>
</header>
<main>
<section class="hero">
<div class="hero-copy">
<p class="eyebrow">QR-first link intelligence</p>
<h1>Design bold QR codes. Track every scan.</h1>
<p class="subhead">
Build branded short links, craft beautiful QR designs, and turn scans into
actionable analytics for your projects.
</p>
<div class="hero-actions">
<router-link to="/register" class="cta">Start free</router-link>
<button class="ghost">View demo</button>
</div>
<div class="hero-metrics">
<div>
<p class="metric">10k+</p>
<p class="label">Events per month on free tier</p>
</div>
<div>
<p class="metric">3</p>
<p class="label">Designer presets included</p>
</div>
</div>
</div>
<div class="hero-panel">
<div class="panel-header">
<div>
<p class="panel-title">Live QR Preview</p>
<p class="panel-sub">Updated from your short link</p>
</div>
<span class="status">Active</span>
</div>
<div class="qr-preview">
<div class="qr-grid"></div>
</div>
<div class="panel-footer">
<div>
<p class="label">https://tq.link/menu</p>
<p class="hint">Scan-ready, export PNG or SVG</p>
</div>
<button class="ghost small">Export</button>
</div>
</div>
</section>
<section id="features" class="feature-grid">
<article class="feature">
<h3>Branded short links</h3>
<p>Custom slugs, expiring links, and password protection built in.</p>
</article>
<article class="feature">
<h3>Designer-grade QR</h3>
<p>Control shapes, colors, quiet zones, and add logos with precision.</p>
</article>
<article class="feature">
<h3>Trustworthy analytics</h3>
<p>See scans, clicks, referrers, and device mix across every project.</p>
</article>
</section>
<section id="analytics" class="split">
<div>
<h2>Analytics that guide your next campaign.</h2>
<p>
Track events by link and by QR design. Spot spikes in real time and
attribute every scan with confidence.
</p>
<ul class="list">
<li>Live timelines with 24h, 7d, 30d filters</li>
<li>Device and geo snapshots for quick decisions</li>
<li>Workspace and project rollups</li>
</ul>
</div>
<div class="chart-card">
<div class="chart-header">
<p class="panel-title">Events last 7 days</p>
<p class="panel-sub">Scans + clicks</p>
</div>
<div class="chart">
<span style="height: 30%"></span>
<span style="height: 45%"></span>
<span style="height: 65%"></span>
<span style="height: 50%"></span>
<span style="height: 80%"></span>
<span style="height: 60%"></span>
<span style="height: 70%"></span>
</div>
</div>
</section>
<section id="designer" class="callout">
<div>
<h2>Launch your first QR in minutes.</h2>
<p>Start with presets or build your own design system.</p>
</div>
<router-link to="/register" class="cta">Create a project</router-link>
</section>
</main>
</div>
</template>
<style scoped>
.auth-buttons {
display: flex;
gap: 12px;
align-items: center;
}
</style>

View File

@@ -0,0 +1,552 @@
<template>
<AppLayout>
<div class="analytics-page">
<header class="page-header">
<div>
<h1>Analytics</h1>
<p class="subtitle">Track performance across your workspace</p>
</div>
<div class="period-selector">
<button
v-for="p in periods"
:key="p.value"
:class="{ active: period === p.value }"
@click="setPeriod(p.value)"
>
{{ p.label }}
</button>
</div>
</header>
<div class="stats-grid" v-if="analytics">
<div class="stat-card">
<div class="stat-icon clicks">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 15l-2 5L9 9l11 4-5 2z"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-value">{{ analytics.summary.totalClicks }}</p>
<p class="stat-label">Total Clicks</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon scans">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="6" height="6"/>
<rect x="15" y="3" width="6" height="6"/>
<rect x="3" y="15" width="6" height="6"/>
<rect x="15" y="15" width="6" height="6"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-value">{{ analytics.summary.totalScans }}</p>
<p class="stat-label">Total Scans</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon visitors">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-value">{{ analytics.summary.uniqueVisitors }}</p>
<p class="stat-label">Unique Visitors</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon total">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-value">{{ analytics.summary.totalClicks + analytics.summary.totalScans }}</p>
<p class="stat-label">Total Events</p>
</div>
</div>
</div>
<div class="analytics-grid">
<section class="card chart-card">
<div class="card-header">
<h2>Event Timeline</h2>
</div>
<div class="chart-container" v-if="analytics?.timeSeries?.length">
<div class="chart">
<div
v-for="(point, i) in analytics.timeSeries"
:key="i"
class="chart-bar-group"
>
<div
class="chart-bar clicks"
:style="{ height: getBarHeight(point.clicks) + '%' }"
:title="`${point.date}: ${point.clicks} clicks`"
></div>
<div
class="chart-bar scans"
:style="{ height: getBarHeight(point.scans) + '%' }"
:title="`${point.date}: ${point.scans} scans`"
></div>
</div>
</div>
<div class="chart-legend">
<span class="legend-item clicks">Clicks</span>
<span class="legend-item scans">Scans</span>
</div>
</div>
<div v-else class="empty-state">
<p>No activity data yet</p>
</div>
</section>
<section class="card">
<div class="card-header">
<h2>Top Links</h2>
</div>
<div class="top-links" v-if="analytics?.topLinks?.length">
<div v-for="(link, index) in analytics.topLinks" :key="link.linkId" class="top-link">
<span class="rank">{{ index + 1 }}</span>
<div class="link-info">
<p class="link-title">{{ link.title || link.slug }}</p>
<p class="link-slug">/{{ link.slug }}</p>
</div>
<div class="link-stats">
<span class="clicks">{{ link.clicks }} clicks</span>
</div>
</div>
</div>
<div v-else class="empty-state">
<p>No link data yet</p>
</div>
</section>
<section class="card">
<div class="card-header">
<h2>Devices</h2>
</div>
<div class="breakdown" v-if="analytics?.deviceBreakdown?.length">
<div v-for="device in analytics.deviceBreakdown" :key="device.device" class="breakdown-item">
<div class="breakdown-bar" :style="{ width: getDevicePercentage(device.count) + '%' }"></div>
<div class="breakdown-icon">
<svg v-if="device.device === 'Mobile'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="5" y="2" width="14" height="20" rx="2" ry="2"/>
<line x1="12" y1="18" x2="12.01" y2="18"/>
</svg>
<svg v-else-if="device.device === 'Desktop'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<svg v-else-if="device.device === 'Tablet'" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="2" width="16" height="20" rx="2" ry="2"/>
<line x1="12" y1="18" x2="12.01" y2="18"/>
</svg>
<svg v-else width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="2" y1="12" x2="22" y2="12"/>
</svg>
</div>
<span class="breakdown-label">{{ device.device }}</span>
<span class="breakdown-value">{{ device.count }}</span>
<span class="breakdown-percent">{{ getDevicePercentage(device.count).toFixed(0) }}%</span>
</div>
</div>
<div v-else class="empty-state">
<p>No device data yet</p>
</div>
</section>
<section class="card">
<div class="card-header">
<h2>Referrers</h2>
</div>
<div class="breakdown" v-if="analytics?.referrerBreakdown?.length">
<div v-for="ref in analytics.referrerBreakdown" :key="ref.referrer" class="breakdown-item">
<div class="breakdown-bar" :style="{ width: getReferrerPercentage(ref.count) + '%' }"></div>
<span class="breakdown-label">{{ ref.referrer || 'Direct' }}</span>
<span class="breakdown-value">{{ ref.count }}</span>
<span class="breakdown-percent">{{ getReferrerPercentage(ref.count).toFixed(0) }}%</span>
</div>
</div>
<div v-else class="empty-state">
<p>No referrer data yet</p>
</div>
</section>
</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import AppLayout from '../../components/layout/AppLayout.vue';
import { useWorkspaceStore } from '../../stores/workspace';
const workspaceStore = useWorkspaceStore();
const periods = [
{ label: '24h', value: '24h' },
{ label: '7d', value: '7d' },
{ label: '30d', value: '30d' },
];
const period = ref('7d');
const analytics = computed(() => workspaceStore.analytics);
const setPeriod = async (p) => {
period.value = p;
await workspaceStore.fetchAnalytics(p);
};
const maxEvents = computed(() => {
if (!analytics.value?.timeSeries) return 1;
return Math.max(...analytics.value.timeSeries.map(p => Math.max(p.clicks, p.scans)), 1);
});
const totalDeviceEvents = computed(() => {
if (!analytics.value?.deviceBreakdown) return 1;
return analytics.value.deviceBreakdown.reduce((sum, d) => sum + d.count, 0) || 1;
});
const totalReferrerEvents = computed(() => {
if (!analytics.value?.referrerBreakdown) return 1;
return analytics.value.referrerBreakdown.reduce((sum, r) => sum + r.count, 0) || 1;
});
const getBarHeight = (value) => {
return Math.max((value / maxEvents.value) * 100, 4);
};
const getDevicePercentage = (value) => {
return (value / totalDeviceEvents.value) * 100;
};
const getReferrerPercentage = (value) => {
return (value / totalReferrerEvents.value) * 100;
};
onMounted(async () => {
await workspaceStore.fetchAnalytics(period.value);
});
watch(() => workspaceStore.currentWorkspaceId, async () => {
if (workspaceStore.currentWorkspaceId) {
await workspaceStore.fetchAnalytics(period.value);
}
});
</script>
<style scoped>
.analytics-page {
max-width: 1200px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32px;
}
.page-header h1 {
font-size: 1.75rem;
margin-bottom: 4px;
}
.subtitle {
color: var(--muted);
}
.period-selector {
display: flex;
gap: 4px;
background: var(--surface);
padding: 4px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.period-selector button {
padding: 10px 20px;
border: none;
background: transparent;
border-radius: 10px;
font-size: 0.9rem;
font-weight: 500;
color: var(--muted);
cursor: pointer;
transition: all 0.15s ease;
}
.period-selector button.active {
background: var(--accent);
color: white;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 24px;
background: var(--surface);
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.stat-icon {
width: 52px;
height: 52px;
border-radius: 14px;
display: flex;
align-items: center;
justify-content: center;
}
.stat-icon.clicks { background: #fff0e6; color: var(--accent); }
.stat-icon.scans { background: #e6f0ff; color: #3b82f6; }
.stat-icon.visitors { background: #e6fff0; color: #22c55e; }
.stat-icon.total { background: #f0e6ff; color: #8b5cf6; }
.stat-value {
font-size: 1.75rem;
font-weight: 700;
}
.stat-label {
color: var(--muted);
font-size: 0.9rem;
}
.analytics-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
}
.card {
background: var(--surface);
border-radius: 20px;
padding: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.card-header {
margin-bottom: 20px;
}
.card-header h2 {
font-size: 1.1rem;
}
.chart-card {
grid-column: span 2;
}
.chart-container {
height: 280px;
}
.chart {
display: flex;
align-items: flex-end;
gap: 12px;
height: calc(100% - 40px);
}
.chart-bar-group {
flex: 1;
display: flex;
gap: 4px;
align-items: flex-end;
height: 100%;
}
.chart-bar {
flex: 1;
border-radius: 6px 6px 0 0;
min-height: 8px;
transition: height 0.3s ease;
}
.chart-bar.clicks {
background: var(--accent);
}
.chart-bar.scans {
background: #3b82f6;
}
.chart-legend {
display: flex;
justify-content: center;
gap: 24px;
margin-top: 16px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: var(--muted);
}
.legend-item::before {
content: '';
width: 12px;
height: 12px;
border-radius: 3px;
}
.legend-item.clicks::before {
background: var(--accent);
}
.legend-item.scans::before {
background: #3b82f6;
}
.top-links {
display: flex;
flex-direction: column;
gap: 12px;
}
.top-link {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: var(--bg);
border-radius: 14px;
}
.rank {
width: 28px;
height: 28px;
border-radius: 8px;
background: var(--accent);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 0.85rem;
}
.link-info {
flex: 1;
}
.link-title {
font-weight: 600;
margin-bottom: 2px;
}
.link-slug {
font-size: 0.85rem;
color: var(--muted);
}
.link-stats .clicks {
font-weight: 600;
color: var(--accent);
}
.breakdown {
display: flex;
flex-direction: column;
gap: 12px;
}
.breakdown-item {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: var(--bg);
border-radius: 12px;
overflow: hidden;
}
.breakdown-bar {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: rgba(255, 106, 61, 0.12);
border-radius: 12px;
}
.breakdown-icon {
position: relative;
color: var(--muted);
}
.breakdown-label {
position: relative;
flex: 1;
font-weight: 500;
}
.breakdown-value {
position: relative;
color: var(--muted);
font-weight: 500;
}
.breakdown-percent {
position: relative;
font-size: 0.85rem;
color: var(--muted);
min-width: 40px;
text-align: right;
}
.empty-state {
text-align: center;
padding: 40px;
color: var(--muted);
}
@media (max-width: 900px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.analytics-grid {
grid-template-columns: 1fr;
}
.chart-card {
grid-column: span 1;
}
}
@media (max-width: 600px) {
.page-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.stats-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,191 @@
<template>
<div class="auth-page">
<div class="auth-card">
<div class="auth-header">
<div class="brand">
<span class="brand-mark">TQ</span>
<span class="brand-name">TrakQR</span>
</div>
<h1>Welcome back</h1>
<p>Sign in to your account</p>
</div>
<form @submit.prevent="handleSubmit" class="auth-form">
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
v-model="email"
type="email"
placeholder="you@example.com"
required
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
placeholder="Your password"
required
/>
</div>
<div v-if="authStore.error" class="error-message">
{{ authStore.error }}
</div>
<button type="submit" class="cta full" :disabled="authStore.loading">
{{ authStore.loading ? 'Signing in...' : 'Sign in' }}
</button>
</form>
<p class="auth-footer">
Don't have an account?
<router-link to="/register">Create one</router-link>
</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useAuthStore } from '../../stores/auth';
const router = useRouter();
const route = useRoute();
const authStore = useAuthStore();
const email = ref('');
const password = ref('');
const handleSubmit = async () => {
const success = await authStore.login(email.value, password.value);
if (success) {
const redirect = route.query.redirect || '/dashboard';
router.push(redirect);
}
};
</script>
<style scoped>
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--surface);
border-radius: 24px;
padding: 40px;
box-shadow: var(--shadow);
}
.auth-header {
text-align: center;
margin-bottom: 32px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--ink);
color: #fff4ec;
font-weight: 700;
}
.brand-name {
font-size: 1.2rem;
font-weight: 600;
}
.auth-header h1 {
font-size: 1.5rem;
margin-bottom: 8px;
}
.auth-header p {
color: var(--muted);
}
.auth-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 500;
font-size: 0.9rem;
}
.form-group input {
padding: 12px 16px;
border: 1px solid var(--line);
border-radius: 12px;
font-size: 1rem;
font-family: inherit;
transition: border-color 0.15s ease;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
.error-message {
padding: 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 10px;
color: #dc2626;
font-size: 0.9rem;
}
.cta.full {
width: 100%;
padding: 14px;
font-size: 1rem;
}
.cta:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-footer {
text-align: center;
margin-top: 24px;
color: var(--muted);
}
.auth-footer a {
color: var(--accent);
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,213 @@
<template>
<div class="auth-page">
<div class="auth-card">
<div class="auth-header">
<div class="brand">
<span class="brand-mark">TQ</span>
<span class="brand-name">TrakQR</span>
</div>
<h1>Create your account</h1>
<p>Start building QR-powered links</p>
</div>
<form @submit.prevent="handleSubmit" class="auth-form">
<div class="form-group">
<label for="email">Email</label>
<input
id="email"
v-model="email"
type="email"
placeholder="you@example.com"
required
/>
</div>
<div class="form-group">
<label for="password">Password</label>
<input
id="password"
v-model="password"
type="password"
placeholder="At least 8 characters"
minlength="8"
required
/>
</div>
<div class="form-group">
<label for="confirmPassword">Confirm Password</label>
<input
id="confirmPassword"
v-model="confirmPassword"
type="password"
placeholder="Repeat your password"
required
/>
</div>
<div v-if="error" class="error-message">
{{ error }}
</div>
<button type="submit" class="cta full" :disabled="authStore.loading">
{{ authStore.loading ? 'Creating account...' : 'Create account' }}
</button>
</form>
<p class="auth-footer">
Already have an account?
<router-link to="/login">Sign in</router-link>
</p>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../../stores/auth';
const router = useRouter();
const authStore = useAuthStore();
const email = ref('');
const password = ref('');
const confirmPassword = ref('');
const error = computed(() => {
if (password.value && confirmPassword.value && password.value !== confirmPassword.value) {
return 'Passwords do not match';
}
return authStore.error;
});
const handleSubmit = async () => {
if (password.value !== confirmPassword.value) {
return;
}
const success = await authStore.register(email.value, password.value);
if (success) {
router.push('/dashboard');
}
};
</script>
<style scoped>
.auth-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-card {
width: 100%;
max-width: 400px;
background: var(--surface);
border-radius: 24px;
padding: 40px;
box-shadow: var(--shadow);
}
.auth-header {
text-align: center;
margin-bottom: 32px;
}
.brand {
display: inline-flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
}
.brand-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 12px;
background: var(--ink);
color: #fff4ec;
font-weight: 700;
}
.brand-name {
font-size: 1.2rem;
font-weight: 600;
}
.auth-header h1 {
font-size: 1.5rem;
margin-bottom: 8px;
}
.auth-header p {
color: var(--muted);
}
.auth-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 500;
font-size: 0.9rem;
}
.form-group input {
padding: 12px 16px;
border: 1px solid var(--line);
border-radius: 12px;
font-size: 1rem;
font-family: inherit;
transition: border-color 0.15s ease;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
.error-message {
padding: 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 10px;
color: #dc2626;
font-size: 0.9rem;
}
.cta.full {
width: 100%;
padding: 14px;
font-size: 1rem;
}
.cta:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.auth-footer {
text-align: center;
margin-top: 24px;
color: var(--muted);
}
.auth-footer a {
color: var(--accent);
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,438 @@
<template>
<AppLayout>
<div class="dashboard">
<header class="page-header">
<h1>Dashboard</h1>
<p class="subtitle">Overview of your workspace activity</p>
</header>
<div class="stats-grid" v-if="analytics">
<div class="stat-card">
<div class="stat-icon clicks">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 15l-2 5L9 9l11 4-5 2z"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-value">{{ analytics.summary.totalClicks }}</p>
<p class="stat-label">Total Clicks</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon scans">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="6" height="6"/>
<rect x="15" y="3" width="6" height="6"/>
<rect x="3" y="15" width="6" height="6"/>
<rect x="15" y="15" width="6" height="6"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-value">{{ analytics.summary.totalScans }}</p>
<p class="stat-label">Total Scans</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon visitors">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-value">{{ analytics.summary.uniqueVisitors }}</p>
<p class="stat-label">Unique Visitors</p>
</div>
</div>
<div class="stat-card">
<div class="stat-icon links">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
</div>
<div class="stat-content">
<p class="stat-value">{{ workspaceStore.links.length }}</p>
<p class="stat-label">Active Links</p>
</div>
</div>
</div>
<div class="dashboard-grid">
<section class="card chart-card">
<div class="card-header">
<h2>Activity</h2>
<div class="period-selector">
<button
v-for="p in periods"
:key="p.value"
:class="{ active: period === p.value }"
@click="setPeriod(p.value)"
>
{{ p.label }}
</button>
</div>
</div>
<div class="chart-container" v-if="analytics?.timeSeries?.length">
<div class="chart">
<div
v-for="(point, i) in analytics.timeSeries"
:key="i"
class="chart-bar"
:style="{ height: getBarHeight(point.clicks + point.scans) + '%' }"
:title="`${point.date}: ${point.clicks + point.scans} events`"
></div>
</div>
</div>
<div v-else class="empty-state">
<p>No activity data yet</p>
</div>
</section>
<section class="card">
<div class="card-header">
<h2>Top Links</h2>
<router-link to="/links" class="view-all">View all</router-link>
</div>
<div class="top-links" v-if="analytics?.topLinks?.length">
<div v-for="link in analytics.topLinks.slice(0, 5)" :key="link.linkId" class="top-link">
<div class="link-info">
<p class="link-title">{{ link.title || link.slug }}</p>
<p class="link-slug">/{{ link.slug }}</p>
</div>
<span class="link-clicks">{{ link.clicks }} clicks</span>
</div>
</div>
<div v-else class="empty-state">
<p>No links yet</p>
<router-link to="/links" class="cta small">Create your first link</router-link>
</div>
</section>
<section class="card">
<div class="card-header">
<h2>Devices</h2>
</div>
<div class="breakdown" v-if="analytics?.deviceBreakdown?.length">
<div v-for="device in analytics.deviceBreakdown" :key="device.device" class="breakdown-item">
<div class="breakdown-bar" :style="{ width: getPercentage(device.count) + '%' }"></div>
<span class="breakdown-label">{{ device.device }}</span>
<span class="breakdown-value">{{ device.count }}</span>
</div>
</div>
<div v-else class="empty-state">
<p>No device data yet</p>
</div>
</section>
<section class="card">
<div class="card-header">
<h2>Top Referrers</h2>
</div>
<div class="breakdown" v-if="analytics?.referrerBreakdown?.length">
<div v-for="ref in analytics.referrerBreakdown.slice(0, 5)" :key="ref.referrer" class="breakdown-item">
<div class="breakdown-bar" :style="{ width: getPercentage(ref.count) + '%' }"></div>
<span class="breakdown-label">{{ ref.referrer || 'Direct' }}</span>
<span class="breakdown-value">{{ ref.count }}</span>
</div>
</div>
<div v-else class="empty-state">
<p>No referrer data yet</p>
</div>
</section>
</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import AppLayout from '../../components/layout/AppLayout.vue';
import { useWorkspaceStore } from '../../stores/workspace';
const workspaceStore = useWorkspaceStore();
const periods = [
{ label: '24h', value: '24h' },
{ label: '7d', value: '7d' },
{ label: '30d', value: '30d' },
];
const period = ref('7d');
const analytics = computed(() => workspaceStore.analytics);
const setPeriod = async (p) => {
period.value = p;
await workspaceStore.fetchAnalytics(p);
};
const maxEvents = computed(() => {
if (!analytics.value?.timeSeries) return 1;
return Math.max(...analytics.value.timeSeries.map(p => p.clicks + p.scans), 1);
});
const totalEvents = computed(() => {
if (!analytics.value?.timeSeries) return 1;
return analytics.value.timeSeries.reduce((sum, p) => sum + p.clicks + p.scans, 0) || 1;
});
const getBarHeight = (value) => {
return Math.max((value / maxEvents.value) * 100, 4);
};
const getPercentage = (value) => {
return Math.max((value / totalEvents.value) * 100, 5);
};
onMounted(async () => {
await workspaceStore.fetchLinks();
await workspaceStore.fetchAnalytics(period.value);
});
watch(() => workspaceStore.currentWorkspaceId, async () => {
if (workspaceStore.currentWorkspaceId) {
await workspaceStore.fetchLinks();
await workspaceStore.fetchAnalytics(period.value);
}
});
</script>
<style scoped>
.dashboard {
max-width: 1200px;
}
.page-header {
margin-bottom: 32px;
}
.page-header h1 {
font-size: 1.75rem;
margin-bottom: 4px;
}
.subtitle {
color: var(--muted);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: var(--surface);
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.stat-icon.clicks { background: #fff0e6; color: var(--accent); }
.stat-icon.scans { background: #e6f0ff; color: #3b82f6; }
.stat-icon.visitors { background: #e6fff0; color: #22c55e; }
.stat-icon.links { background: #f0e6ff; color: #8b5cf6; }
.stat-value {
font-size: 1.5rem;
font-weight: 700;
}
.stat-label {
color: var(--muted);
font-size: 0.85rem;
}
.dashboard-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
}
.card {
background: var(--surface);
border-radius: 20px;
padding: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.card-header h2 {
font-size: 1.1rem;
}
.view-all {
color: var(--accent);
font-size: 0.9rem;
font-weight: 500;
}
.period-selector {
display: flex;
gap: 4px;
background: var(--bg);
padding: 4px;
border-radius: 10px;
}
.period-selector button {
padding: 6px 12px;
border: none;
background: transparent;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
color: var(--muted);
cursor: pointer;
transition: all 0.15s ease;
}
.period-selector button.active {
background: var(--surface);
color: var(--ink);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.chart-card {
grid-column: span 2;
}
.chart-container {
height: 200px;
padding-top: 16px;
}
.chart {
display: flex;
align-items: flex-end;
gap: 8px;
height: 100%;
}
.chart-bar {
flex: 1;
background: var(--accent);
border-radius: 6px 6px 0 0;
min-height: 8px;
transition: height 0.3s ease;
}
.chart-bar:hover {
opacity: 0.8;
}
.top-links {
display: flex;
flex-direction: column;
gap: 12px;
}
.top-link {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
background: var(--bg);
border-radius: 12px;
}
.link-title {
font-weight: 500;
margin-bottom: 2px;
}
.link-slug {
font-size: 0.85rem;
color: var(--muted);
}
.link-clicks {
font-weight: 600;
color: var(--accent);
}
.breakdown {
display: flex;
flex-direction: column;
gap: 12px;
}
.breakdown-item {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
background: var(--bg);
border-radius: 10px;
overflow: hidden;
}
.breakdown-bar {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: rgba(255, 106, 61, 0.15);
border-radius: 10px;
}
.breakdown-label {
position: relative;
font-weight: 500;
}
.breakdown-value {
position: relative;
color: var(--muted);
}
.empty-state {
text-align: center;
padding: 32px;
color: var(--muted);
}
.empty-state .cta {
margin-top: 16px;
}
.cta.small {
padding: 10px 16px;
font-size: 0.9rem;
}
@media (max-width: 900px) {
.dashboard-grid {
grid-template-columns: 1fr;
}
.chart-card {
grid-column: span 1;
}
}
</style>

View File

@@ -0,0 +1,462 @@
<template>
<AppLayout>
<div class="link-detail" v-if="link">
<header class="page-header">
<div>
<router-link to="/links" class="back-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5"/>
<path d="M12 19l-7-7 7-7"/>
</svg>
Back to Links
</router-link>
<h1>{{ link.title || link.slug }}</h1>
<div class="link-meta">
<span class="short-url" @click="copyToClipboard">
http://localhost:42001/{{ link.slug }}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</span>
<span :class="['status-badge', link.status.toLowerCase()]">{{ link.status }}</span>
</div>
</div>
</header>
<div class="stats-grid">
<div class="stat-card">
<p class="stat-value">{{ analytics?.summary?.totalClicks || 0 }}</p>
<p class="stat-label">Total Clicks</p>
</div>
<div class="stat-card">
<p class="stat-value">{{ analytics?.summary?.uniqueVisitors || 0 }}</p>
<p class="stat-label">Unique Visitors</p>
</div>
<div class="stat-card">
<p class="stat-value">{{ formatDate(link.createdAt) }}</p>
<p class="stat-label">Created</p>
</div>
</div>
<div class="analytics-grid">
<section class="card chart-card">
<div class="card-header">
<h2>Click Activity</h2>
<div class="period-selector">
<button
v-for="p in periods"
:key="p.value"
:class="{ active: period === p.value }"
@click="setPeriod(p.value)"
>
{{ p.label }}
</button>
</div>
</div>
<div class="chart-container" v-if="analytics?.timeSeries?.length">
<div class="chart">
<div
v-for="(point, i) in analytics.timeSeries"
:key="i"
class="chart-bar"
:style="{ height: getBarHeight(point.clicks) + '%' }"
:title="`${point.date}: ${point.clicks} clicks`"
></div>
</div>
</div>
<div v-else class="empty-state">
<p>No click data yet</p>
</div>
</section>
<section class="card">
<h2>Devices</h2>
<div class="breakdown" v-if="analytics?.deviceBreakdown?.length">
<div v-for="device in analytics.deviceBreakdown" :key="device.device" class="breakdown-item">
<div class="breakdown-bar" :style="{ width: getPercentage(device.count) + '%' }"></div>
<span class="breakdown-label">{{ device.device }}</span>
<span class="breakdown-value">{{ device.count }}</span>
</div>
</div>
<div v-else class="empty-state">
<p>No device data yet</p>
</div>
</section>
<section class="card">
<h2>Referrers</h2>
<div class="breakdown" v-if="analytics?.referrerBreakdown?.length">
<div v-for="ref in analytics.referrerBreakdown" :key="ref.referrer" class="breakdown-item">
<div class="breakdown-bar" :style="{ width: getPercentage(ref.count) + '%' }"></div>
<span class="breakdown-label">{{ ref.referrer || 'Direct' }}</span>
<span class="breakdown-value">{{ ref.count }}</span>
</div>
</div>
<div v-else class="empty-state">
<p>No referrer data yet</p>
</div>
</section>
</div>
<section class="card link-info-card">
<h2>Link Details</h2>
<div class="info-grid">
<div class="info-item">
<span class="info-label">Destination</span>
<a :href="link.destinationUrl" target="_blank" class="info-value link">
{{ link.destinationUrl }}
</a>
</div>
<div class="info-item" v-if="link.expiresAt">
<span class="info-label">Expires</span>
<span class="info-value">{{ formatDate(link.expiresAt) }}</span>
</div>
<div class="info-item">
<span class="info-label">Password Protected</span>
<span class="info-value">{{ link.hasPassword ? 'Yes' : 'No' }}</span>
</div>
</div>
</section>
</div>
<div v-else class="loading">
Loading...
</div>
</AppLayout>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import AppLayout from '../../components/layout/AppLayout.vue';
import { useWorkspaceStore } from '../../stores/workspace';
import { api } from '../../api/client';
const route = useRoute();
const workspaceStore = useWorkspaceStore();
const link = ref(null);
const analytics = ref(null);
const period = ref('7d');
const periods = [
{ label: '24h', value: '24h' },
{ label: '7d', value: '7d' },
{ label: '30d', value: '30d' },
];
const fetchData = async () => {
const linkId = route.params.id;
const workspaceId = workspaceStore.currentWorkspaceId;
if (!workspaceId) return;
try {
link.value = await api.getLink(workspaceId, linkId);
await fetchAnalytics();
} catch (err) {
console.error('Failed to fetch link:', err);
}
};
const fetchAnalytics = async () => {
const linkId = route.params.id;
const workspaceId = workspaceStore.currentWorkspaceId;
if (!workspaceId) return;
try {
analytics.value = await api.getLinkAnalytics(workspaceId, linkId, period.value);
} catch (err) {
console.error('Failed to fetch analytics:', err);
}
};
const setPeriod = async (p) => {
period.value = p;
await fetchAnalytics();
};
const maxClicks = computed(() => {
if (!analytics.value?.timeSeries) return 1;
return Math.max(...analytics.value.timeSeries.map(p => p.clicks), 1);
});
const totalClicks = computed(() => {
return analytics.value?.summary?.totalClicks || 1;
});
const getBarHeight = (value) => {
return Math.max((value / maxClicks.value) * 100, 4);
};
const getPercentage = (value) => {
return Math.max((value / totalClicks.value) * 100, 5);
};
const formatDate = (dateStr) => {
return new Date(dateStr).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(`http://localhost:42001/${link.value.slug}`);
} catch (err) {
console.error('Failed to copy:', err);
}
};
onMounted(async () => {
await fetchData();
});
</script>
<style scoped>
.link-detail {
max-width: 1000px;
}
.page-header {
margin-bottom: 32px;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 0.9rem;
margin-bottom: 12px;
}
.back-link:hover {
color: var(--ink);
}
.page-header h1 {
font-size: 1.75rem;
margin-bottom: 8px;
}
.link-meta {
display: flex;
align-items: center;
gap: 12px;
}
.short-url {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--accent);
font-weight: 500;
cursor: pointer;
}
.status-badge {
padding: 4px 10px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.active {
background: #dcfce7;
color: #16a34a;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
background: var(--surface);
border-radius: 16px;
padding: 24px;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.stat-value {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 4px;
}
.stat-label {
color: var(--muted);
font-size: 0.9rem;
}
.analytics-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-bottom: 24px;
}
.chart-card {
grid-column: span 2;
}
.card {
background: var(--surface);
border-radius: 20px;
padding: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.card h2 {
font-size: 1.1rem;
margin-bottom: 20px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.period-selector {
display: flex;
gap: 4px;
background: var(--bg);
padding: 4px;
border-radius: 10px;
}
.period-selector button {
padding: 6px 12px;
border: none;
background: transparent;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 500;
color: var(--muted);
cursor: pointer;
}
.period-selector button.active {
background: var(--surface);
color: var(--ink);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.chart-container {
height: 200px;
}
.chart {
display: flex;
align-items: flex-end;
gap: 8px;
height: 100%;
}
.chart-bar {
flex: 1;
background: var(--accent);
border-radius: 6px 6px 0 0;
min-height: 8px;
}
.breakdown {
display: flex;
flex-direction: column;
gap: 12px;
}
.breakdown-item {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
background: var(--bg);
border-radius: 10px;
overflow: hidden;
}
.breakdown-bar {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: rgba(255, 106, 61, 0.15);
}
.breakdown-label {
position: relative;
font-weight: 500;
}
.breakdown-value {
position: relative;
color: var(--muted);
}
.link-info-card {
margin-top: 24px;
}
.info-grid {
display: grid;
gap: 16px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.info-label {
font-size: 0.85rem;
color: var(--muted);
}
.info-value {
font-weight: 500;
}
.info-value.link {
color: var(--accent);
word-break: break-all;
}
.empty-state {
text-align: center;
padding: 32px;
color: var(--muted);
}
.loading {
text-align: center;
padding: 60px;
color: var(--muted);
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
}
.analytics-grid {
grid-template-columns: 1fr;
}
.chart-card {
grid-column: span 1;
}
}
</style>

View File

@@ -0,0 +1,599 @@
<template>
<AppLayout>
<div class="links-page">
<header class="page-header">
<div>
<h1>Links</h1>
<p class="subtitle">Manage your short links</p>
</div>
<button @click="showCreateModal = true" class="cta">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Create Link
</button>
</header>
<div class="links-list" v-if="workspaceStore.links.length">
<div
v-for="link in workspaceStore.links"
:key="link.id"
class="link-card"
>
<div class="link-main">
<div class="link-info">
<h3 class="link-title">{{ link.title || link.slug }}</h3>
<div class="link-urls">
<span class="short-url" @click="copyToClipboard(getShortUrl(link))">
{{ getShortUrl(link) }}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</span>
<span class="dest-url">{{ truncateUrl(link.destinationUrl) }}</span>
</div>
</div>
<div class="link-stats">
<span class="stat">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M15 15l-2 5L9 9l11 4-5 2z"/>
</svg>
{{ link.clickCount || 0 }}
</span>
<span :class="['status-badge', link.status.toLowerCase()]">
{{ link.status }}
</span>
</div>
</div>
<div class="link-actions">
<button @click="editLink(link)" class="action-btn" title="Edit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<router-link :to="`/links/${link.id}`" class="action-btn" title="Analytics">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 20V10"/>
<path d="M12 20V4"/>
<path d="M6 20v-6"/>
</svg>
</router-link>
<button @click="confirmDelete(link)" class="action-btn delete" title="Delete">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
</div>
<div v-else class="empty-state">
<div class="empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
</svg>
</div>
<h2>No links yet</h2>
<p>Create your first short link to get started</p>
<button @click="showCreateModal = true" class="cta">Create Link</button>
</div>
<!-- Create/Edit Modal -->
<div v-if="showCreateModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h2>{{ editingLink ? 'Edit Link' : 'Create Link' }}</h2>
<button @click="closeModal" class="close-btn">&times;</button>
</div>
<form @submit.prevent="saveLink" class="modal-form">
<div class="form-group">
<label for="destinationUrl">Destination URL *</label>
<input
id="destinationUrl"
v-model="formData.destinationUrl"
type="url"
placeholder="https://example.com/page"
required
/>
</div>
<div class="form-group">
<label for="title">Title (optional)</label>
<input
id="title"
v-model="formData.title"
type="text"
placeholder="My awesome link"
/>
</div>
<div class="form-group">
<label for="slug">Custom slug (optional)</label>
<input
id="slug"
v-model="formData.slug"
type="text"
placeholder="my-link"
pattern="[a-zA-Z0-9-]+"
/>
<p class="hint">Leave empty for auto-generated slug</p>
</div>
<div class="form-row">
<div class="form-group">
<label for="expiresAt">Expires (optional)</label>
<input
id="expiresAt"
v-model="formData.expiresAt"
type="datetime-local"
/>
</div>
<div class="form-group">
<label for="password">Password (optional)</label>
<input
id="password"
v-model="formData.password"
type="password"
placeholder="Password protect"
/>
</div>
</div>
<div v-if="formError" class="error-message">
{{ formError }}
</div>
<div class="modal-actions">
<button type="button" @click="closeModal" class="ghost">Cancel</button>
<button type="submit" class="cta" :disabled="saving">
{{ saving ? 'Saving...' : (editingLink ? 'Update' : 'Create') }}
</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal modal-sm">
<h2>Delete Link?</h2>
<p>Are you sure you want to delete this link? This action cannot be undone.</p>
<div class="modal-actions">
<button @click="showDeleteModal = false" class="ghost">Cancel</button>
<button @click="deleteLink" class="cta danger">Delete</button>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import AppLayout from '../../components/layout/AppLayout.vue';
import { useWorkspaceStore } from '../../stores/workspace';
const workspaceStore = useWorkspaceStore();
const showCreateModal = ref(false);
const showDeleteModal = ref(false);
const editingLink = ref(null);
const deletingLink = ref(null);
const saving = ref(false);
const formError = ref('');
const formData = ref({
destinationUrl: '',
title: '',
slug: '',
expiresAt: '',
password: '',
});
const resetForm = () => {
formData.value = {
destinationUrl: '',
title: '',
slug: '',
expiresAt: '',
password: '',
};
formError.value = '';
editingLink.value = null;
};
const closeModal = () => {
showCreateModal.value = false;
resetForm();
};
const editLink = (link) => {
editingLink.value = link;
formData.value = {
destinationUrl: link.destinationUrl,
title: link.title || '',
slug: link.slug,
expiresAt: link.expiresAt ? new Date(link.expiresAt).toISOString().slice(0, 16) : '',
password: '',
};
showCreateModal.value = true;
};
const saveLink = async () => {
saving.value = true;
formError.value = '';
try {
const data = {
destinationUrl: formData.value.destinationUrl,
title: formData.value.title || null,
expiresAt: formData.value.expiresAt ? new Date(formData.value.expiresAt).toISOString() : null,
password: formData.value.password || null,
};
if (editingLink.value) {
await workspaceStore.updateLink(editingLink.value.id, data);
} else {
if (formData.value.slug) {
data.slug = formData.value.slug;
}
await workspaceStore.createLink(data);
}
closeModal();
} catch (err) {
formError.value = err.message;
} finally {
saving.value = false;
}
};
const confirmDelete = (link) => {
deletingLink.value = link;
showDeleteModal.value = true;
};
const deleteLink = async () => {
if (deletingLink.value) {
await workspaceStore.deleteLink(deletingLink.value.id);
showDeleteModal.value = false;
deletingLink.value = null;
}
};
const getShortUrl = (link) => {
return `localhost:42001/${link.slug}`;
};
const truncateUrl = (url) => {
if (url.length > 50) {
return url.substring(0, 50) + '...';
}
return url;
};
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(`http://${text}`);
} catch (err) {
console.error('Failed to copy:', err);
}
};
onMounted(async () => {
await workspaceStore.fetchLinks();
});
watch(() => workspaceStore.currentWorkspaceId, async () => {
if (workspaceStore.currentWorkspaceId) {
await workspaceStore.fetchLinks();
}
});
</script>
<style scoped>
.links-page {
max-width: 1000px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32px;
}
.page-header h1 {
font-size: 1.75rem;
margin-bottom: 4px;
}
.subtitle {
color: var(--muted);
}
.cta {
display: inline-flex;
align-items: center;
gap: 8px;
}
.links-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.link-card {
background: var(--surface);
border-radius: 16px;
padding: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.link-main {
display: flex;
align-items: center;
justify-content: space-between;
flex: 1;
gap: 20px;
}
.link-title {
font-size: 1.1rem;
margin-bottom: 6px;
}
.link-urls {
display: flex;
flex-direction: column;
gap: 4px;
}
.short-url {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--accent);
font-weight: 500;
cursor: pointer;
}
.short-url:hover {
text-decoration: underline;
}
.dest-url {
color: var(--muted);
font-size: 0.85rem;
}
.link-stats {
display: flex;
align-items: center;
gap: 16px;
}
.stat {
display: flex;
align-items: center;
gap: 6px;
color: var(--muted);
font-weight: 500;
}
.status-badge {
padding: 4px 10px;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.active {
background: #dcfce7;
color: #16a34a;
}
.status-badge.disabled {
background: #fef2f2;
color: #dc2626;
}
.status-badge.expired {
background: #fef3c7;
color: #d97706;
}
.link-actions {
display: flex;
gap: 8px;
}
.action-btn {
width: 36px;
height: 36px;
border: none;
border-radius: 10px;
background: var(--bg);
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.action-btn:hover {
background: var(--line);
color: var(--ink);
}
.action-btn.delete:hover {
background: #fef2f2;
color: #dc2626;
}
.empty-state {
text-align: center;
padding: 60px 20px;
background: var(--surface);
border-radius: 20px;
}
.empty-icon {
color: var(--muted);
margin-bottom: 16px;
}
.empty-state h2 {
margin-bottom: 8px;
}
.empty-state p {
color: var(--muted);
margin-bottom: 24px;
}
/* Modal styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal {
background: var(--surface);
border-radius: 24px;
padding: 32px;
width: 100%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
}
.modal-sm {
max-width: 400px;
text-align: center;
}
.modal-sm p {
color: var(--muted);
margin: 16px 0 24px;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.close-btn {
width: 32px;
height: 32px;
border: none;
background: var(--bg);
border-radius: 8px;
font-size: 1.5rem;
color: var(--muted);
cursor: pointer;
line-height: 1;
}
.modal-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-weight: 500;
font-size: 0.9rem;
}
.form-group input {
padding: 12px 16px;
border: 1px solid var(--line);
border-radius: 12px;
font-size: 1rem;
font-family: inherit;
}
.form-group input:focus {
outline: none;
border-color: var(--accent);
}
.form-group .hint {
font-size: 0.85rem;
color: var(--muted);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.error-message {
padding: 12px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 10px;
color: #dc2626;
font-size: 0.9rem;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 8px;
}
.cta.danger {
background: #dc2626;
}
@media (max-width: 600px) {
.link-card {
flex-direction: column;
align-items: flex-start;
}
.link-main {
flex-direction: column;
align-items: flex-start;
}
.form-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,535 @@
<template>
<AppLayout>
<div class="designer-page">
<header class="page-header">
<div>
<router-link to="/qrcodes" class="back-link">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 12H5"/>
<path d="M12 19l-7-7 7-7"/>
</svg>
Back to QR Codes
</router-link>
<h1>{{ isEditing ? 'Edit QR Code' : 'Create QR Code' }}</h1>
</div>
<div class="header-actions">
<button @click="downloadQR('svg')" class="ghost" :disabled="!previewUrl">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
SVG
</button>
<button @click="downloadQR('png')" class="ghost" :disabled="!previewUrl">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
PNG
</button>
<button @click="save" class="cta" :disabled="saving || !formData.name || !formData.linkId">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</header>
<div class="designer-grid">
<section class="preview-section">
<div class="preview-card" :style="{ background: formData.style.backgroundColor }">
<img
v-if="previewUrl"
:src="previewUrl"
alt="QR Preview"
class="preview-image"
/>
<div v-else class="preview-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="6" height="6"/>
<rect x="15" y="3" width="6" height="6"/>
<rect x="3" y="15" width="6" height="6"/>
<rect x="15" y="15" width="6" height="6"/>
<rect x="9" y="9" width="6" height="6"/>
</svg>
<p>Select a link to preview</p>
</div>
</div>
</section>
<section class="settings-section">
<div class="settings-card">
<h2>Basic Info</h2>
<div class="form-group">
<label for="name">Name *</label>
<input
id="name"
v-model="formData.name"
type="text"
placeholder="My QR Code"
required
/>
</div>
<div class="form-group">
<label for="linkId">Link *</label>
<select id="linkId" v-model="formData.linkId" required>
<option value="">Select a link</option>
<option v-for="link in workspaceStore.links" :key="link.id" :value="link.id">
{{ link.title || link.slug }} ({{ link.slug }})
</option>
</select>
</div>
</div>
<div class="settings-card">
<h2>Style</h2>
<div class="form-row">
<div class="form-group">
<label for="foregroundColor">Foreground</label>
<div class="color-input">
<input
id="foregroundColor"
v-model="formData.style.foregroundColor"
type="color"
/>
<input
v-model="formData.style.foregroundColor"
type="text"
class="color-text"
/>
</div>
</div>
<div class="form-group">
<label for="backgroundColor">Background</label>
<div class="color-input">
<input
id="backgroundColor"
v-model="formData.style.backgroundColor"
type="color"
/>
<input
v-model="formData.style.backgroundColor"
type="text"
class="color-text"
/>
</div>
</div>
</div>
<div class="form-group">
<label for="errorCorrection">Error Correction</label>
<select id="errorCorrection" v-model="formData.style.errorCorrectionLevel">
<option value="L">Low (7%)</option>
<option value="M">Medium (15%)</option>
<option value="Q">Quartile (25%)</option>
<option value="H">High (30%)</option>
</select>
<p class="hint">Higher correction allows for more damage tolerance but increases QR size</p>
</div>
<div class="form-group">
<label for="quietZone">Quiet Zone</label>
<input
id="quietZone"
v-model.number="formData.style.quietZone"
type="range"
min="1"
max="6"
/>
<span class="range-value">{{ formData.style.quietZone }} modules</span>
</div>
</div>
<div class="settings-card">
<h2>Presets</h2>
<div class="presets-grid">
<button
v-for="preset in presets"
:key="preset.name"
@click="applyPreset(preset)"
class="preset-btn"
:style="{
background: preset.style.backgroundColor,
color: preset.style.foregroundColor,
borderColor: preset.style.foregroundColor
}"
>
{{ preset.name }}
</button>
</div>
</div>
</section>
</div>
<div v-if="error" class="error-toast">
{{ error }}
</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, watch, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import AppLayout from '../../components/layout/AppLayout.vue';
import { useWorkspaceStore } from '../../stores/workspace';
import { api } from '../../api/client';
const route = useRoute();
const router = useRouter();
const workspaceStore = useWorkspaceStore();
const isEditing = computed(() => !!route.params.id);
const saving = ref(false);
const error = ref('');
const previewUrl = ref('');
const previewTimeout = ref(null);
const formData = ref({
name: '',
linkId: '',
style: {
foregroundColor: '#000000',
backgroundColor: '#ffffff',
errorCorrectionLevel: 'M',
quietZone: 4,
},
});
const presets = [
{
name: 'Classic',
style: { foregroundColor: '#000000', backgroundColor: '#ffffff', errorCorrectionLevel: 'M', quietZone: 4 }
},
{
name: 'Dark',
style: { foregroundColor: '#ffffff', backgroundColor: '#1a1a1a', errorCorrectionLevel: 'M', quietZone: 4 }
},
{
name: 'Ocean',
style: { foregroundColor: '#0369a1', backgroundColor: '#e0f2fe', errorCorrectionLevel: 'M', quietZone: 4 }
},
{
name: 'Forest',
style: { foregroundColor: '#166534', backgroundColor: '#dcfce7', errorCorrectionLevel: 'M', quietZone: 4 }
},
{
name: 'Sunset',
style: { foregroundColor: '#c2410c', backgroundColor: '#fff7ed', errorCorrectionLevel: 'M', quietZone: 4 }
},
{
name: 'Purple',
style: { foregroundColor: '#7c3aed', backgroundColor: '#f3e8ff', errorCorrectionLevel: 'M', quietZone: 4 }
},
];
const applyPreset = (preset) => {
formData.value.style = { ...preset.style };
};
const fetchPreview = async () => {
if (!formData.value.linkId || !workspaceStore.currentWorkspaceId) {
previewUrl.value = '';
return;
}
// For new QR codes, we need to create a temporary one to get preview
// For now, we'll just show a placeholder until saved
if (isEditing.value) {
try {
const preview = await api.getQRCodePreview(workspaceStore.currentWorkspaceId, route.params.id);
previewUrl.value = preview.dataUrl;
} catch (err) {
console.error('Preview error:', err);
}
}
};
const save = async () => {
if (!formData.value.name || !formData.value.linkId) return;
saving.value = true;
error.value = '';
try {
const data = {
name: formData.value.name,
linkId: formData.value.linkId,
style: formData.value.style,
};
if (isEditing.value) {
await workspaceStore.updateQRCode(route.params.id, data);
} else {
await workspaceStore.createQRCode(data);
}
router.push('/qrcodes');
} catch (err) {
error.value = err.message;
} finally {
saving.value = false;
}
};
const downloadQR = async (format) => {
if (!isEditing.value) return;
const url = api.getQRCodeExportUrl(workspaceStore.currentWorkspaceId, route.params.id, format, 512);
const link = document.createElement('a');
link.href = url;
link.download = `${formData.value.name}.${format}`;
link.click();
};
const loadExisting = async () => {
if (!isEditing.value) return;
try {
const qr = await api.getQRCode(workspaceStore.currentWorkspaceId, route.params.id);
formData.value = {
name: qr.name,
linkId: qr.linkId,
style: qr.style || {
foregroundColor: '#000000',
backgroundColor: '#ffffff',
errorCorrectionLevel: 'M',
quietZone: 4,
},
};
await fetchPreview();
} catch (err) {
error.value = 'Failed to load QR code';
}
};
watch(() => formData.value.style, () => {
if (previewTimeout.value) clearTimeout(previewTimeout.value);
previewTimeout.value = setTimeout(fetchPreview, 500);
}, { deep: true });
onMounted(async () => {
await workspaceStore.fetchLinks();
if (isEditing.value) {
await loadExisting();
}
});
</script>
<style scoped>
.designer-page {
max-width: 1200px;
}
.page-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 32px;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 0.9rem;
margin-bottom: 12px;
}
.page-header h1 {
font-size: 1.75rem;
}
.header-actions {
display: flex;
gap: 12px;
}
.header-actions .ghost,
.header-actions .cta {
display: inline-flex;
align-items: center;
gap: 8px;
}
.designer-grid {
display: grid;
grid-template-columns: 1fr 400px;
gap: 32px;
}
.preview-section {
position: sticky;
top: 32px;
}
.preview-card {
aspect-ratio: 1;
max-width: 500px;
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.1);
padding: 48px;
}
.preview-image {
max-width: 100%;
max-height: 100%;
border-radius: 12px;
}
.preview-placeholder {
text-align: center;
color: var(--muted);
}
.preview-placeholder p {
margin-top: 16px;
}
.settings-section {
display: flex;
flex-direction: column;
gap: 24px;
}
.settings-card {
background: var(--surface);
border-radius: 20px;
padding: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.settings-card h2 {
font-size: 1.1rem;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 20px;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-group label {
display: block;
font-weight: 500;
font-size: 0.9rem;
margin-bottom: 8px;
}
.form-group input[type="text"],
.form-group select {
width: 100%;
padding: 12px 16px;
border: 1px solid var(--line);
border-radius: 12px;
font-size: 1rem;
font-family: inherit;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.color-input {
display: flex;
gap: 8px;
}
.color-input input[type="color"] {
width: 48px;
height: 48px;
padding: 0;
border: 1px solid var(--line);
border-radius: 12px;
cursor: pointer;
}
.color-input .color-text {
flex: 1;
padding: 12px;
border: 1px solid var(--line);
border-radius: 12px;
font-family: monospace;
}
.form-group input[type="range"] {
width: 100%;
margin-bottom: 8px;
}
.range-value {
font-size: 0.85rem;
color: var(--muted);
}
.hint {
font-size: 0.85rem;
color: var(--muted);
margin-top: 8px;
}
.presets-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.preset-btn {
padding: 16px 12px;
border: 2px solid;
border-radius: 12px;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
transition: transform 0.15s ease;
}
.preset-btn:hover {
transform: scale(1.05);
}
.error-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: #dc2626;
color: white;
padding: 12px 24px;
border-radius: 12px;
font-weight: 500;
}
@media (max-width: 900px) {
.designer-grid {
grid-template-columns: 1fr;
}
.preview-section {
position: static;
}
.preview-card {
max-width: 100%;
}
}
</style>

View File

@@ -0,0 +1,338 @@
<template>
<AppLayout>
<div class="qrcodes-page">
<header class="page-header">
<div>
<h1>QR Codes</h1>
<p class="subtitle">Design and manage your QR codes</p>
</div>
<router-link to="/qrcodes/new" class="cta">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Create QR Code
</router-link>
</header>
<div class="qrcodes-grid" v-if="workspaceStore.qrcodes.length">
<div
v-for="qr in workspaceStore.qrcodes"
:key="qr.id"
class="qr-card"
>
<div class="qr-preview" :style="{ background: qr.style?.backgroundColor || '#ffffff' }">
<img
v-if="previews[qr.id]"
:src="previews[qr.id]"
alt="QR Preview"
class="qr-image"
/>
<div v-else class="qr-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="6" height="6"/>
<rect x="15" y="3" width="6" height="6"/>
<rect x="3" y="15" width="6" height="6"/>
<rect x="15" y="15" width="6" height="6"/>
</svg>
</div>
</div>
<div class="qr-info">
<h3 class="qr-title">{{ qr.name }}</h3>
<p class="qr-link">{{ getLinkSlug(qr.linkId) }}</p>
</div>
<div class="qr-actions">
<router-link :to="`/qrcodes/${qr.id}`" class="action-btn" title="Edit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</router-link>
<button @click="downloadQR(qr, 'png')" class="action-btn" title="Download PNG">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</button>
<button @click="confirmDelete(qr)" class="action-btn delete" title="Delete">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</div>
</div>
</div>
<div v-else class="empty-state">
<div class="empty-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="6" height="6"/>
<rect x="15" y="3" width="6" height="6"/>
<rect x="3" y="15" width="6" height="6"/>
<rect x="15" y="15" width="6" height="6"/>
<rect x="9" y="9" width="6" height="6"/>
</svg>
</div>
<h2>No QR codes yet</h2>
<p>Create your first QR code design</p>
<router-link to="/qrcodes/new" class="cta">Create QR Code</router-link>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal modal-sm">
<h2>Delete QR Code?</h2>
<p>Are you sure you want to delete this QR code design?</p>
<div class="modal-actions">
<button @click="showDeleteModal = false" class="ghost">Cancel</button>
<button @click="deleteQR" class="cta danger">Delete</button>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue';
import AppLayout from '../../components/layout/AppLayout.vue';
import { useWorkspaceStore } from '../../stores/workspace';
import { api } from '../../api/client';
const workspaceStore = useWorkspaceStore();
const previews = ref({});
const showDeleteModal = ref(false);
const deletingQR = ref(null);
const fetchPreviews = async () => {
for (const qr of workspaceStore.qrcodes) {
try {
const preview = await api.getQRCodePreview(workspaceStore.currentWorkspaceId, qr.id);
previews.value[qr.id] = preview.dataUrl;
} catch (err) {
console.error('Failed to fetch preview:', err);
}
}
};
const getLinkSlug = (linkId) => {
const link = workspaceStore.links.find(l => l.id === linkId);
return link ? `/${link.slug}` : 'No link';
};
const downloadQR = async (qr, format) => {
const url = api.getQRCodeExportUrl(workspaceStore.currentWorkspaceId, qr.id, format, 512);
const link = document.createElement('a');
link.href = url;
link.download = `${qr.name}.${format}`;
link.click();
};
const confirmDelete = (qr) => {
deletingQR.value = qr;
showDeleteModal.value = true;
};
const deleteQR = async () => {
if (deletingQR.value) {
await workspaceStore.deleteQRCode(deletingQR.value.id);
delete previews.value[deletingQR.value.id];
showDeleteModal.value = false;
deletingQR.value = null;
}
};
onMounted(async () => {
await workspaceStore.fetchQRCodes();
await workspaceStore.fetchLinks();
await fetchPreviews();
});
watch(() => workspaceStore.currentWorkspaceId, async () => {
if (workspaceStore.currentWorkspaceId) {
previews.value = {};
await workspaceStore.fetchQRCodes();
await workspaceStore.fetchLinks();
await fetchPreviews();
}
});
</script>
<style scoped>
.qrcodes-page {
max-width: 1200px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 32px;
}
.page-header h1 {
font-size: 1.75rem;
margin-bottom: 4px;
}
.subtitle {
color: var(--muted);
}
.cta {
display: inline-flex;
align-items: center;
gap: 8px;
}
.qrcodes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
}
.qr-card {
background: var(--surface);
border-radius: 20px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.qr-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
}
.qr-preview {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
}
.qr-image {
max-width: 100%;
max-height: 100%;
border-radius: 8px;
}
.qr-placeholder {
color: var(--muted);
}
.qr-info {
padding: 16px 20px;
}
.qr-title {
font-size: 1.1rem;
margin-bottom: 4px;
}
.qr-link {
color: var(--muted);
font-size: 0.9rem;
}
.qr-actions {
display: flex;
gap: 8px;
padding: 0 20px 20px;
}
.action-btn {
flex: 1;
height: 40px;
border: none;
border-radius: 10px;
background: var(--bg);
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.action-btn:hover {
background: var(--line);
color: var(--ink);
}
.action-btn.delete:hover {
background: #fef2f2;
color: #dc2626;
}
.empty-state {
text-align: center;
padding: 60px 20px;
background: var(--surface);
border-radius: 20px;
}
.empty-icon {
color: var(--muted);
margin-bottom: 16px;
}
.empty-state h2 {
margin-bottom: 8px;
}
.empty-state p {
color: var(--muted);
margin-bottom: 24px;
}
/* Modal styles */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal {
background: var(--surface);
border-radius: 24px;
padding: 32px;
width: 100%;
max-width: 400px;
}
.modal-sm {
text-align: center;
}
.modal-sm h2 {
margin-bottom: 12px;
}
.modal-sm p {
color: var(--muted);
margin-bottom: 24px;
}
.modal-actions {
display: flex;
justify-content: center;
gap: 12px;
}
.cta.danger {
background: #dc2626;
}
</style>

View File

@@ -4,6 +4,13 @@ import vue from "@vitejs/plugin-vue";
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
server: { server: {
port: 5173 port: 5173,
proxy: {
'/api': {
target: 'http://localhost:42001',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
} }
}); });