|
|
@@ -1,11 +1,40 @@
|
|
|
import type { ShareLink } from '../types';
|
|
|
|
|
|
+/**
|
|
|
+ * Share links API using the generic CRUD endpoints.
|
|
|
+ * Share links are stored in the `share_links` project table.
|
|
|
+ *
|
|
|
+ * Table schema:
|
|
|
+ * - id: string (primary key, auto-generated)
|
|
|
+ * - link_code: string (unique code for the share URL)
|
|
|
+ * - image_id: string (references the image's id)
|
|
|
+ * - image_short_code: string (the image's short code for convenience)
|
|
|
+ * - created_by: string (user ID if authenticated)
|
|
|
+ * - session_token: string (for non-auth users)
|
|
|
+ * - expires_at: string (ISO timestamp)
|
|
|
+ * - view_count: integer
|
|
|
+ * - last_viewed_at: string (ISO timestamp, nullable)
|
|
|
+ * - is_revoked: boolean
|
|
|
+ * - created_at: string (ISO timestamp)
|
|
|
+ */
|
|
|
+
|
|
|
const API_BASE = '/api';
|
|
|
|
|
|
+function generateLinkCode(): string {
|
|
|
+ const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
|
+ let result = '';
|
|
|
+ for (let i = 0; i < 12; i++) {
|
|
|
+ result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
|
+ }
|
|
|
+ return result;
|
|
|
+}
|
|
|
+
|
|
|
export async function createShareLink(
|
|
|
shortCode: string,
|
|
|
expiresIn: number,
|
|
|
- token?: string
|
|
|
+ token?: string,
|
|
|
+ sessionToken?: string,
|
|
|
+ imageId?: string
|
|
|
): Promise<{ shareUrl: string; linkCode: string; expiresAt: string }> {
|
|
|
const headers: Record<string, string> = {
|
|
|
'Content-Type': 'application/json',
|
|
|
@@ -13,11 +42,26 @@ export async function createShareLink(
|
|
|
if (token) {
|
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
|
}
|
|
|
+ if (sessionToken) {
|
|
|
+ headers['X-Session-Token'] = sessionToken;
|
|
|
+ }
|
|
|
+
|
|
|
+ const linkCode = generateLinkCode();
|
|
|
+ const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
|
|
|
|
|
|
- const res = await fetch(`${API_BASE}/images/${shortCode}/links`, {
|
|
|
+ // Create share link via CRUD API
|
|
|
+ const res = await fetch(`${API_BASE}/share_links`, {
|
|
|
method: 'POST',
|
|
|
headers,
|
|
|
- body: JSON.stringify({ expires_in: expiresIn }),
|
|
|
+ body: JSON.stringify({
|
|
|
+ link_code: linkCode,
|
|
|
+ image_id: imageId || '',
|
|
|
+ image_short_code: shortCode,
|
|
|
+ expires_at: expiresAt,
|
|
|
+ view_count: 0,
|
|
|
+ is_revoked: false,
|
|
|
+ session_token: sessionToken || '',
|
|
|
+ }),
|
|
|
});
|
|
|
|
|
|
if (!res.ok) {
|
|
|
@@ -25,54 +69,176 @@ export async function createShareLink(
|
|
|
throw new Error(error.error || 'Failed to create share link');
|
|
|
}
|
|
|
|
|
|
- return res.json();
|
|
|
+ const data = await res.json();
|
|
|
+ const shareUrl = `${window.location.origin}/s/${linkCode}`;
|
|
|
+
|
|
|
+ return {
|
|
|
+ shareUrl,
|
|
|
+ linkCode,
|
|
|
+ expiresAt: data.expires_at || expiresAt,
|
|
|
+ };
|
|
|
}
|
|
|
|
|
|
export async function listShareLinks(
|
|
|
shortCode: string,
|
|
|
- token?: string
|
|
|
+ token?: string,
|
|
|
+ sessionToken?: string
|
|
|
): Promise<ShareLink[]> {
|
|
|
const headers: Record<string, string> = {};
|
|
|
if (token) {
|
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
|
}
|
|
|
+ if (sessionToken) {
|
|
|
+ headers['X-Session-Token'] = sessionToken;
|
|
|
+ }
|
|
|
+
|
|
|
+ // Query share links filtered by image_short_code
|
|
|
+ const params = new URLSearchParams({
|
|
|
+ filter: `image_short_code.eq.${shortCode}`,
|
|
|
+ order: 'created_at.desc',
|
|
|
+ });
|
|
|
|
|
|
- const res = await fetch(`${API_BASE}/images/${shortCode}/links`, { headers });
|
|
|
+ const res = await fetch(`${API_BASE}/share_links?${params}`, { headers });
|
|
|
|
|
|
if (!res.ok) {
|
|
|
throw new Error('Failed to load share links');
|
|
|
}
|
|
|
|
|
|
const data = await res.json();
|
|
|
- return (data.links || []).map((link: Record<string, unknown>) => ({
|
|
|
- id: link.id as string,
|
|
|
- linkCode: link.link_code as string,
|
|
|
- shareUrl: link.share_url as string,
|
|
|
- expiresAt: new Date(link.expires_at as string),
|
|
|
- viewCount: link.view_count as number,
|
|
|
- lastViewedAt: link.last_viewed_at ? new Date(link.last_viewed_at as string) : undefined,
|
|
|
- isRevoked: link.is_revoked as boolean,
|
|
|
- isValid: link.is_valid as boolean,
|
|
|
- createdAt: new Date(link.created_at as string),
|
|
|
- }));
|
|
|
+ const now = new Date();
|
|
|
+
|
|
|
+ return (data.data || []).map((link: Record<string, unknown>) => {
|
|
|
+ const expiresAt = new Date(link.expires_at as string);
|
|
|
+ const isExpired = expiresAt < now;
|
|
|
+ const isRevoked = link.is_revoked as boolean;
|
|
|
+
|
|
|
+ return {
|
|
|
+ id: link.id as string,
|
|
|
+ linkCode: link.link_code as string,
|
|
|
+ shareUrl: `${window.location.origin}/s/${link.link_code}`,
|
|
|
+ expiresAt,
|
|
|
+ viewCount: (link.view_count as number) || 0,
|
|
|
+ lastViewedAt: link.last_viewed_at ? new Date(link.last_viewed_at as string) : undefined,
|
|
|
+ isRevoked,
|
|
|
+ isValid: !isRevoked && !isExpired,
|
|
|
+ createdAt: new Date(link.created_at as string),
|
|
|
+ };
|
|
|
+ });
|
|
|
}
|
|
|
|
|
|
export async function revokeShareLink(
|
|
|
- shortCode: string,
|
|
|
+ _shortCode: string, // Kept for API compatibility, but not used with CRUD approach
|
|
|
linkCode: string,
|
|
|
- token?: string
|
|
|
+ token?: string,
|
|
|
+ sessionToken?: string
|
|
|
): Promise<void> {
|
|
|
- const headers: Record<string, string> = {};
|
|
|
+ const headers: Record<string, string> = {
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+ };
|
|
|
if (token) {
|
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
|
}
|
|
|
+ if (sessionToken) {
|
|
|
+ headers['X-Session-Token'] = sessionToken;
|
|
|
+ }
|
|
|
+
|
|
|
+ // First find the link ID by link_code
|
|
|
+ const params = new URLSearchParams({
|
|
|
+ filter: `link_code.eq.${linkCode}`,
|
|
|
+ limit: '1',
|
|
|
+ });
|
|
|
+
|
|
|
+ const findRes = await fetch(`${API_BASE}/share_links?${params}`, { headers });
|
|
|
+ if (!findRes.ok) {
|
|
|
+ throw new Error('Failed to find share link');
|
|
|
+ }
|
|
|
|
|
|
- const res = await fetch(`${API_BASE}/images/${shortCode}/links/${linkCode}`, {
|
|
|
- method: 'DELETE',
|
|
|
+ const findData = await findRes.json();
|
|
|
+ if (!findData.data || findData.data.length === 0) {
|
|
|
+ throw new Error('Share link not found');
|
|
|
+ }
|
|
|
+
|
|
|
+ const linkId = findData.data[0].id;
|
|
|
+
|
|
|
+ // Update the link to mark it as revoked
|
|
|
+ const res = await fetch(`${API_BASE}/share_links/${linkId}`, {
|
|
|
+ method: 'PUT',
|
|
|
headers,
|
|
|
+ body: JSON.stringify({
|
|
|
+ is_revoked: true,
|
|
|
+ }),
|
|
|
});
|
|
|
|
|
|
if (!res.ok) {
|
|
|
throw new Error('Failed to revoke share link');
|
|
|
}
|
|
|
}
|
|
|
+
|
|
|
+export async function getShareLinkByCode(
|
|
|
+ linkCode: string
|
|
|
+): Promise<ShareLink | null> {
|
|
|
+ const params = new URLSearchParams({
|
|
|
+ filter: `link_code.eq.${linkCode}`,
|
|
|
+ limit: '1',
|
|
|
+ });
|
|
|
+
|
|
|
+ const res = await fetch(`${API_BASE}/share_links?${params}`);
|
|
|
+
|
|
|
+ if (!res.ok) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ const data = await res.json();
|
|
|
+ if (!data.data || data.data.length === 0) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ const link = data.data[0];
|
|
|
+ const now = new Date();
|
|
|
+ const expiresAt = new Date(link.expires_at as string);
|
|
|
+ const isExpired = expiresAt < now;
|
|
|
+ const isRevoked = link.is_revoked as boolean;
|
|
|
+
|
|
|
+ return {
|
|
|
+ id: link.id as string,
|
|
|
+ linkCode: link.link_code as string,
|
|
|
+ shareUrl: `${window.location.origin}/s/${link.link_code}`,
|
|
|
+ expiresAt,
|
|
|
+ viewCount: (link.view_count as number) || 0,
|
|
|
+ lastViewedAt: link.last_viewed_at ? new Date(link.last_viewed_at as string) : undefined,
|
|
|
+ isRevoked,
|
|
|
+ isValid: !isRevoked && !isExpired,
|
|
|
+ createdAt: new Date(link.created_at as string),
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+export async function incrementViewCount(
|
|
|
+ linkCode: string
|
|
|
+): Promise<void> {
|
|
|
+ // Find the link
|
|
|
+ const params = new URLSearchParams({
|
|
|
+ filter: `link_code.eq.${linkCode}`,
|
|
|
+ limit: '1',
|
|
|
+ });
|
|
|
+
|
|
|
+ const findRes = await fetch(`${API_BASE}/share_links?${params}`);
|
|
|
+ if (!findRes.ok) return;
|
|
|
+
|
|
|
+ const findData = await findRes.json();
|
|
|
+ if (!findData.data || findData.data.length === 0) return;
|
|
|
+
|
|
|
+ const link = findData.data[0];
|
|
|
+ const currentCount = (link.view_count as number) || 0;
|
|
|
+
|
|
|
+ // Update view count
|
|
|
+ await fetch(`${API_BASE}/share_links/${link.id}`, {
|
|
|
+ method: 'PUT',
|
|
|
+ headers: {
|
|
|
+ 'Content-Type': 'application/json',
|
|
|
+ },
|
|
|
+ body: JSON.stringify({
|
|
|
+ view_count: currentCount + 1,
|
|
|
+ last_viewed_at: new Date().toISOString(),
|
|
|
+ }),
|
|
|
+ });
|
|
|
+}
|