feat(gif): favourites and category shortcuts
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
Favourites are stored server-side per user, so one made on the phone is there on the desktop — the point of favouriting. The whole result is stored rather than an id: the provider offers no lookup by id, so an id-only favourite could not be rendered without re-finding it through search. Category chips translate their label but not their query, which goes to a provider that indexes in English. The star sits beside the tile button rather than inside it: a button within a button is invalid and swallows the click. Toggling is optimistic and reverts on failure, and favourites skip both the loading skeleton and the infinite scroll, which belong to provider-backed browsing only. Server caps favourites per user and rejects non-http(s) URLs, which become <img src> in everyone's picker.
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE `gif_favorites` (
|
||||
`user_id` text NOT NULL,
|
||||
`gif_id` text NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`preview_url` text NOT NULL,
|
||||
`url` text NOT NULL,
|
||||
`width` integer NOT NULL,
|
||||
`height` integer NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
PRIMARY KEY(`user_id`, `gif_id`),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_gif_favorites_user_id` ON `gif_favorites` (`user_id`);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,13 @@
|
||||
"when": 1788190305853,
|
||||
"tag": "0011_lethal_bruce_banner",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1788192490711,
|
||||
"tag": "0012_sour_pixie",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -566,3 +566,24 @@ export const spotifyConnections = sqliteTable('spotify_connections', {
|
||||
spotifyUserId: text('spotify_user_id'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Favourited GIFs, one row per user per GIF.
|
||||
*
|
||||
* Stores the whole result rather than an id: the provider offers no lookup by
|
||||
* id, so a favourites tab that only kept ids could not render without
|
||||
* re-searching for something the user may never find again.
|
||||
*/
|
||||
export const gifFavorites = sqliteTable('gif_favorites', {
|
||||
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
gifId: text('gif_id').notNull(),
|
||||
title: text('title').notNull(),
|
||||
previewUrl: text('preview_url').notNull(),
|
||||
url: text('url').notNull(),
|
||||
width: integer('width').notNull(),
|
||||
height: integer('height').notNull(),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
}, (table) => ({
|
||||
pk: primaryKey({ columns: [table.userId, table.gifId] }),
|
||||
userIdx: index('idx_gif_favorites_user_id').on(table.userId),
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import type { GifResult } from '@backspace/shared';
|
||||
@@ -178,4 +178,68 @@ export async function gifRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(200).send({ results: [], next: '' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Favourites ──────────────────────────────────────────────────────────
|
||||
// Kept per user and synced server-side so a favourite made on the phone is
|
||||
// there on the desktop, which is the whole point of favouriting.
|
||||
|
||||
/** Cap per user: a favourites tab is a shortlist, not an archive. */
|
||||
const MAX_FAVORITES = 200;
|
||||
|
||||
app.get('/api/gif/favorites', { preHandler: authenticate }, async (request, reply) => {
|
||||
const db = getDb();
|
||||
const rows = db.select().from(schema.gifFavorites)
|
||||
.where(eq(schema.gifFavorites.userId, request.userId))
|
||||
.orderBy(desc(schema.gifFavorites.createdAt))
|
||||
.all();
|
||||
const results: GifResult[] = rows.map((r) => ({
|
||||
id: r.gifId, title: r.title, previewUrl: r.previewUrl,
|
||||
url: r.url, width: r.width, height: r.height,
|
||||
}));
|
||||
return reply.code(200).send({ results });
|
||||
});
|
||||
|
||||
app.post<{ Body: GifResult }>('/api/gif/favorites', { preHandler: authenticate }, async (request, reply) => {
|
||||
const { id, title, previewUrl, url, width, height } = request.body ?? ({} as GifResult);
|
||||
if (!id || typeof id !== 'string' || !previewUrl || !url) {
|
||||
return reply.code(400).send({ error: 'id, previewUrl and url are required', statusCode: 400 });
|
||||
}
|
||||
// Only http(s): these become <img src> for everyone who opens the picker.
|
||||
for (const candidate of [previewUrl, url]) {
|
||||
if (!/^https?:\/\//.test(candidate)) {
|
||||
return reply.code(400).send({ error: 'previewUrl and url must be http(s)', statusCode: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const count = db.select().from(schema.gifFavorites)
|
||||
.where(eq(schema.gifFavorites.userId, request.userId)).all().length;
|
||||
const existing = db.select().from(schema.gifFavorites)
|
||||
.where(and(eq(schema.gifFavorites.userId, request.userId), eq(schema.gifFavorites.gifId, id))).get();
|
||||
if (!existing && count >= MAX_FAVORITES) {
|
||||
return reply.code(409).send({ error: `At most ${MAX_FAVORITES} favourites`, statusCode: 409 });
|
||||
}
|
||||
|
||||
db.insert(schema.gifFavorites).values({
|
||||
userId: request.userId,
|
||||
gifId: id,
|
||||
title: typeof title === 'string' ? title.slice(0, 200) : '',
|
||||
previewUrl,
|
||||
url,
|
||||
width: Number.isFinite(width) ? width : 0,
|
||||
height: Number.isFinite(height) ? height : 0,
|
||||
createdAt: Date.now(),
|
||||
}).onConflictDoNothing().run();
|
||||
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/gif/favorites/:id', { preHandler: authenticate }, async (request, reply) => {
|
||||
const db = getDb();
|
||||
db.delete(schema.gifFavorites)
|
||||
.where(and(eq(schema.gifFavorites.userId, request.userId), eq(schema.gifFavorites.gifId, request.params.id)))
|
||||
.run();
|
||||
return reply.code(204).send();
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user