diff --git a/docs/tasks.md b/docs/tasks.md index 46aef06..f881459 100644 --- a/docs/tasks.md +++ b/docs/tasks.md @@ -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~~ ✓ --- diff --git a/src/api/Program.cs b/src/api/Program.cs index 75b76e0..596d814 100644 --- a/src/api/Program.cs +++ b/src/api/Program.cs @@ -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(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(); @@ -64,4 +78,4 @@ app.UseAuthorization(); app.UseFastEndpoints(); -app.Run(); +app.Run(); \ No newline at end of file diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index a3dbaff..d40189a 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -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==" } } } diff --git a/src/frontend/package.json b/src/frontend/package.json index 5ab0974..d67a900 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -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", diff --git a/src/frontend/src/App.vue b/src/frontend/src/App.vue index 87bbe15..98240ae 100644 --- a/src/frontend/src/App.vue +++ b/src/frontend/src/App.vue @@ -1,115 +1,3 @@ diff --git a/src/frontend/src/api/client.js b/src/frontend/src/api/client.js new file mode 100644 index 0000000..60f6744 --- /dev/null +++ b/src/frontend/src/api/client.js @@ -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(); diff --git a/src/frontend/src/components/layout/AppLayout.vue b/src/frontend/src/components/layout/AppLayout.vue new file mode 100644 index 0000000..aa9bb94 --- /dev/null +++ b/src/frontend/src/components/layout/AppLayout.vue @@ -0,0 +1,230 @@ + + + + + diff --git a/src/frontend/src/main.js b/src/frontend/src/main.js index de275e7..8c00f64 100644 --- a/src/frontend/src/main.js +++ b/src/frontend/src/main.js @@ -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'); diff --git a/src/frontend/src/router/index.js b/src/frontend/src/router/index.js new file mode 100644 index 0000000..b1be14a --- /dev/null +++ b/src/frontend/src/router/index.js @@ -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; diff --git a/src/frontend/src/stores/auth.js b/src/frontend/src/stores/auth.js new file mode 100644 index 0000000..a7b90e5 --- /dev/null +++ b/src/frontend/src/stores/auth.js @@ -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; + }, + }, +}); diff --git a/src/frontend/src/stores/workspace.js b/src/frontend/src/stores/workspace.js new file mode 100644 index 0000000..5826c5a --- /dev/null +++ b/src/frontend/src/stores/workspace.js @@ -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; + } + }, + }, +}); diff --git a/src/frontend/src/views/Landing.vue b/src/frontend/src/views/Landing.vue new file mode 100644 index 0000000..1295e9f --- /dev/null +++ b/src/frontend/src/views/Landing.vue @@ -0,0 +1,126 @@ + + + diff --git a/src/frontend/src/views/analytics/Analytics.vue b/src/frontend/src/views/analytics/Analytics.vue new file mode 100644 index 0000000..8035518 --- /dev/null +++ b/src/frontend/src/views/analytics/Analytics.vue @@ -0,0 +1,552 @@ + + + + + diff --git a/src/frontend/src/views/auth/Login.vue b/src/frontend/src/views/auth/Login.vue new file mode 100644 index 0000000..045ff24 --- /dev/null +++ b/src/frontend/src/views/auth/Login.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/src/frontend/src/views/auth/Register.vue b/src/frontend/src/views/auth/Register.vue new file mode 100644 index 0000000..d9d9859 --- /dev/null +++ b/src/frontend/src/views/auth/Register.vue @@ -0,0 +1,213 @@ + + + + + diff --git a/src/frontend/src/views/dashboard/Dashboard.vue b/src/frontend/src/views/dashboard/Dashboard.vue new file mode 100644 index 0000000..07bc960 --- /dev/null +++ b/src/frontend/src/views/dashboard/Dashboard.vue @@ -0,0 +1,438 @@ + + + + + diff --git a/src/frontend/src/views/links/LinkDetail.vue b/src/frontend/src/views/links/LinkDetail.vue new file mode 100644 index 0000000..505af2e --- /dev/null +++ b/src/frontend/src/views/links/LinkDetail.vue @@ -0,0 +1,462 @@ + + + + + diff --git a/src/frontend/src/views/links/Links.vue b/src/frontend/src/views/links/Links.vue new file mode 100644 index 0000000..145caf8 --- /dev/null +++ b/src/frontend/src/views/links/Links.vue @@ -0,0 +1,599 @@ + + + + + diff --git a/src/frontend/src/views/qrcodes/QRCodeDesigner.vue b/src/frontend/src/views/qrcodes/QRCodeDesigner.vue new file mode 100644 index 0000000..b1440a2 --- /dev/null +++ b/src/frontend/src/views/qrcodes/QRCodeDesigner.vue @@ -0,0 +1,535 @@ + + + + + diff --git a/src/frontend/src/views/qrcodes/QRCodes.vue b/src/frontend/src/views/qrcodes/QRCodes.vue new file mode 100644 index 0000000..df5ad96 --- /dev/null +++ b/src/frontend/src/views/qrcodes/QRCodes.vue @@ -0,0 +1,338 @@ + + + + + diff --git a/src/frontend/vite.config.js b/src/frontend/vite.config.js index bce4fb0..d0a3073 100644 --- a/src/frontend/vite.config.js +++ b/src/frontend/vite.config.js @@ -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/, '') + } + } } });