diff --git a/convex.json b/convex.json new file mode 100644 index 0000000..a3cd501 --- /dev/null +++ b/convex.json @@ -0,0 +1,7 @@ +{ + "node": { + "externalPackages": [ + "image-size" + ] + } +} diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 9d505b1..7c87fce 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -13,9 +13,10 @@ import type { FilterApi, FunctionReference, } from "convex/server"; -import type * as details from "../details.js"; import type * as features from "../features.js"; -import type * as previews from "../previews.js"; +import type * as imageMigration from "../imageMigration.js"; +import type * as internal_ from "../internal.js"; +import type * as migrations from "../migrations.js"; import type * as projects from "../projects.js"; /** @@ -27,9 +28,10 @@ import type * as projects from "../projects.js"; * ``` */ declare const fullApi: ApiFromModules<{ - details: typeof details; features: typeof features; - previews: typeof previews; + imageMigration: typeof imageMigration; + internal: typeof internal_; + migrations: typeof migrations; projects: typeof projects; }>; export declare const api: FilterApi< diff --git a/convex/details.ts b/convex/details.ts deleted file mode 100644 index 96f0fc5..0000000 --- a/convex/details.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { ConvexError, v } from 'convex/values'; -import type { Doc, Id } from './_generated/dataModel'; -import { internalMutation, type MutationCtx, query } from './_generated/server'; - -export const loadProjectDetails = query({ - args: { projectId: v.id('projects') }, - handler: async (ctx, { projectId }) => { - const project = await ctx.db.get(projectId); - - if (!project || project.deletedAt !== null) { - throw new ConvexError({ - message: 'Project not found', - code: 404, - }); - } - - const details = await ctx.db - .query('details') - .withIndex('project', (q) => q.eq('projectId', projectId)) - .filter((q) => q.eq(q.field('deletedAt'), null)) - .unique(); - - if (!details) { - throw new ConvexError({ - message: 'Project details not found', - code: 404, - }); - } - - const { title } = project; - const { coverImageId, embedId } = details; - const coverImageUrl: string | null = coverImageId - ? await ctx.storage.getUrl(coverImageId) - : null; - const embed: Doc<'embeds'> | null = embedId - ? await ctx.db.get(embedId) - : null; - - return { - ...details, - coverImage: coverImageUrl - ? { - alt: title, - url: coverImageUrl, - } - : null, - embed: embed - ? { - ...embed, - title, - } - : null, - }; - }, -}); - -const services = v.union( - v.literal('bandcamp'), - v.literal('youtube'), - v.literal('soundcloud'), -); - -const getOrInsertProjectDetails = async ( - ctx: MutationCtx, - projectId: Id<'projects'>, -) => { - let details = await ctx.db - .query('details') - .withIndex('project', (q) => q.eq('projectId', projectId)) - .filter((q) => q.eq(q.field('deletedAt'), null)) - .unique(); - - if (!details) { - const insertedId = await ctx.db.insert('details', { - content: null, - coverImageId: null, - deletedAt: null, - embedId: null, - projectId, - }); - details = await ctx.db.get(insertedId); - } - - if (!details) { - throw new ConvexError({ - message: 'Details not found', - code: 500, - }); - } - - return details; -}; - -export const attachProjectEmbed = internalMutation({ - args: { - projectId: v.id('projects'), - service: services, - src: v.string(), - }, - handler: async (ctx, { projectId, service, src }) => { - const project = await ctx.db.get(projectId); - - if (!project) { - throw new ConvexError({ - message: 'Project not found', - code: 500, - }); - } - - const existingDetails = await getOrInsertProjectDetails(ctx, projectId); - const existingEmbed = existingDetails?.embedId - ? await ctx.db.get(existingDetails?.embedId) - : null; - - if (existingEmbed) { - await ctx.db.delete(existingEmbed._id); - } - - const embedId = await ctx.db.insert('embeds', { - deletedAt: null, - service, - src, - }); - - return await ctx.db.patch(existingDetails._id, { - embedId, - }); - }, -}); - -export const attachProjectCoverImage = internalMutation({ - args: { - coverImageId: v.id('_storage'), - projectId: v.id('projects'), - }, - handler: async (ctx, { coverImageId, projectId }) => { - const project = await ctx.db.get(projectId); - - if (!project) { - throw new ConvexError({ - message: 'Project not found', - code: 500, - }); - } - - const existingCoverImage = await ctx.storage.getUrl(coverImageId); - - if (!existingCoverImage) { - throw new ConvexError({ - message: 'Cover image does not exist', - code: 500, - }); - } - - const existingDetails = await getOrInsertProjectDetails(ctx, projectId); - - return await ctx.db.patch(existingDetails._id, { - coverImageId, - }); - }, -}); diff --git a/convex/imageMigration.ts b/convex/imageMigration.ts new file mode 100644 index 0000000..76c5a9c --- /dev/null +++ b/convex/imageMigration.ts @@ -0,0 +1,119 @@ +'use node'; + +import sizeOf from 'image-size'; +import { internal } from './_generated/api'; +import type { Id } from './_generated/dataModel'; +import { internalAction } from './_generated/server'; + +type UpdatePayload = { + coverImageId: Id<'images'> | null; + previewImageId: Id<'images'> | null; + projectId: Id<'projects'>; +}; + +export const calculateImageDimensions = internalAction({ + args: {}, + handler: async (ctx) => { + const details = await ctx.runQuery(internal.migrations.collectAllDetails); + const previews = await ctx.runQuery(internal.migrations.collectAllPreviews); + const projectIds = new Set>([ + ...details.map((d) => d.projectId), + ...previews.map((p) => p.projectId), + ]); + const updates: Map, UpdatePayload> = new Map(); + const res: Id<'projects'>[] = []; + + for (const projectId of projectIds) { + updates.set(projectId, { + coverImageId: null, + previewImageId: null, + projectId, + }); + } + + for (const detail of details) { + const { coverImageId: initialStorageId, projectId } = detail; + + if (!initialStorageId) { + continue; + } + + const storageItem = await ctx.storage.get(initialStorageId); + const payload = updates.get(detail.projectId); + + if (!storageItem || !payload) { + throw new Error(`Cannot find storage item: ${initialStorageId}`); + } + + const buffer = await storageItem.arrayBuffer(); + const dimensions = sizeOf(Buffer.from(buffer)); + + console.log({ dimensions, storageItem, detail }); + + if (!dimensions.width || !dimensions.height) { + throw new Error(`Cannot calculate dimensions: ${initialStorageId}`); + } + + const insertedImageId = await ctx.runMutation( + internal.migrations.createImage, + { + mimeType: storageItem.type, + naturalHeight: dimensions.width, + naturalWidth: dimensions.height, + size: storageItem.size, + storageId: initialStorageId, + }, + ); + + updates.set(projectId, { + ...payload, + coverImageId: insertedImageId, + }); + } + + for (const preview of previews) { + const { storageId: initialStorageId, projectId } = preview; + const payload = updates.get(projectId); + const storageItem = await ctx.storage.get(initialStorageId); + + if (!storageItem || !payload) { + throw new Error(`Cannot find storage item: ${initialStorageId}`); + } + + const buffer = await storageItem.arrayBuffer(); + const dimensions = sizeOf(Buffer.from(buffer)); + + console.log({ dimensions, storageItem, preview }); + + if (!dimensions.width || !dimensions.height) { + throw new Error(`Cannot calculate dimensions: ${initialStorageId}`); + } + + const insertedImageId = await ctx.runMutation( + internal.migrations.createImage, + { + mimeType: storageItem.type, + naturalHeight: dimensions.width, + naturalWidth: dimensions.height, + size: storageItem.size, + storageId: initialStorageId, + }, + ); + + updates.set(projectId, { + ...payload, + previewImageId: insertedImageId, + }); + } + + for (const [projectId, payload] of updates.entries()) { + await ctx.runMutation(internal.migrations.updateProjectImages, { + ...payload, + }); + + res.push(projectId); + } + + return res; + }, +}); diff --git a/convex/internal.ts b/convex/internal.ts new file mode 100644 index 0000000..2d75124 --- /dev/null +++ b/convex/internal.ts @@ -0,0 +1,239 @@ +import { ConvexError, v } from 'convex/values'; +import type { Id } from './_generated/dataModel'; +import { + internalMutation, + mutation, + query, + type MutationCtx, +} from './_generated/server'; + +export const collectAllProjects = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query('projects').order('asc').collect(); + }, +}); + +export const reorderProjects = internalMutation({ + args: { + id: v.id('projects'), + order: v.number(), + }, + handler: async (ctx, { id: targetId, order: targetOrder }) => { + const projects = await ctx.db.query('projects').collect(); + const { updates } = projects + .sort((a, b) => a.order - b.order) + .reduce<{ updates: Map, number>; prev: number }>( + (res, project) => { + let updatedOrder; + + if (project._id === targetId) { + updatedOrder = targetOrder; + } else if (project.deletedAt) { + updatedOrder = Number.MAX_SAFE_INTEGER; + } else { + const next = res.prev + 1; + updatedOrder = next === targetOrder ? next + 1 : next; + res.prev = updatedOrder; + } + + res.updates.set(project._id, updatedOrder); + + return res; + }, + { updates: new Map(), prev: -1 }, + ); + + for (const [id, order] of updates.entries()) { + await ctx.db.patch(id, { order }); + } + + return Array.from(updates.entries()); + }, +}); + +export const archiveProject = internalMutation({ + args: { + id: v.id('projects'), + }, + handler: async (ctx, args) => { + return await ctx.db.patch(args.id, { + deletedAt: Date.now(), + order: Number.MAX_SAFE_INTEGER, + }); + }, +}); + +export const unarchiveProject = internalMutation({ + args: { + id: v.id('projects'), + }, + handler: async (ctx, args) => { + const projects = await ctx.db + .query('projects') + .withIndex('deletedByOrder', (q) => q.eq('deletedAt', null)) + .order('desc') + .take(1); + + const lastOrder = projects[0].order; + + return await ctx.db.patch(args.id, { + deletedAt: null, + order: lastOrder + 1, + }); + }, +}); + +export const generateUploadUrl = mutation({ + args: { token: v.string() }, + handler: async (ctx, { token }) => { + if (token !== process.env.UPLOAD_TOKEN) { + throw new Error('Unauthorized'); + } + + const uploadUrl = await ctx.storage.generateUploadUrl(); + + return { uploadUrl }; + }, +}); + +export const createPreview = mutation({ + args: { + projectId: v.id('projects'), + storageId: v.id('_storage'), + token: v.string(), + }, + handler: async ( + ctx, + { projectId, storageId, token }, + ): Promise> => { + if (token !== process.env.UPLOAD_TOKEN) { + throw new Error('Unauthorized'); + } + + const existingPreview = await ctx.db + .query('previews') + .withIndex('project', (q) => q.eq('projectId', projectId)) + .filter((q) => q.eq(q.field('deletedAt'), null)) + .unique(); + + if (existingPreview) { + await ctx.db.patch(existingPreview._id, { storageId }); + await ctx.storage.delete(existingPreview.storageId); + + return existingPreview._id; + } + + return await ctx.db.insert('previews', { + deletedAt: null, + projectId, + storageId, + }); + }, +}); + +const services = v.union( + v.literal('bandcamp'), + v.literal('youtube'), + v.literal('soundcloud'), +); + +const getOrInsertProjectDetails = async ( + ctx: MutationCtx, + projectId: Id<'projects'>, +) => { + let details = await ctx.db + .query('details') + .withIndex('project', (q) => q.eq('projectId', projectId)) + .filter((q) => q.eq(q.field('deletedAt'), null)) + .unique(); + + if (!details) { + const insertedId = await ctx.db.insert('details', { + content: null, + coverImageId: null, + deletedAt: null, + embedId: null, + projectId, + }); + details = await ctx.db.get(insertedId); + } + + if (!details) { + throw new ConvexError({ + message: 'Details not found', + code: 500, + }); + } + + return details; +}; + +export const attachProjectEmbed = internalMutation({ + args: { + projectId: v.id('projects'), + service: services, + src: v.string(), + }, + handler: async (ctx, { projectId, service, src }) => { + const project = await ctx.db.get(projectId); + + if (!project) { + throw new ConvexError({ + message: 'Project not found', + code: 500, + }); + } + + const existingDetails = await getOrInsertProjectDetails(ctx, projectId); + const existingEmbed = existingDetails?.embedId + ? await ctx.db.get(existingDetails?.embedId) + : null; + + if (existingEmbed) { + await ctx.db.delete(existingEmbed._id); + } + + const embedId = await ctx.db.insert('embeds', { + deletedAt: null, + service, + src, + }); + + return await ctx.db.patch(existingDetails._id, { + embedId, + }); + }, +}); + +export const attachProjectCoverImage = internalMutation({ + args: { + coverImageId: v.id('_storage'), + projectId: v.id('projects'), + }, + handler: async (ctx, { coverImageId, projectId }) => { + const project = await ctx.db.get(projectId); + + if (!project) { + throw new ConvexError({ + message: 'Project not found', + code: 500, + }); + } + + const existingCoverImage = await ctx.storage.getUrl(coverImageId); + + if (!existingCoverImage) { + throw new ConvexError({ + message: 'Cover image does not exist', + code: 500, + }); + } + + const existingDetails = await getOrInsertProjectDetails(ctx, projectId); + + return await ctx.db.patch(existingDetails._id, { + coverImageId, + }); + }, +}); diff --git a/convex/migrations.ts b/convex/migrations.ts new file mode 100644 index 0000000..91e9901 --- /dev/null +++ b/convex/migrations.ts @@ -0,0 +1,172 @@ +import { v } from 'convex/values'; +import type { Id } from './_generated/dataModel'; +import { internalMutation, internalQuery } from './_generated/server'; + +export const collectAllDetails = internalQuery({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query('details').collect(); + }, +}); + +export const collectAllPreviews = internalQuery({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query('previews').collect(); + }, +}); + +export const createImage = internalMutation({ + args: { + alt: v.optional(v.string()), + description: v.optional(v.string()), + mimeType: v.string(), + naturalHeight: v.number(), + naturalWidth: v.number(), + size: v.number(), + storageId: v.id('_storage'), + }, + handler: async (ctx, args) => { + const { + alt = null, + description = null, + mimeType, + naturalHeight, + naturalWidth, + size, + storageId, + } = args; + + return await ctx.db.insert('images', { + alt, + aspectRatio: naturalWidth / naturalHeight, + deletedAt: null, + description, + mimeType, + naturalHeight, + naturalWidth, + size, + storageId, + updatedAt: Date.now(), + }); + }, +}); + +export const updateProjectImages = internalMutation({ + args: { + projectId: v.id('projects'), + coverImageId: v.union(v.id('images'), v.null()), + previewImageId: v.union(v.id('images'), v.null()), + }, + handler: async (ctx, args) => { + const { projectId, coverImageId = null, previewImageId = null } = args; + const project = await ctx.db.get(projectId); + + if (!project) { + throw new Error(`Cannot find project: ${projectId}`); + } + + const { + coverImageId: existingCoverImageId, + previewImageId: existingPreviewImageId, + } = project; + + if (existingCoverImageId) { + const existingCoverImage = await ctx.db.get(existingCoverImageId); + + if (existingCoverImage) { + await ctx.db.patch(existingCoverImage._id, { + deletedAt: Date.now(), + }); + } + } + + if (existingPreviewImageId) { + const existingPreviewImage = await ctx.db.get(existingPreviewImageId); + + if (existingPreviewImage) { + await ctx.db.patch(existingPreviewImage._id, { + deletedAt: Date.now(), + }); + } + } + + return await ctx.db.patch(projectId, { + coverImageId, + previewImageId, + updatedAt: Date.now(), + }); + }, +}); + +export const migrateProjectEmbeds = internalMutation({ + args: {}, + handler: async (ctx) => { + const projects = await ctx.db.query('projects').collect(); + const details = await ctx.db.query('details').collect(); + const projectToEmbed = new Map( + details.filter((d) => !!d.embedId).map((d) => [d.projectId, d.embedId]), + ); + const res: Id<'projects'>[] = []; + + for (const project of projects) { + const embedId = projectToEmbed.get(project._id); + + if (embedId) { + await ctx.db.patch(embedId, { updatedAt: Date.now() }); + await ctx.db.patch(project._id, { + embedId, + updatedAt: Date.now(), + }); + } else { + await ctx.db.patch(project._id, { + embedId: null, + updatedAt: Date.now(), + }); + } + + res.push(project._id); + } + + return res; + }, +}); + +export const migrateProjectContent = internalMutation({ + args: {}, + handler: async (ctx) => { + const projects = await ctx.db.query('projects').collect(); + const details = await ctx.db.query('details').collect(); + const projectToContent = new Map( + details + .filter((d) => d.content !== null) + .map((d) => [d.projectId, d.content]), + ); + const res: Id<'projects'>[] = []; + + for (const project of projects) { + const content = projectToContent.get(project._id); + + if (content) { + const contentId = await ctx.db.insert('content', { + content, + deletedAt: null, + updatedAt: Date.now(), + }); + await ctx.db.patch(project._id, { + contentId, + updatedAt: Date.now(), + }); + } else { + await ctx.db.patch(project._id, { + contentId: null, + updatedAt: Date.now(), + }); + } + + res.push(project._id); + } + + return res; + }, +}); diff --git a/convex/previews.ts b/convex/previews.ts deleted file mode 100644 index 5830124..0000000 --- a/convex/previews.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { ConvexError, v } from 'convex/values'; -import type { Id } from './_generated/dataModel'; -import { mutation, query } from './_generated/server'; - -export const loadProjectPreview = query({ - args: { - projectId: v.id('projects'), - }, - handler: async (ctx, { projectId }) => { - const project = await ctx.db.get(projectId); - - if (!project) { - throw new ConvexError({ - message: 'Project not found', - code: 404, - }); - } - - const preview = await ctx.db - .query('previews') - .withIndex('project', (q) => q.eq('projectId', project._id)) - .filter((q) => q.eq(q.field('deletedAt'), null)) - .unique(); - - if (!preview) { - throw new ConvexError({ - message: 'Preview not found', - code: 404, - }); - } - - const publicUrl = await ctx.storage.getUrl(preview.storageId); - - if (publicUrl) { - return { - ...preview, - publicUrl, - }; - } - - return null; - }, -}); - -export const generateUploadUrl = mutation({ - args: { token: v.string() }, - handler: async (ctx, { token }) => { - if (token !== process.env.UPLOAD_TOKEN) { - throw new Error('Unauthorized'); - } - - const uploadUrl = await ctx.storage.generateUploadUrl(); - - return { uploadUrl }; - }, -}); - -export const createPreview = mutation({ - args: { - projectId: v.id('projects'), - storageId: v.id('_storage'), - token: v.string(), - }, - handler: async ( - ctx, - { projectId, storageId, token }, - ): Promise> => { - if (token !== process.env.UPLOAD_TOKEN) { - throw new Error('Unauthorized'); - } - - const existingPreview = await ctx.db - .query('previews') - .withIndex('project', (q) => q.eq('projectId', projectId)) - .filter((q) => q.eq(q.field('deletedAt'), null)) - .unique(); - - if (existingPreview) { - await ctx.db.patch(existingPreview._id, { storageId }); - await ctx.storage.delete(existingPreview.storageId); - - return existingPreview._id; - } - - return await ctx.db.insert('previews', { - deletedAt: null, - projectId, - storageId, - }); - }, -}); diff --git a/convex/projects.ts b/convex/projects.ts index e07d58b..135dcbe 100644 --- a/convex/projects.ts +++ b/convex/projects.ts @@ -1,7 +1,7 @@ import { paginationOptsValidator } from 'convex/server'; import { ConvexError, v } from 'convex/values'; -import { type Id } from './_generated/dataModel'; -import { internalMutation, query } from './_generated/server'; +import type { Doc, Id } from './_generated/dataModel'; +import { query, type QueryCtx } from './_generated/server'; export const loadProjects = query({ args: { @@ -31,95 +31,140 @@ export const loadProjectIds = query({ }, }); -export const loadAllProjects = query({ - args: {}, - handler: async (ctx) => { - return await ctx.db.query('projects').order('asc').collect(); - }, -}); +const getProjectOrNotFound = async ( + ctx: QueryCtx, + projectId: Id<'projects'>, +): Promise> => { + const project = await ctx.db.get(projectId); + + if (!project || project.deletedAt !== null) { + throw new ConvexError({ + message: 'Project not found', + code: 404, + }); + } + + return project; +}; export const loadProject = query({ args: { projectId: v.id('projects') }, handler: async (ctx, { projectId }) => { - const project = await ctx.db.get(projectId); + return await getProjectOrNotFound(ctx, projectId); + }, +}); - if (!project || project.deletedAt !== null) { - throw new ConvexError({ - message: 'Project not found', - code: 404, - }); +export const loadProjectPreview = query({ + args: { + projectId: v.id('projects'), + }, + handler: async (ctx, { projectId }) => { + const project = await getProjectOrNotFound(ctx, projectId); + + if (project.previewImageId) { + const previewImage = await ctx.db.get(project.previewImageId); + + if (!previewImage || previewImage.deletedAt !== null) { + throw new ConvexError({ + message: 'Preview not found', + code: 404, + }); + } + + const publicUrl = await ctx.storage.getUrl(previewImage.storageId); + + if (publicUrl) { + return { + ...previewImage, + alt: previewImage.alt || project.title, + publicUrl, + }; + } } - return project; + return null; }, }); -export const reorderProjects = internalMutation({ +export const loadProjectCoverImage = query({ args: { - id: v.id('projects'), - order: v.number(), + projectId: v.id('projects'), }, - handler: async (ctx, { id: targetId, order: targetOrder }) => { - const projects = await ctx.db.query('projects').collect(); - const { updates } = projects - .sort((a, b) => a.order - b.order) - .reduce<{ updates: Map, number>; prev: number }>( - (res, project) => { - let updatedOrder; - - if (project._id === targetId) { - updatedOrder = targetOrder; - } else if (project.deletedAt) { - updatedOrder = Number.MAX_SAFE_INTEGER; - } else { - const next = res.prev + 1; - updatedOrder = next === targetOrder ? next + 1 : next; - res.prev = updatedOrder; - } - - res.updates.set(project._id, updatedOrder); - - return res; - }, - { updates: new Map(), prev: -1 }, - ); - - for (const [id, order] of updates.entries()) { - await ctx.db.patch(id, { order }); + handler: async (ctx, { projectId }) => { + const project = await getProjectOrNotFound(ctx, projectId); + + if (project.coverImageId) { + const coverImage = await ctx.db.get(project.coverImageId); + + if (!coverImage || coverImage.deletedAt !== null) { + throw new ConvexError({ + message: 'Cover image not found', + code: 404, + }); + } + + const publicUrl = await ctx.storage.getUrl(coverImage.storageId); + + if (publicUrl) { + return { + ...coverImage, + alt: coverImage.alt || project.title, + publicUrl, + }; + } } - return Array.from(updates.entries()); + return null; }, }); -export const archiveProject = internalMutation({ +export const loadProjectEmbed = query({ args: { - id: v.id('projects'), + projectId: v.id('projects'), }, - handler: async (ctx, args) => { - return await ctx.db.patch(args.id, { - deletedAt: Date.now(), - order: Number.MAX_SAFE_INTEGER, - }); + handler: async (ctx, { projectId }) => { + const project = await getProjectOrNotFound(ctx, projectId); + + if (project.embedId) { + const embed = await ctx.db.get(project.embedId); + + if (!embed || embed.deletedAt !== null) { + throw new ConvexError({ + message: 'Embed not found', + code: 404, + }); + } + + return { + ...embed, + title: project.title, + }; + } + + return null; }, }); -export const unarchiveProject = internalMutation({ +export const loadProjectContent = query({ args: { - id: v.id('projects'), + projectId: v.id('projects'), }, - handler: async (ctx, args) => { - const projects = await ctx.db - .query('projects') - .withIndex('deletedByOrder', (q) => q.eq('deletedAt', null)) - .order('desc') - .take(1); + handler: async (ctx, { projectId }) => { + const project = await getProjectOrNotFound(ctx, projectId); - const lastOrder = projects[0].order; + if (project.contentId) { + const content = await ctx.db.get(project.contentId); - return await ctx.db.patch(args.id, { - deletedAt: null, - order: lastOrder + 1, - }); + if (!content || content.deletedAt !== null) { + throw new ConvexError({ + message: 'Content not found', + code: 404, + }); + } + + return content; + } + + return null; }, }); diff --git a/convex/schema.ts b/convex/schema.ts index c5c5ffc..2febb93 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -8,15 +8,52 @@ const categories = v.union( v.literal('video'), ); +const services = v.union( + v.literal('bandcamp'), + v.literal('youtube'), + v.literal('soundcloud'), +); + const projects = defineTable({ category: categories, + contentId: v.optional(v.union(v.id('content'), v.null())), + coverImageId: v.optional(v.union(v.id('images'), v.null())), deletedAt: v.union(v.number(), v.null()), + embedId: v.optional(v.union(v.id('embeds'), v.null())), order: v.number(), + previewImageId: v.optional(v.union(v.id('images'), v.null())), publishedAt: v.union(v.number(), v.null()), title: v.string(), + updatedAt: v.optional(v.union(v.number(), v.null())), url: v.string(), }).index('deletedByOrder', ['deletedAt', 'order']); +const embeds = defineTable({ + deletedAt: v.union(v.number(), v.null()), + service: services, + src: v.string(), + updatedAt: v.optional(v.union(v.number(), v.null())), +}); + +const content = defineTable({ + content: v.union(v.string(), v.null()), + deletedAt: v.union(v.number(), v.null()), + updatedAt: v.union(v.number(), v.null()), +}); + +const images = defineTable({ + alt: v.union(v.string(), v.null()), + aspectRatio: v.number(), + deletedAt: v.union(v.number(), v.null()), + description: v.union(v.string(), v.null()), + mimeType: v.string(), + naturalHeight: v.number(), + naturalWidth: v.number(), + size: v.number(), + storageId: v.id('_storage'), + updatedAt: v.number(), +}); + const details = defineTable({ content: v.union(v.string(), v.null()), coverImageId: v.union(v.id('_storage'), v.null()), @@ -25,18 +62,6 @@ const details = defineTable({ projectId: v.id('projects'), }).index('project', ['projectId']); -const services = v.union( - v.literal('bandcamp'), - v.literal('youtube'), - v.literal('soundcloud'), -); - -const embeds = defineTable({ - deletedAt: v.union(v.number(), v.null()), - service: services, - src: v.string(), -}); - const previews = defineTable({ deletedAt: v.union(v.number(), v.null()), projectId: v.id('projects'), @@ -50,9 +75,11 @@ export const features = defineTable({ }); export default defineSchema({ + content, details, embeds, features, + images, previews, projects, }); diff --git a/package-lock.json b/package-lock.json index 313d84c..401f4de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^4.6.2", + "image-size": "^1.1.1", "postcss": "^8.4.38", "postcss-cli": "^11.0.0", "postcss-preset-env": "^9.5.11", @@ -816,7 +817,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { @@ -7746,6 +7747,22 @@ "node": ">= 4" } }, + "node_modules/image-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-1.1.1.tgz", + "integrity": "sha512-541xKlUw6jr/6gGuk92F+mYM5zaFAc5ahphvkqvNe2bQ6gVBkd6bfrmVJ2t4KDAfikAYZyIqTnktX3i6/aQDrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -11378,6 +11395,16 @@ "node": ">=6" } }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/package.json b/package.json index 7be9784..613e978 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-react": "^7.37.2", "eslint-plugin-react-hooks": "^4.6.2", + "image-size": "^1.1.1", "postcss": "^8.4.38", "postcss-cli": "^11.0.0", "postcss-preset-env": "^9.5.11", diff --git a/scripts/generate-screenshots.ts b/scripts/generate-screenshots.ts index 132fb69..10fa12c 100644 --- a/scripts/generate-screenshots.ts +++ b/scripts/generate-screenshots.ts @@ -34,7 +34,7 @@ async function main(deploymentUrl: string) { const client = new ConvexClient(deploymentUrl); try { - const projects = await client.query(api.projects.loadAllProjects, {}); + const projects = await client.query(api.internal.collectAllProjects, {}); const previewsDir = path.join(process.cwd(), 'previews'); await fs.mkdir(previewsDir, { recursive: true }); diff --git a/scripts/import-screenshots.ts b/scripts/import-screenshots.ts index 613f8f8..2ed8ebb 100644 --- a/scripts/import-screenshots.ts +++ b/scripts/import-screenshots.ts @@ -34,7 +34,7 @@ async function main(deploymentUrl: string, uploadToken: string) { const previewsDir = path.join(process.cwd(), 'previews'); try { - const projects = await client.query(api.projects.loadAllProjects, {}); + const projects = await client.query(api.internal.collectAllProjects, {}); const projectIds = new Set(projects.map((p) => p._id)); const files = await fs.readdir(previewsDir); const imageFiles = files.filter( @@ -63,7 +63,7 @@ async function main(deploymentUrl: string, uploadToken: string) { }); const { uploadUrl } = await client.mutation( - api.previews.generateUploadUrl, + api.internal.generateUploadUrl, { token: uploadToken }, ); @@ -74,7 +74,7 @@ async function main(deploymentUrl: string, uploadToken: string) { `Uploaded ${filename} with storageId: ${payload.storageId}`, ); - await client.mutation(api.previews.createPreview, { + await client.mutation(api.internal.createPreview, { projectId: projectId as Id<'projects'>, storageId: payload.storageId as Id<'_storage'>, token: uploadToken, diff --git a/src/feat/Projects/components/ProjectDetails.tsx b/src/feat/Projects/components/ProjectDetails.tsx index 425e259..43403b4 100644 --- a/src/feat/Projects/components/ProjectDetails.tsx +++ b/src/feat/Projects/components/ProjectDetails.tsx @@ -61,8 +61,15 @@ const ScrollContainer = (props: FlexProps) => { ); }; -const ImageContainer = (props: FlexProps) => { - const { children, ...flexProps } = props; +const CoverImage = (props: { projectId: ProjectId }) => { + const { projectId } = props; + const coverImage = useQuery(api.projects.loadProjectCoverImage, { + projectId, + }); + + if (!coverImage) { + return null; + } return ( { py={[8, 0]} width="100%" _selection={{ bg: 'transparent' }} - {...flexProps} > - {children} + {coverImage.alt} ); }; -const EmbedContainer = (props: FlexProps) => { - const { children, ...flexProps } = props; +const EmbedCode = (props: { projectId: ProjectId }) => { + const { projectId } = props; + const embedCode = useQuery(api.projects.loadProjectEmbed, { projectId }); + + if (!embedCode) { + return null; + } return ( - - {children} + + ); }; -const ContentContainer = (props: FlexProps) => { - const { children, ...flexProps } = props; +const Content = (props: { projectId: ProjectId }) => { + const { projectId } = props; + const content = useQuery(api.projects.loadProjectContent, { projectId }); + + if (!content) { + return null; + } return ( { pt={[0, 4]} px={['0.68rem', 4]} shadow={['initial', 'lg']} - {...flexProps} > - {children} + {content.content} ); }; -const InnerDetails = (props: { projectId: ProjectId }) => { - const { projectId } = props; - const details = useQuery(api.details.loadProjectDetails, { projectId }); - const { content, embed, coverImage } = details || {}; - - return ( - - <> - {coverImage && ( - - {coverImage.alt} - - )} - {content && ( - - {content} - - )} - {embed && ( - - - - )} - - - ); -}; - export const ProjectDetails = (props: { direction: Directions; projectId: ProjectId; @@ -199,7 +187,11 @@ export const ProjectDetails = (props: { position: 'absolute', }} > - + + + + + ))} diff --git a/src/feat/Projects/components/ProjectItem.tsx b/src/feat/Projects/components/ProjectItem.tsx index 850f39d..9df1dea 100644 --- a/src/feat/Projects/components/ProjectItem.tsx +++ b/src/feat/Projects/components/ProjectItem.tsx @@ -16,7 +16,11 @@ import type { ProjectEntity, ProjectId } from '../types'; const InnerPreview = (props: { projectId: ProjectId }) => { const { projectId } = props; const [isLoading, setLoading] = useState(false); - const preview = useQuery(api.previews.loadProjectPreview, { projectId }); + const previewImage = useQuery(api.projects.loadProjectPreview, { projectId }); + const calculatedHeight = Math.max( + 180, + Math.round(256 * previewImage?.aspectRatio || 0), + ); const hasInitialized = useRef(false); const toggleLoader = useMemo( () => @@ -33,15 +37,21 @@ const InnerPreview = (props: { projectId: ProjectId }) => { } return ( - + {previewImage?.alt} { toggleLoader.cancel(); setLoading(false); }} options={{ width: 256 }} - src={preview?.publicUrl} + src={previewImage?.publicUrl} useHighRes useAnimation width="256px"