feat(sacred-valley): hybrid free/snap canvas + blank & blackflame cards (2.8.0)
Replace masonry grid with an absolute-positioned 12-col canvas: drag to move, corner to resize, per-card free/overlap toggle (Alt = no-snap). Geometry persisted (migration 027: dashboard_layout.geom + extras). Two new addable decorative cards: blank spacer + animated blackflame (canvas particle flame). Old layout auto-migrates by flow-placement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -8,11 +8,23 @@ import * as repo from '../../db/repos/dashboard_layout.js';
|
||||
export const router = Router();
|
||||
router.use(requireOwner);
|
||||
|
||||
const geomCell = z.object({
|
||||
x: z.number(), y: z.number(),
|
||||
w: z.number().positive(), h: z.number().positive(),
|
||||
free: z.boolean().optional()
|
||||
});
|
||||
const layoutSchema = z.object({
|
||||
card_order: z.array(z.string()).default([]),
|
||||
hidden: z.array(z.string()).default([]),
|
||||
// Per-card width: an integer column span 1–12 (legacy 's'|'m'|'l' still accepted).
|
||||
sizes: z.record(z.union([z.number().int().min(1).max(12), z.enum(['s', 'm', 'l'])])).default({})
|
||||
sizes: z.record(z.union([z.number().int().min(1).max(12), z.enum(['s', 'm', 'l'])])).default({}),
|
||||
// Hybrid-canvas geometry, keyed by card id → {x,y,w,h,free} in 12-col grid units.
|
||||
geom: z.record(geomCell).default({}),
|
||||
// User-added decorative card instances that must survive reloads.
|
||||
extras: z.array(z.object({
|
||||
id: z.string().min(1).max(64),
|
||||
type: z.enum(['blank', 'blackflame'])
|
||||
})).default([])
|
||||
});
|
||||
|
||||
router.get('/layout', asyncWrap(async (_req, res) => {
|
||||
|
||||
7
lib/db/migrations/027_dashboard_geom.sql
Normal file
7
lib/db/migrations/027_dashboard_geom.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- 027_dashboard_geom.sql
|
||||
-- Sacred Valley hybrid canvas: free/snap geometry per card + decorative card
|
||||
-- instances (blank spacers, blackflame). geom is keyed by card id →
|
||||
-- {x,y,w,h,free} in (fractional) 12-col grid units; extras lists the
|
||||
-- user-added decorative cards so they survive reloads.
|
||||
ALTER TABLE dashboard_layout ADD COLUMN IF NOT EXISTS geom jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
ALTER TABLE dashboard_layout ADD COLUMN IF NOT EXISTS extras jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
@@ -1,24 +1,28 @@
|
||||
import { pool } from '../pool.js';
|
||||
|
||||
const DEFAULTS = { card_order: [], hidden: [], sizes: {} };
|
||||
const DEFAULTS = { card_order: [], hidden: [], sizes: {}, geom: {}, extras: [] };
|
||||
|
||||
export async function get() {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT card_order, hidden, sizes FROM dashboard_layout WHERE owner_key = 'owner'`
|
||||
`SELECT card_order, hidden, sizes, geom, extras
|
||||
FROM dashboard_layout WHERE owner_key = 'owner'`
|
||||
);
|
||||
return rows[0] || { ...DEFAULTS };
|
||||
}
|
||||
|
||||
export async function put({ card_order = [], hidden = [], sizes = {} }) {
|
||||
export async function put({ card_order = [], hidden = [], sizes = {}, geom = {}, extras = [] }) {
|
||||
await pool.query(
|
||||
`INSERT INTO dashboard_layout (owner_key, card_order, hidden, sizes, updated_at)
|
||||
VALUES ('owner', $1::jsonb, $2::jsonb, $3::jsonb, now())
|
||||
`INSERT INTO dashboard_layout (owner_key, card_order, hidden, sizes, geom, extras, updated_at)
|
||||
VALUES ('owner', $1::jsonb, $2::jsonb, $3::jsonb, $4::jsonb, $5::jsonb, now())
|
||||
ON CONFLICT (owner_key) DO UPDATE
|
||||
SET card_order = EXCLUDED.card_order,
|
||||
hidden = EXCLUDED.hidden,
|
||||
sizes = EXCLUDED.sizes,
|
||||
geom = EXCLUDED.geom,
|
||||
extras = EXCLUDED.extras,
|
||||
updated_at = now()`,
|
||||
[JSON.stringify(card_order), JSON.stringify(hidden), JSON.stringify(sizes)]
|
||||
[JSON.stringify(card_order), JSON.stringify(hidden), JSON.stringify(sizes),
|
||||
JSON.stringify(geom), JSON.stringify(extras)]
|
||||
);
|
||||
return get();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user