mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 09:53:40 +02:00
Pre-launch theme system audit found multiple parallel layers in themes.css
(--theme-X full hsl strings, --X partial shadcn aliases, --color-X populated
by runtime store with raw channels) plus dead-code companion files. The
inconsistency caused light-mode regressions when scoped-CSS consumers
wrote `var(--color-X)` standalone — the variable holds raw HSL channels
which is invalid as a color value, browser fell back to inherited (white).
Rewrite to one consistent layer:
- Source of truth: --color-X defined as raw HSL channels (e.g.
`0 0% 17%`) in :root, .dark, and all variant [data-theme="..."]
blocks. Matches the format the runtime store
(@mana/shared-theme/src/utils.ts) writes, eliminating the
static-fallback-vs-runtime mismatch and the corresponding flash
of unstyled content on hydration.
- @theme inline uses self-reference + Tailwind v4 <alpha-value>
placeholder so utility classes generate correctly AND opacity
modifiers work: `text-foreground/50` → `hsl(var(--color-foreground) / 0.5)`.
- @layer components (.btn-primary, .card, .badge, etc.) wraps
var(--color-X) refs with hsl() — they were broken in light mode
too for the same reason.
Convention going forward (also documented in the file header):
1. Markup: use Tailwind utility classes (text-foreground, bg-card, …)
2. Scoped CSS: hsl(var(--color-X)) — always wrap with hsl()
3. NEVER raw var(--color-X) in CSS — that's the bug pattern
Net file: 692 → 580 LOC. Single source layer, no indirection.
Also delete dead companion files (zero imports anywhere):
- tailwind-v4.css (had broken self-reference, never imported)
- theme-variables.css (legacy hex-based palette)
- components.css (legacy component utilities)
- index.js / preset.js / colors.js (Tailwind v3 preset format,
irrelevant under Tailwind v4)
package.json exports map shrinks accordingly to just `./themes.css`.
Consumers using `hsl(var(--color-X))` (~379 files across mana-web,
manavoxel-web, arcade-web) keep working unchanged — the public API
name `--color-X` is preserved. Only the broken pattern `var(--color-X)`
(~61 files) needs a follow-up sweep, handled in a separate commit.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
251 lines
7 KiB
TypeScript
251 lines
7 KiB
TypeScript
/**
|
|
* Presi module — Share link lookups
|
|
* Ported from apps/presi/apps/server
|
|
*
|
|
* All CRUD (decks, slides, themes) is handled client-side via local-first + sync.
|
|
* This module handles public share links and share management.
|
|
*/
|
|
|
|
import { Hono } from 'hono';
|
|
import { eq, and, gt, or, isNull, asc } from 'drizzle-orm';
|
|
import { HTTPException } from 'hono/http-exception';
|
|
import { authMiddleware } from '@mana/shared-hono/auth';
|
|
import type { AuthVariables } from '@mana/shared-hono';
|
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
import postgres from 'postgres';
|
|
import {
|
|
pgSchema,
|
|
uuid,
|
|
text,
|
|
boolean,
|
|
timestamp,
|
|
integer,
|
|
jsonb,
|
|
index,
|
|
} from 'drizzle-orm/pg-core';
|
|
import { relations } from 'drizzle-orm';
|
|
|
|
// ─── DB Schema (read-only for share lookups) ────────────────
|
|
|
|
const DATABASE_URL =
|
|
process.env.DATABASE_URL ?? 'postgresql://mana:devpassword@localhost:5432/mana_platform';
|
|
|
|
const presiSchema = pgSchema('presi');
|
|
|
|
const decks = presiSchema.table('decks', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
userId: text('user_id').notNull(),
|
|
title: text('title').notNull(),
|
|
description: text('description'),
|
|
themeId: uuid('theme_id'),
|
|
isPublic: boolean('is_public').default(false).notNull(),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
|
});
|
|
|
|
const slides = presiSchema.table(
|
|
'slides',
|
|
{
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
deckId: uuid('deck_id').notNull(),
|
|
order: integer('order').default(0).notNull(),
|
|
content: jsonb('content'),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
},
|
|
(table) => [index('slides_deck_order_api_idx').on(table.deckId, table.order)]
|
|
);
|
|
|
|
const themes = presiSchema.table('themes', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
name: text('name').notNull(),
|
|
colors: jsonb('colors'),
|
|
fonts: jsonb('fonts'),
|
|
isDefault: boolean('is_default').default(false),
|
|
});
|
|
|
|
const sharedDecks = presiSchema.table(
|
|
'shared_decks',
|
|
{
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
deckId: uuid('deck_id').notNull(),
|
|
shareCode: text('share_code').notNull().unique(),
|
|
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
|
},
|
|
(table) => [index('shared_decks_deck_id_api_idx').on(table.deckId)]
|
|
);
|
|
|
|
const decksRelations = relations(decks, ({ many }) => ({
|
|
slides: many(slides),
|
|
sharedDecks: many(sharedDecks),
|
|
}));
|
|
|
|
const slidesRelations = relations(slides, ({ one }) => ({
|
|
deck: one(decks, { fields: [slides.deckId], references: [decks.id] }),
|
|
}));
|
|
|
|
const sharedDecksRelations = relations(sharedDecks, ({ one }) => ({
|
|
deck: one(decks, { fields: [sharedDecks.deckId], references: [decks.id] }),
|
|
}));
|
|
|
|
const connection = postgres(DATABASE_URL, { max: 5, idle_timeout: 20 });
|
|
const db = drizzle(connection, {
|
|
schema: {
|
|
decks,
|
|
slides,
|
|
themes,
|
|
sharedDecks,
|
|
decksRelations,
|
|
slidesRelations,
|
|
sharedDecksRelations,
|
|
},
|
|
});
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────
|
|
|
|
function generateShareCode(): string {
|
|
const bytes = new Uint8Array(6);
|
|
crypto.getRandomValues(bytes);
|
|
return Array.from(bytes)
|
|
.map((b) => b.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
}
|
|
|
|
// ─── Routes ─────────────────────────────────────────────────
|
|
|
|
const routes = new Hono<{ Variables: AuthVariables }>();
|
|
|
|
// ─── Public endpoint (no auth) ──────────────────────────────
|
|
|
|
routes.get('/share/:code', async (c) => {
|
|
const code = c.req.param('code');
|
|
|
|
const share = await db.query.sharedDecks.findFirst({
|
|
where: and(
|
|
eq(sharedDecks.shareCode, code),
|
|
or(isNull(sharedDecks.expiresAt), gt(sharedDecks.expiresAt, new Date()))
|
|
),
|
|
});
|
|
|
|
if (!share) {
|
|
throw new HTTPException(404, { message: 'Shared deck not found or link has expired' });
|
|
}
|
|
|
|
// Load deck with slides and theme
|
|
const deck = await db.query.decks.findFirst({
|
|
where: eq(decks.id, share.deckId),
|
|
});
|
|
|
|
if (!deck) {
|
|
throw new HTTPException(404, { message: 'Deck not found' });
|
|
}
|
|
|
|
const deckSlides = await db.query.slides.findMany({
|
|
where: eq(slides.deckId, deck.id),
|
|
orderBy: [asc(slides.order)],
|
|
});
|
|
|
|
let theme = null;
|
|
if (deck.themeId) {
|
|
theme = await db.query.themes.findFirst({
|
|
where: eq(themes.id, deck.themeId),
|
|
});
|
|
}
|
|
|
|
return c.json({
|
|
...deck,
|
|
slides: deckSlides,
|
|
theme,
|
|
});
|
|
});
|
|
|
|
// ─── Authenticated endpoints ────────────────────────────────
|
|
|
|
routes.use('/share/deck/*', authMiddleware());
|
|
|
|
routes.post('/share/deck/:deckId', async (c) => {
|
|
const userId = c.get('userId');
|
|
const deckId = c.req.param('deckId');
|
|
|
|
// Verify ownership
|
|
const deck = await db.query.decks.findFirst({
|
|
where: and(eq(decks.id, deckId), eq(decks.userId, userId)),
|
|
});
|
|
if (!deck) {
|
|
throw new HTTPException(403, { message: 'You do not own this deck' });
|
|
}
|
|
|
|
// Check for existing valid share
|
|
const existing = await db.query.sharedDecks.findFirst({
|
|
where: and(
|
|
eq(sharedDecks.deckId, deckId),
|
|
or(isNull(sharedDecks.expiresAt), gt(sharedDecks.expiresAt, new Date()))
|
|
),
|
|
});
|
|
|
|
if (existing) {
|
|
return c.json(existing);
|
|
}
|
|
|
|
// Parse optional expiry
|
|
const body = (await c.req.json<{ expiresAt?: string }>().catch(() => ({}))) as {
|
|
expiresAt?: string;
|
|
};
|
|
|
|
const [share] = await db
|
|
.insert(sharedDecks)
|
|
.values({
|
|
deckId,
|
|
shareCode: generateShareCode(),
|
|
expiresAt: body.expiresAt ? new Date(body.expiresAt) : null,
|
|
})
|
|
.returning();
|
|
|
|
return c.json(share, 201);
|
|
});
|
|
|
|
routes.get('/share/deck/:deckId/links', async (c) => {
|
|
const userId = c.get('userId');
|
|
const deckId = c.req.param('deckId');
|
|
|
|
// Verify ownership
|
|
const deck = await db.query.decks.findFirst({
|
|
where: and(eq(decks.id, deckId), eq(decks.userId, userId)),
|
|
});
|
|
if (!deck) {
|
|
throw new HTTPException(403, { message: 'You do not own this deck' });
|
|
}
|
|
|
|
const links = await db.query.sharedDecks.findMany({
|
|
where: eq(sharedDecks.deckId, deckId),
|
|
});
|
|
|
|
return c.json(links);
|
|
});
|
|
|
|
routes.delete('/share/:shareId', authMiddleware(), async (c) => {
|
|
const userId = c.get('userId');
|
|
const shareId = c.req.param('shareId');
|
|
if (!shareId) throw new HTTPException(400, { message: 'shareId required' });
|
|
|
|
const share = await db.query.sharedDecks.findFirst({
|
|
where: eq(sharedDecks.id, shareId),
|
|
});
|
|
|
|
if (!share) {
|
|
throw new HTTPException(404, { message: 'Share not found' });
|
|
}
|
|
|
|
// Verify ownership of the deck
|
|
const deck = await db.query.decks.findFirst({
|
|
where: eq(decks.id, share.deckId),
|
|
});
|
|
if (!deck || deck.userId !== userId) {
|
|
throw new HTTPException(403, { message: 'You do not own this deck' });
|
|
}
|
|
|
|
await db.delete(sharedDecks).where(eq(sharedDecks.id, shareId));
|
|
return c.json({ success: true });
|
|
});
|
|
|
|
export { routes as presiRoutes };
|