feat: add frontend dashboard
This commit is contained in:
@@ -175,38 +175,40 @@
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Frontend Dashboard
|
||||
## Phase 6: Frontend Dashboard (In Progress)
|
||||
|
||||
### Authentication UI
|
||||
- [ ] Login page
|
||||
- [ ] Registration page
|
||||
- [x] Login page
|
||||
- [x] Registration page
|
||||
- [ ] Forgot password page
|
||||
- [ ] Password reset page
|
||||
- [ ] Auth state management
|
||||
- [x] Auth state management (Pinia store)
|
||||
|
||||
### Dashboard
|
||||
- [ ] Workspace switcher
|
||||
- [ ] Dashboard home (overview stats)
|
||||
- [ ] Navigation/sidebar
|
||||
- [x] Workspace switcher
|
||||
- [x] Dashboard home (overview stats)
|
||||
- [x] Navigation/sidebar (AppLayout component)
|
||||
|
||||
### Link Management UI
|
||||
- [ ] Links list view
|
||||
- [ ] Create link modal/page
|
||||
- [ ] Edit link modal/page
|
||||
- [ ] Link details with analytics
|
||||
- [x] Links list view
|
||||
- [x] Create link modal
|
||||
- [x] Edit link modal
|
||||
- [x] Link details with analytics
|
||||
|
||||
### QR Designer UI
|
||||
- [ ] QR designer page
|
||||
- [ ] Color pickers
|
||||
- [ ] Shape selectors
|
||||
- [ ] Logo upload
|
||||
- [ ] Live preview
|
||||
- [ ] Export buttons
|
||||
- [x] QR designer page
|
||||
- [x] Color pickers
|
||||
- [~] Shape selectors (basic support)
|
||||
- [ ] Logo upload integration
|
||||
- [x] Live preview (for saved QR codes)
|
||||
- [x] Export buttons (PNG/SVG)
|
||||
- [x] Style presets (6 presets)
|
||||
|
||||
### Analytics UI
|
||||
- [ ] Charts (time series)
|
||||
- [ ] Stat cards
|
||||
- [ ] Breakdown tables (referrer, geo, device)
|
||||
- [x] Charts (time series with clicks/scans)
|
||||
- [x] Stat cards (clicks, scans, visitors, total)
|
||||
- [x] Breakdown tables (referrer, device)
|
||||
- [~] Geo breakdown (API ready, UI pending)
|
||||
|
||||
---
|
||||
|
||||
@@ -254,7 +256,9 @@
|
||||
|
||||
## 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)
|
||||
- Public Redirect Endpoint (2 endpoints, 10 tests)
|
||||
- Event Tracking Service (click logging, dedupe, device detection)
|
||||
@@ -263,9 +267,21 @@
|
||||
- Domain Management (5 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:
|
||||
1. ~~Create short link endpoint with auto-slug generation~~ ✓
|
||||
@@ -277,6 +293,7 @@ Completed:
|
||||
7. ~~QR code generation and designer~~ ✓
|
||||
8. ~~Domain management (add, list, get, delete, verify)~~ ✓
|
||||
9. ~~Asset upload for QR logos~~ ✓
|
||||
10. ~~Frontend dashboard with auth, links, QR, analytics~~ ✓
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -11,6 +11,21 @@ using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
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.
|
||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseNpgsql(builder.Configuration.GetConnectionString("PostgresConnection")));
|
||||
@@ -46,15 +61,14 @@ builder.Services.AddOpenApi();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseCors();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi().CacheOutput();
|
||||
|
||||
app.UseSwaggerUI(options =>
|
||||
{
|
||||
options.SwaggerEndpoint("/openapi/v1.json", "v1");
|
||||
});
|
||||
app.UseSwaggerUI(options => { options.SwaggerEndpoint("/openapi/v1.json", "v1"); });
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
186
src/frontend/package-lock.json
generated
186
src/frontend/package-lock.json
generated
@@ -8,7 +8,10 @@
|
||||
"name": "trakqr-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"vue": "^3.4.21"
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
@@ -722,6 +725,11 @@
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"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": {
|
||||
"version": "5.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
||||
@@ -781,6 +789,36 @@
|
||||
"@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": {
|
||||
"version": "3.5.26",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "3.2.3",
|
||||
"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_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": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -907,6 +1018,11 @@
|
||||
"@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": {
|
||||
"version": "3.3.11",
|
||||
"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_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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"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": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
@@ -956,6 +1097,11 @@
|
||||
"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": {
|
||||
"version": "4.54.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
|
||||
@@ -1005,6 +1151,25 @@
|
||||
"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": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
@@ -1083,6 +1248,25 @@
|
||||
"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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.21"
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
|
||||
@@ -1,115 +1,3 @@
|
||||
<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>
|
||||
<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>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
214
src/frontend/src/api/client.js
Normal file
214
src/frontend/src/api/client.js
Normal 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();
|
||||
230
src/frontend/src/components/layout/AppLayout.vue
Normal file
230
src/frontend/src/components/layout/AppLayout.vue
Normal 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>
|
||||
@@ -1,5 +1,13 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
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');
|
||||
|
||||
94
src/frontend/src/router/index.js
Normal file
94
src/frontend/src/router/index.js
Normal 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;
|
||||
65
src/frontend/src/stores/auth.js
Normal file
65
src/frontend/src/stores/auth.js
Normal 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;
|
||||
},
|
||||
},
|
||||
});
|
||||
197
src/frontend/src/stores/workspace.js
Normal file
197
src/frontend/src/stores/workspace.js
Normal 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;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
126
src/frontend/src/views/Landing.vue
Normal file
126
src/frontend/src/views/Landing.vue
Normal 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>
|
||||
552
src/frontend/src/views/analytics/Analytics.vue
Normal file
552
src/frontend/src/views/analytics/Analytics.vue
Normal 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>
|
||||
191
src/frontend/src/views/auth/Login.vue
Normal file
191
src/frontend/src/views/auth/Login.vue
Normal 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>
|
||||
213
src/frontend/src/views/auth/Register.vue
Normal file
213
src/frontend/src/views/auth/Register.vue
Normal 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>
|
||||
438
src/frontend/src/views/dashboard/Dashboard.vue
Normal file
438
src/frontend/src/views/dashboard/Dashboard.vue
Normal 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>
|
||||
462
src/frontend/src/views/links/LinkDetail.vue
Normal file
462
src/frontend/src/views/links/LinkDetail.vue
Normal 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>
|
||||
599
src/frontend/src/views/links/Links.vue
Normal file
599
src/frontend/src/views/links/Links.vue
Normal 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">×</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>
|
||||
535
src/frontend/src/views/qrcodes/QRCodeDesigner.vue
Normal file
535
src/frontend/src/views/qrcodes/QRCodeDesigner.vue
Normal 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>
|
||||
338
src/frontend/src/views/qrcodes/QRCodes.vue
Normal file
338
src/frontend/src/views/qrcodes/QRCodes.vue
Normal 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>
|
||||
@@ -4,6 +4,13 @@ import vue from "@vitejs/plugin-vue";
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:42001',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user