diff --git a/services/webui/.gitignore b/services/webui/.gitignore new file mode 100644 index 00000000..43370fa9 --- /dev/null +++ b/services/webui/.gitignore @@ -0,0 +1 @@ +*.tsbuildinfo diff --git a/services/webui/src/client/App.tsx b/services/webui/src/client/App.tsx index 50b0fa82..47aabe03 100644 --- a/services/webui/src/client/App.tsx +++ b/services/webui/src/client/App.tsx @@ -10,6 +10,9 @@ import Users from './pages/Users'; import UserDetail from './pages/UserDetail'; import Profile from './pages/Profile'; import Settings from './pages/Settings'; +import Links from './pages/Links'; +import Collections from './pages/Collections'; +import Analytics from './pages/Analytics'; function App() { const { isAuthenticated, isLoading, checkAuth } = useAuth(); @@ -81,6 +84,15 @@ function App() { } /> + + {/* Shortener - Links - all authenticated users */} + } /> + + {/* Shortener - Collections - all authenticated users */} + } /> + + {/* Shortener - Analytics - all authenticated users */} + } /> {/* Catch all - redirect to dashboard or login */} diff --git a/services/webui/src/client/components/Sidebar.tsx b/services/webui/src/client/components/Sidebar.tsx index 914ddf02..a85eba3f 100644 --- a/services/webui/src/client/components/Sidebar.tsx +++ b/services/webui/src/client/components/Sidebar.tsx @@ -16,6 +16,14 @@ const categories: MenuCategory[] = [ { name: 'Profile', href: '/profile' }, ], }, + { + header: 'Shortener', + items: [ + { name: 'Links', href: '/links' }, + { name: 'Collections', href: '/collections' }, + { name: 'Analytics', href: '/analytics' }, + ], + }, { header: 'Management', items: [ diff --git a/services/webui/src/client/hooks/useApi.ts b/services/webui/src/client/hooks/useApi.ts index 9f45ca1e..4d10de4a 100644 --- a/services/webui/src/client/hooks/useApi.ts +++ b/services/webui/src/client/hooks/useApi.ts @@ -1,7 +1,21 @@ import { useState, useCallback } from 'react'; import axios from 'axios'; import api from '../lib/api'; -import type { User, CreateUserData, UpdateUserData, PaginatedResponse } from '../types'; +import type { + User, + CreateUserData, + UpdateUserData, + PaginatedResponse, + ShortLink, + CreateShortLinkData, + UpdateShortLinkData, + Collection, + CreateCollectionData, + UpdateCollectionData, + AnalyticsSummary, + LinkAnalytics, + PaginatedShortLinksResponse, +} from '../types'; // Go backend API client (separate from auth-intercepted api) // Express mounts Go proxy at /api/go, so client calls go to /api/go/* @@ -112,3 +126,81 @@ export const goApi = { return response.data; }, }; + +// Shortener API +export const shortenerApi = { + links: { + list: async (params?: { + limit?: number; + offset?: number; + collection_id?: string; + is_active?: boolean; + search?: string; + }): Promise => { + const response = await api.get('/urls', { params }); + return response.data; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/urls/${id}`); + return response.data.data; + }, + + create: async (data: CreateShortLinkData): Promise => { + const response = await api.post('/urls', data); + return response.data.data; + }, + + update: async (id: string, data: UpdateShortLinkData): Promise => { + const response = await api.put(`/urls/${id}`, data); + return response.data.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`/urls/${id}`); + }, + + getQrCode: async (id: string): Promise => { + const response = await api.get(`/urls/${id}/qr`, { responseType: 'blob' }); + return response.data; + }, + }, + + collections: { + list: async (params?: { parent_id?: string }): Promise => { + const response = await api.get('/collections', { params }); + return response.data.data; + }, + + get: async (id: string): Promise => { + const response = await api.get(`/collections/${id}`); + return response.data.data; + }, + + create: async (data: CreateCollectionData): Promise => { + const response = await api.post('/collections', data); + return response.data.data; + }, + + update: async (id: string, data: UpdateCollectionData): Promise => { + const response = await api.put(`/collections/${id}`, data); + return response.data.data; + }, + + delete: async (id: string): Promise => { + await api.delete(`/collections/${id}`); + }, + }, + + analytics: { + summary: async (): Promise => { + const response = await api.get('/analytics/summary'); + return response.data.data; + }, + + link: async (id: string): Promise => { + const response = await api.get(`/analytics/urls/${id}`); + return response.data.data; + }, + }, +}; diff --git a/services/webui/src/client/pages/Analytics.tsx b/services/webui/src/client/pages/Analytics.tsx new file mode 100644 index 00000000..c266c13b --- /dev/null +++ b/services/webui/src/client/pages/Analytics.tsx @@ -0,0 +1,370 @@ +import { useState, useEffect } from 'react'; +import { shortenerApi } from '../hooks/useApi'; +import Card from '../components/Card'; +import type { AnalyticsSummary, LinkAnalytics } from '../types'; + +interface DrillDownState { + linkId: string | null; + linkTitle: string | null; + analytics: LinkAnalytics | null; + isLoading: boolean; + error: string | null; + licenseDenied: boolean; +} + +export default function Analytics() { + const [summary, setSummary] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [drillDown, setDrillDown] = useState({ + linkId: null, + linkTitle: null, + analytics: null, + isLoading: false, + error: null, + licenseDenied: false, + }); + + const fetchSummary = async () => { + setIsLoading(true); + try { + const data = await shortenerApi.analytics.summary(); + setSummary(data); + setError(null); + console.log('[Analytics] Summary loaded'); + } catch (err: any) { + console.log('[Analytics] Error fetching summary', { error: String(err) }); + setError('Failed to load analytics'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchSummary(); + }, []); + + // Handle drill-down into link analytics + const handleDrillDown = async ( + link: AnalyticsSummary['top_links'][0], + linkId: string + ) => { + setDrillDown((prev) => ({ ...prev, isLoading: true, error: null, licenseDenied: false })); + + try { + const analytics = await shortenerApi.analytics.link(linkId); + setDrillDown({ + linkId, + linkTitle: link.title || link.short_code, + analytics, + isLoading: false, + error: null, + licenseDenied: false, + }); + console.log('[Analytics] Drill-down loaded', { linkId }); + } catch (err: any) { + const statusCode = err?.response?.status; + const isLicenseDenied = statusCode === 402 || statusCode === 403; + + if (isLicenseDenied) { + setDrillDown({ + linkId, + linkTitle: link.title || link.short_code, + analytics: null, + isLoading: false, + error: null, + licenseDenied: true, + }); + console.log('[Analytics] License check failed (402/403)'); + } else { + const msg = err?.response?.data?.error || 'Failed to load link analytics'; + setDrillDown({ + linkId: null, + linkTitle: null, + analytics: null, + isLoading: false, + error: msg, + licenseDenied: false, + }); + console.log('[Analytics] Drill-down error', { error: msg }); + } + } + }; + + // Close drill-down + const closeDrillDown = () => { + setDrillDown({ + linkId: null, + linkTitle: null, + analytics: null, + isLoading: false, + error: null, + licenseDenied: false, + }); + }; + + // Simple bar chart renderer (inline SVG) + const renderClicksChart = (data: Array<{ date: string; count: number }>) => { + if (!data || data.length === 0) { + return

No data available

; + } + + const maxCount = Math.max(...data.map((d) => d.count), 1); + const chartHeight = 200; + + return ( + + {data.map((item, idx) => { + const barHeight = (item.count / maxCount) * (chartHeight - 40); + const x = idx * 20; + const y = chartHeight - barHeight - 20; + + return ( + + + + {item.date.substring(5)} + + + ); + })} + + ); + }; + + if (isLoading) { + return ( +
Loading analytics...
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!summary) { + return ( + +

No analytics data available

+
+ ); + } + + return ( +
+ {/* Header */} +
+

Analytics

+

Track your short link performance

+
+ + {/* Summary cards */} +
+ +
+

Total Links

+

{summary.total_links}

+
+
+ +
+

Total Clicks

+

{summary.total_clicks}

+
+
+
+ + {/* Clicks over time chart */} + +

Clicks Over Time

+
+ {renderClicksChart(summary.clicks_over_time)} +
+
+ + {/* Top links */} + +

Top Links

+ + {summary.top_links.length === 0 ? ( +

No data available

+ ) : ( + + + + + + + + + + + {summary.top_links.map((link, idx) => ( + + + + + + + ))} + +
Short CodeTitleClicksAction
{link.short_code}{link.title || '—'}{link.clicks} + +
+ )} +
+ + {/* Drill-down modal */} + {drillDown.linkId && ( +
+ +
+

Analytics: {drillDown.linkTitle}

+ +
+ + {drillDown.isLoading && ( +
Loading...
+ )} + + {drillDown.error && ( +
+ {drillDown.error} +
+ )} + + {drillDown.licenseDenied && ( +
+
+

Enterprise Feature

+

+ Advanced analytics (detailed breakdowns, referrers, device/browser data) are + available on the Professional or Enterprise plans. Please upgrade your license + to access these features. +

+
+
+ +
+
+ )} + + {drillDown.analytics && ( +
+ {/* Total clicks */} +
+ +

Total Clicks

+

{drillDown.analytics.total_clicks}

+
+
+ + {/* Clicks over time */} +
+

Clicks Over Time

+
+ {renderClicksChart(drillDown.analytics.clicks_over_time)} +
+
+ + {/* Top referrers */} + {drillDown.analytics.top_referers && drillDown.analytics.top_referers.length > 0 && ( +
+

Top Referrers

+ + + + + + + + + {drillDown.analytics.top_referers.map((ref, idx) => ( + + + + + ))} + +
ReferrerCount
{ref.referer || '(direct)'}{ref.count}
+
+ )} + + {/* Device breakdown */} + {drillDown.analytics.device_breakdown && + Object.keys(drillDown.analytics.device_breakdown).length > 0 && ( +
+

Device Breakdown

+
+ {Object.entries(drillDown.analytics.device_breakdown).map(([device, count]) => ( +
+ {device} + {count} +
+ ))} +
+
+ )} + + {/* Browser breakdown */} + {drillDown.analytics.browser_breakdown && + Object.keys(drillDown.analytics.browser_breakdown).length > 0 && ( +
+

Browser Breakdown

+
+ {Object.entries(drillDown.analytics.browser_breakdown).map(([browser, count]) => ( +
+ {browser} + {count} +
+ ))} +
+
+ )} + +
+ +
+
+ )} +
+
+ )} +
+ ); +} diff --git a/services/webui/src/client/pages/Collections.tsx b/services/webui/src/client/pages/Collections.tsx new file mode 100644 index 00000000..d5ea6fe2 --- /dev/null +++ b/services/webui/src/client/pages/Collections.tsx @@ -0,0 +1,456 @@ +import { useState, useEffect } from 'react'; +import { shortenerApi } from '../hooks/useApi'; +import { useAuth } from '../hooks/useAuth'; +import Card from '../components/Card'; +import Button from '../components/Button'; +import type { Collection } from '../types'; + +interface FormData { + name: string; + description: string; + parent_id: string; +} + +export default function Collections() { + const { isAdmin, isMaintainer } = useAuth(); + const [collections, setCollections] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedParents, setExpandedParents] = useState>(new Set()); + + // Create/Edit modal state + const [showCreateModal, setShowCreateModal] = useState(false); + const [showEditModal, setShowEditModal] = useState(false); + const [editingCollection, setEditingCollection] = useState(null); + const [formData, setFormData] = useState({ + name: '', + description: '', + parent_id: '', + }); + const [formError, setFormError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const fetchCollections = async () => { + setIsLoading(true); + try { + const data = await shortenerApi.collections.list(); + setCollections(data); + setError(null); + } catch (err) { + console.log('[Collections] Error fetching collections', { error: String(err) }); + setError('Failed to load collections'); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchCollections(); + }, []); + + // Validate form + const validateForm = (): boolean => { + setFormError(null); + + if (!formData.name.trim()) { + setFormError('Name is required'); + return false; + } + + return true; + }; + + // Handle create collection + const handleCreateCollection = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) return; + + setIsSubmitting(true); + try { + const payload: any = { + name: formData.name, + }; + if (formData.description) payload.description = formData.description; + if (formData.parent_id) payload.parent_id = formData.parent_id; + + await shortenerApi.collections.create(payload); + setShowCreateModal(false); + setFormData({ + name: '', + description: '', + parent_id: '', + }); + setFormError(null); + console.log('[Collections] Collection created successfully'); + fetchCollections(); + } catch (err: any) { + const msg = err?.response?.data?.error || 'Failed to create collection'; + setFormError(msg); + console.log('[Collections] Create failed', { error: msg }); + } finally { + setIsSubmitting(false); + } + }; + + // Handle edit collection + const handleEditCollection = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm() || !editingCollection) return; + + setIsSubmitting(true); + try { + const payload: any = { + name: formData.name, + }; + if (formData.description) payload.description = formData.description; + if (formData.parent_id) payload.parent_id = formData.parent_id; + + await shortenerApi.collections.update(editingCollection.id, payload); + setShowEditModal(false); + setEditingCollection(null); + setFormData({ + name: '', + description: '', + parent_id: '', + }); + setFormError(null); + console.log('[Collections] Collection updated successfully'); + fetchCollections(); + } catch (err: any) { + const msg = err?.response?.data?.error || 'Failed to update collection'; + setFormError(msg); + console.log('[Collections] Update failed', { error: msg }); + } finally { + setIsSubmitting(false); + } + }; + + // Handle delete collection + const handleDeleteCollection = async (id: string) => { + if (!confirm('Are you sure you want to delete this collection? Links in it will remain.')) return; + + try { + await shortenerApi.collections.delete(id); + console.log('[Collections] Collection deleted successfully', { id }); + fetchCollections(); + } catch (err) { + setError('Failed to delete collection'); + console.log('[Collections] Delete failed', { error: String(err) }); + } + }; + + // Open create modal + const openCreateModal = () => { + setEditingCollection(null); + setFormData({ + name: '', + description: '', + parent_id: '', + }); + setFormError(null); + setShowCreateModal(true); + }; + + // Open edit modal + const openEditModal = (collection: Collection) => { + setEditingCollection(collection); + setFormData({ + name: collection.name, + description: collection.description || '', + parent_id: collection.parent_id || '', + }); + setFormError(null); + setShowEditModal(true); + }; + + // Toggle expanded parent + const toggleExpanded = (id: string) => { + const newExpanded = new Set(expandedParents); + if (newExpanded.has(id)) { + newExpanded.delete(id); + } else { + newExpanded.add(id); + } + setExpandedParents(newExpanded); + }; + + // Get child collections + const getChildCollections = (parentId: string): Collection[] => { + return collections.filter((c) => c.parent_id === parentId); + }; + + // Get top-level collections (no parent) + const topLevelCollections = collections.filter((c) => !c.parent_id); + + // Render collection tree + const renderCollectionTree = ( + parentId: string | null = null, + level: number = 0 + ): React.ReactNode[] => { + const items = parentId === null + ? topLevelCollections + : getChildCollections(parentId as string); + + return items.map((collection) => { + const children = getChildCollections(collection.id); + const isExpanded = expandedParents.has(collection.id); + const hasChildren = children.length > 0; + + return ( +
+
0 ? `ml-${(level - 1) * 4}` : '' + }`} + > + {hasChildren && ( + + )} + {!hasChildren &&
} + +
+

{collection.name}

+ {collection.description && ( +

{collection.description}

+ )} + {collection.link_count !== undefined && ( +

{collection.link_count} links

+ )} +
+ + {(isAdmin() || isMaintainer()) && ( +
+ + +
+ )} +
+ + {/* Render children */} + {hasChildren && isExpanded && ( +
+ {renderCollectionTree(collection.id, level + 1)} +
+ )} +
+ ); + }); + }; + + const canEdit = isAdmin() || isMaintainer(); + + return ( +
+ {/* Header */} +
+
+

Collections

+

Organize your short links into collections

+
+ {canEdit && ( + + )} +
+ + {/* Error message */} + {error && ( +
+ {error} +
+ )} + + {/* Loading state */} + {isLoading && ( +
Loading collections...
+ )} + + {/* Collections tree */} + {!isLoading && collections.length === 0 && ( + +

+ No collections yet. {canEdit && 'Create your first collection!'} +

+
+ )} + + {!isLoading && collections.length > 0 && ( + {renderCollectionTree()} + )} + + {/* Create Modal */} + {showCreateModal && canEdit && ( +
+ +

Create Collection

+ + {formError && ( +
+ {formError} +
+ )} + +
+
+ + setFormData({ ...formData, name: e.target.value })} + className="w-full px-3 py-2 bg-dark-800 text-gold-100 border border-dark-600 rounded focus:outline-none focus:border-gold-400" + disabled={isSubmitting} + /> +
+ +
+ +