From a00a07568e191ba56ab6ce51b7d0568a7e1621c2 Mon Sep 17 00:00:00 2001 From: James Williams Date: Mon, 11 Nov 2024 15:13:31 +0000 Subject: [PATCH] Added observations & fixed clip timing. --- census/api/package.json | 1 + census/api/src/api/capture.ts | 29 ++- census/api/src/api/identification.ts | 19 +- census/api/src/api/index.ts | 2 + census/api/src/api/observation.ts | 37 ++- census/api/src/db/schema/index.ts | 1 + census/api/src/index.ts | 5 + census/api/src/services/capture/index.ts | 18 +- .../identifications/identifications.ts | 27 +++ census/api/src/services/inat/index.ts | 7 + .../src/services/observations/observations.ts | 25 +- census/api/src/services/points/achievement.ts | 14 +- census/api/src/services/twitch/index.ts | 136 ++++++++++- census/website/package.json | 1 + .../src/components/containers/Note.tsx | 7 +- .../components/controls/ObservationEntry.tsx | 9 +- .../src/components/controls/button/button.ts | 9 +- .../controls/button/juicy/index.tsx | 9 +- .../controls/button/paper/index.tsx | 29 +-- .../editor/SubjectSelectionInput.tsx | 2 +- .../{ => forms}/inputs/BoundingBoxInput.tsx | 4 +- .../components/forms/inputs/INatTaxaInput.tsx | 108 +++++++++ .../{ => forms}/inputs/SelectionInput.tsx | 6 +- .../src/components/inputs/INatTaxaInput.tsx | 114 ---------- .../src/components/points/PointOrigin.tsx | 13 +- census/website/src/layouts/Header.tsx | 3 +- census/website/src/layouts/Main.tsx | 4 +- .../website/src/pages/captures/Captures.tsx | 20 ++ census/website/src/pages/captures/Editor.tsx | 6 +- .../captures/create/CreateFromClipModal.tsx | 1 + census/website/src/pages/home/Home.tsx | 3 +- .../src/pages/observations/Observations.tsx | 214 ++++++++++++++++++ .../observations/gallery/GalleryProvider.tsx | 83 +++++++ .../src/pages/observations/gallery/hooks.ts | 11 + census/website/src/router.tsx | 10 + census/website/src/services/api/capture.ts | 30 +-- .../src/services/api/identifications.ts | 15 ++ .../website/src/services/api/observations.ts | 15 ++ .../services/video/CaptureEditorProvider.tsx | 4 +- pnpm-lock.yaml | 21 ++ 40 files changed, 860 insertions(+), 212 deletions(-) rename census/website/src/components/{ => forms}/inputs/BoundingBoxInput.tsx (99%) create mode 100644 census/website/src/components/forms/inputs/INatTaxaInput.tsx rename census/website/src/components/{ => forms}/inputs/SelectionInput.tsx (98%) delete mode 100644 census/website/src/components/inputs/INatTaxaInput.tsx create mode 100644 census/website/src/pages/captures/Captures.tsx create mode 100644 census/website/src/pages/observations/Observations.tsx create mode 100644 census/website/src/pages/observations/gallery/GalleryProvider.tsx create mode 100644 census/website/src/pages/observations/gallery/hooks.ts create mode 100644 census/website/src/services/api/identifications.ts create mode 100644 census/website/src/services/api/observations.ts diff --git a/census/api/package.json b/census/api/package.json index 9f9edda..08e348f 100644 --- a/census/api/package.json +++ b/census/api/package.json @@ -29,6 +29,7 @@ "npm": "^10.8.3", "oslo": "^1.2.1", "postgres": "^3.4.4", + "sharp": "^0.33.5", "tsx": "^4.19.0", "yauzl": "^3.1.3", "zod": "^3.23.8" diff --git a/census/api/src/api/capture.ts b/census/api/src/api/capture.ts index 177875c..201ef3f 100644 --- a/census/api/src/api/capture.ts +++ b/census/api/src/api/capture.ts @@ -1,8 +1,15 @@ import { z } from 'zod'; import { subscribeToChanges } from '../db/listen.js'; -import { completeCaptureRequest, createFromClip, getCapture } from '../services/capture/index.js'; +import { + completeCaptureRequest, + createFromClip, + getCapture, + getCaptureCount, + getCaptures +} from '../services/capture/index.js'; import { downloadClip } from '../services/twitch/clips.js'; import { procedure, router } from '../trpc/trpc.js'; +import { Pagination } from './observation.js'; export default router({ capture: procedure.input(z.object({ id: z.number() })).query(async ({ input }) => { @@ -21,6 +28,15 @@ export default router({ }) }, + captures: procedure.input(z.object({ meta: Pagination })).query(async ({ input }) => { + const data = await getCaptures(input.meta); + const count = await getCaptureCount(); + return { + meta: { ...input.meta, total: count }, + data + }; + }), + createFromClip: procedure .input(z.object({ id: z.string(), userIsVerySureItIsNeeded: z.boolean().optional() })) .mutation(async ({ input }) => { @@ -32,14 +48,5 @@ export default router({ }); } return clip; - }), - - addPoints: procedure.input(z.object({ points: z.number() })).mutation(async ({ input, ctx }) => { - points += input.points; - ctx.points(points); - await new Promise(resolve => setTimeout(resolve, 300)); - return { hello: 'world' }; - }) + }) }); - -let points = 0; diff --git a/census/api/src/api/identification.ts b/census/api/src/api/identification.ts index 57a557f..592a983 100644 --- a/census/api/src/api/identification.ts +++ b/census/api/src/api/identification.ts @@ -1,18 +1,29 @@ import { z } from 'zod'; +import { suggestIdentification } from '../services/identifications/identifications.js'; +import { getTaxaFromPartialSearch } from '../services/inat/index.js'; import { recordAchievement } from '../services/points/achievement.js'; import { procedure, router } from '../trpc/trpc'; +import { useUser } from '../utils/env/env.js'; export default router({ vote: procedure .input( z.object({ - id: z.string(), + id: z.number(), vote: z.enum(['up', 'down']), comment: z.string().optional() }) ) .mutation(async ({ input, ctx }) => { - const points = await recordAchievement('vote', ctx.user); - ctx.points(points); - }) + const user = useUser(); + const points = await recordAchievement('vote', user.id); + if (points) ctx.points(points); + }), + searchForTaxa: procedure.input(z.object({ query: z.string() })).query(async ({ input }) => { + return await getTaxaFromPartialSearch(input.query); + }), + + suggest: procedure.input(z.object({ observationId: z.number(), iNatId: z.number() })).mutation(async ({ input }) => { + return await suggestIdentification(input.observationId, input.iNatId); + }) }); diff --git a/census/api/src/api/index.ts b/census/api/src/api/index.ts index 187b1d9..08ad081 100644 --- a/census/api/src/api/index.ts +++ b/census/api/src/api/index.ts @@ -1,6 +1,7 @@ import { router } from '../trpc/trpc.js'; import capture from './capture.js'; import feed from './feed.js'; +import identification from './identification.js'; import me from './me.js'; import observation from './observation.js'; import twitch from './twitch.js'; @@ -9,5 +10,6 @@ export default router({ feed, capture, observation, + identification, twitch }); diff --git a/census/api/src/api/observation.ts b/census/api/src/api/observation.ts index 5ca38a1..19e966d 100644 --- a/census/api/src/api/observation.ts +++ b/census/api/src/api/observation.ts @@ -1,11 +1,42 @@ import { z } from 'zod'; -import { createObservationsFromCapture, ObservationPayload } from '../services/observations/observations.js'; -import { editorProcedure, router } from '../trpc/trpc.js'; +import { + createObservationsFromCapture, + getObservationCount, + getObservations, + ObservationPayload +} from '../services/observations/observations.js'; +import { editorProcedure, procedure, router } from '../trpc/trpc.js'; + +export const Pagination = z.object({ + page: z.number().default(1), + size: z.number().default(30) +}); + +export type Pagination = z.infer; + +export const Query = z.object({ + start: z.coerce.date().optional(), + end: z.coerce.date().optional() +}); + +export type Query = z.infer; export default router({ createObservationsFromCapture: editorProcedure .input(z.object({ captureId: z.number(), observations: z.array(ObservationPayload) })) .mutation(async ({ input }) => { return await createObservationsFromCapture(input.captureId, input.observations); - }) + }), + + list: procedure.input(z.object({ meta: Pagination, query: Query.optional() })).query(async ({ input }) => { + const count = await getObservationCount(); + const data = await getObservations(input.meta); + return { + meta: { + ...input.meta, + total: count + }, + data + }; + }) }); diff --git a/census/api/src/db/schema/index.ts b/census/api/src/db/schema/index.ts index c912b45..05a8177 100644 --- a/census/api/src/db/schema/index.ts +++ b/census/api/src/db/schema/index.ts @@ -88,6 +88,7 @@ export const observationsRelations = relations(observations, ({ one, many }) => fields: [observations.captureId], references: [captures.id] }), + identifications: many(identifications), images: many(images) })); diff --git a/census/api/src/index.ts b/census/api/src/index.ts index 243992f..a2943c5 100644 --- a/census/api/src/index.ts +++ b/census/api/src/index.ts @@ -11,6 +11,7 @@ import { createContext } from './trpc/context.js'; // NOT the router itself. export type AppRouter = typeof router; +import { getEncodedTimestamp } from './services/twitch/index.js'; import { createEnvironment, withEnvironment } from './utils/env/env.js'; (async () => { @@ -32,6 +33,10 @@ import { createEnvironment, withEnvironment } from './utils/env/env.js'; } satisfies FastifyTRPCPluginOptions['trpcOptions'] }); server.listen({ port: Number(process.env.PORT), host: process.env.HOST }, async (err, address) => { + const pixels = await getEncodedTimestamp( + 'https://static-cdn.jtvnw.net/twitch-clips-thumbnails-prod/OilyClumsyLorisLitFam-UhVlgBoVRPDlk1X1/0339a75a-4829-4242-8e36-dcd08f2f4793/preview.jpg' + ); + console.log(pixels); if (err) { console.error(err); process.exit(1); diff --git a/census/api/src/services/capture/index.ts b/census/api/src/services/capture/index.ts index 7d3245b..3b77d69 100644 --- a/census/api/src/services/capture/index.ts +++ b/census/api/src/services/capture/index.ts @@ -1,4 +1,5 @@ -import { and, eq, gte, lte, or } from 'drizzle-orm'; +import { and, count, desc, eq, gte, lte, or } from 'drizzle-orm'; +import { Pagination } from '../../api/observation.js'; import { Capture, captures } from '../../db/schema/index.js'; import { useDB } from '../../db/transaction.js'; import { useUser } from '../../utils/env/env.js'; @@ -152,6 +153,21 @@ export const getCapture = async (id: number) => { return capture; }; +export const getCaptureCount = async () => { + const db = useDB(); + const [result] = await db.select({ count: count() }).from(captures); + return result.count; +}; + +export const getCaptures = async (pagination: Pagination) => { + const db = useDB(); + return await db.query.captures.findMany({ + limit: pagination.size, + offset: (pagination.page - 1) * pagination.size, + orderBy: [desc(captures.capturedAt)] + }); +}; + export const getCaptureByClipId = async (id: string) => { const db = useDB(); return await db.query.captures.findFirst({ diff --git a/census/api/src/services/identifications/identifications.ts b/census/api/src/services/identifications/identifications.ts index e69de29..3dea3a9 100644 --- a/census/api/src/services/identifications/identifications.ts +++ b/census/api/src/services/identifications/identifications.ts @@ -0,0 +1,27 @@ +import { identifications } from '../../db/schema/index.js'; +import { useDB } from '../../db/transaction.js'; +import { useUser } from '../../utils/env/env.js'; +import { getTaxaInfo } from '../inat/index.js'; + +export const suggestIdentification = async (observationId: number, iNatId: number) => { + const source = await getTaxaInfo(iNatId); + return createIdentification(observationId, iNatId, source.preferred_common_name ?? source.name); +}; + +export const createIdentification = async (observationId: number, iNatId: number, name: string) => { + const db = useDB(); + const user = useUser(); + + const [identification] = await db + .insert(identifications) + .values({ + name, + nickname: name, + sourceId: iNatId.toString(), + observationId, + suggestedBy: user.id + }) + .returning(); + + return identification; +}; diff --git a/census/api/src/services/inat/index.ts b/census/api/src/services/inat/index.ts index 211db73..ba152a0 100644 --- a/census/api/src/services/inat/index.ts +++ b/census/api/src/services/inat/index.ts @@ -37,3 +37,10 @@ export const getTaxaFromPartialSearch = async (search: string) => { const data = await response.json(); return SearchResults.parse(data); }; + +export const getTaxaInfo = async (iNatId: number) => { + const response = await fetch(`https://api.inaturalist.org/v1/taxa/${iNatId}`); + const data = await response.json(); + const { results } = SearchResults.parse(data); + return results[0]; +}; diff --git a/census/api/src/services/observations/observations.ts b/census/api/src/services/observations/observations.ts index ba3ad17..82576c0 100644 --- a/census/api/src/services/observations/observations.ts +++ b/census/api/src/services/observations/observations.ts @@ -1,4 +1,4 @@ -import { eq } from 'drizzle-orm'; +import { count, desc, eq } from 'drizzle-orm'; import { ReadableStream } from 'node:stream/web'; import { Readable } from 'stream'; import { BoundingBox, images, observations } from '../../db/schema'; @@ -79,6 +79,25 @@ const createObservations = async (captureId: number, selections: Selection[], ni return await getObservation(observation.id); }; +export const getObservationCount = async () => { + const db = useDB(); + const [result] = await db.select({ count: count() }).from(observations); + return result.count; +}; + +export const getObservations = (pagination: Pagination) => { + const db = useDB(); + return db.query.observations.findMany({ + with: { images: true, capture: true, identifications: true }, + orderBy: desc(observations.observedAt), + columns: { + moderated: false + }, + limit: pagination.size, + offset: (pagination.page - 1) * pagination.size + }); +}; + const scaleBoundingBox = (boundingBox: BoundingBox, width: number, height: number) => { return { ...boundingBox, @@ -88,9 +107,6 @@ const scaleBoundingBox = (boundingBox: BoundingBox, width: number, height: numbe height: Math.round(boundingBox.height * height) }; }; -/* -`ffmpeg -accurate_seek -ss {frame * 60} -i input.mp4 -frames:v 1 frame.png` -*/ export const getFrameFromVideo = async (video: TemporaryFile, stats: ffmpeg.FfprobeStream, timestamp: number) => { const { storage } = useEnvironment(); @@ -119,6 +135,7 @@ import { randomUUID } from 'crypto'; import ffmpeg from 'fluent-ffmpeg'; import { writeFile } from 'fs/promises'; import { z } from 'zod'; +import { Pagination } from '../../api/observation'; import { useEnvironment, useUser } from '../../utils/env/env'; export const extractFrameFromVideo = async (video: TemporaryFile, timestamp: number, stats: ffmpeg.FfprobeStream) => { diff --git a/census/api/src/services/points/achievement.ts b/census/api/src/services/points/achievement.ts index ff478e3..d4a57ab 100644 --- a/census/api/src/services/points/achievement.ts +++ b/census/api/src/services/points/achievement.ts @@ -12,10 +12,16 @@ const registry = { export type Achievements = keyof typeof registry; -export const recordAchievement = async (action: Achievements, username: string) => { +export const recordAchievement = async (action: Achievements, username: string, immediate = false) => { const details = registry[action]; if (!details) throw new Error(`Invalid action: ${action}`); - await addAchievement(action, username, details.points); + const db = useDB(); + return await db.transaction(async tx => + withTransaction(tx, async () => { + await addAchievement(action, username, details.points, immediate); + if (immediate) return await addPoints(username, details.points); + }) + ); }; export const redeemAchievementAndAwardPoints = async (username: string, id: number) => { @@ -66,9 +72,9 @@ export const revokeAchievement = async (id: number) => { ); }; -const addAchievement = async (action: Achievements, username: string, points: number) => { +const addAchievement = async (action: Achievements, username: string, points: number, immediate = false) => { const db = useDB(); - await db.insert(achievements).values({ type: action, username, points }); + await db.insert(achievements).values({ type: action, username, points, redeemed: immediate }); }; const redeemAchievement = async (username: string, id: number) => { diff --git a/census/api/src/services/twitch/index.ts b/census/api/src/services/twitch/index.ts index eda3a98..cebaa50 100644 --- a/census/api/src/services/twitch/index.ts +++ b/census/api/src/services/twitch/index.ts @@ -1,7 +1,7 @@ -import { addSeconds, isAfter, subMinutes } from 'date-fns'; +import { addHours, addSeconds, differenceInSeconds, isAfter, setMinutes, setSeconds, subMinutes } from 'date-fns'; +import sharp from 'sharp'; import { useEnvironment } from '../../utils/env/env'; import { ClipNotFoundResult, ClipNotProcessedResult, VODNotFoundResult } from '../capture'; - type ClipSuccessResult = { result: 'success'; clip: { @@ -20,12 +20,16 @@ export const getClip = async (id: string): Promise => { const { twitch } = useEnvironment(); const clip = await twitch.clips.getClipById(id); if (!clip || !clip.id || !clip.creationDate) return { result: 'error', type: 'clip_not_found' }; - if (isAfter(clip.creationDate, subMinutes(new Date(), 15))) return { result: 'error', type: 'clip_not_processed' }; - if (!clip.videoId || !clip.vodOffset) return { result: 'error', type: 'vod_not_found' }; + if (!clip.videoId || !clip.vodOffset) { + if (isAfter(clip.creationDate, subMinutes(new Date(), 15))) return { result: 'error', type: 'clip_not_processed' }; + return { result: 'error', type: 'vod_not_found' }; + } const vod = await getVOD(clip.videoId); const vodStartDate = new Date(vod.publishedAt); - const startDate = addSeconds(vodStartDate, clip.vodOffset); + const twitchStartDate = addSeconds(vodStartDate, clip.vodOffset); + const encodedTimestamp = await getEncodedTimestamp(getThumbnailUrl(clip.thumbnailUrl)); + const startDate = estimateStartDateFromTwitchTimestampAndEncodedTimestamp(twitchStartDate, encodedTimestamp); const endDate = addSeconds(startDate, clip.duration); const result = { @@ -53,3 +57,125 @@ export const getVOD = async (id: string) => { views: vod.views }; }; + +export interface Color { + r: number; + g: number; + b: number; +} + +// const colors = ["#7E7E7E", "#4E3029"]; +const Colors: Color[] = [ + { r: 126, g: 126, b: 126 }, + { r: 78, g: 48, b: 41 } +]; + +interface ClosestColor { + index: number; + distance: number; +} + +export const getClosestColor = (color: Color) => { + const threshold = 100; + + const closestColor = Colors.reduce( + (closest, value, index) => { + const distance = Math.sqrt((color.r - value.r) ** 2 + (color.g - value.g) ** 2 + (color.b - value.b) ** 2); + return distance < closest.distance ? { index, distance } : closest; + }, + { index: 0, distance: Infinity } as ClosestColor + ); + + if (closestColor.distance > threshold) { + console.log('Color not found', color); + console.log('Closest color', closestColor); + console.log('Threshold', threshold); + console.log('Distance', closestColor.distance); + console.log('Off by ', closestColor.distance - threshold); + throw new Error('Color not found'); + } + return closestColor.index; +}; + +const regex = /-\d+x\d+/; +export const getThumbnailUrl = (url: string) => url.replace(regex, ''); + +export const getEncodedTimestamp = async (url: string) => { + const thumbnail = await fetch(url); + const buffer = await thumbnail.arrayBuffer(); + const image = sharp(buffer); + + const metadata = await image.metadata(); + const { width, height } = metadata; + if (!width || !height) throw new Error('Invalid thumbnail'); + // Ensure the image has sufficient dimensions + if (width < 2 + 4 * 11 || height < 2) { + // 2 pixels offset + 4 pixels step * 11 steps for 12 values + throw new Error('Image is too small for the specified extraction parameters.'); + } + + const rawBuffer = await image.raw().toBuffer(); + + const getPixelColor = (x: number, y: number) => { + const channels = 3; // RGB + const idx = (width * y + x) * channels; + return { + r: rawBuffer[idx], + g: rawBuffer[idx + 1], + b: rawBuffer[idx + 2] + }; + }; + + let binary = ''; + + let startX = width - 2; + let startY = height - 2; + + for (let i = 11; i >= 0; i--) { + const currentX = startX - i * 4; + const currentY = startY; + + if (currentX < 0) { + console.warn(`Pixel position (${currentX}, ${currentY}) is outside image boundaries.`); + break; + } + + const color = getPixelColor(currentX, currentY); + binary += getClosestColor(color); + } + + const result = parseInt(binary, 2); + const minutes = Math.floor(result / 60); + const seconds = result % 60; + return { minutes, seconds }; +}; + +const applyEncodedTimestamp = (date: Date, encodedTimestamp: { minutes: number; seconds: number }) => { + return setSeconds(setMinutes(date, encodedTimestamp.minutes), encodedTimestamp.seconds); +}; + +export const estimateStartDateFromTwitchTimestampAndEncodedTimestamp = ( + twitchTimestamp: Date, + encodedTimestamp: { minutes: number; seconds: number } +) => { + console.log('Twitch timestamp', twitchTimestamp); + const date = applyEncodedTimestamp(twitchTimestamp, encodedTimestamp); + console.log('Date', date); + const candidates = [addHours(date, -1), date, addHours(date, 1)]; + + const closestCandidate = candidates.reduce((closest, candidate) => { + const currentDifference = Math.abs(differenceInSeconds(closest, twitchTimestamp)); + const candidateDifference = Math.abs(differenceInSeconds(candidate, twitchTimestamp)); + console.log('Current difference', currentDifference); + console.log('Candidate difference', candidateDifference); + if (currentDifference < candidateDifference) { + console.log('Returning closest'); + return closest; + } + console.log('Returning candidate'); + return candidate; + }, candidates[0]); + console.log('Closest candidate', closestCandidate); + + return closestCandidate; +}; diff --git a/census/website/package.json b/census/website/package.json index 1e62a6d..b8d5778 100644 --- a/census/website/package.json +++ b/census/website/package.json @@ -50,6 +50,7 @@ "react-dom": "^18.3.1", "react-error-boundary": "^4.0.13", "react-hook-form": "^7.53.0", + "react-intersection-observer": "^9.13.1", "react-markdown": "^9.0.1", "react-router": "^6.26.1", "react-router-dom": "^6.26.1", diff --git a/census/website/src/components/containers/Note.tsx b/census/website/src/components/containers/Note.tsx index 1b99e52..a846673 100644 --- a/census/website/src/components/containers/Note.tsx +++ b/census/website/src/components/containers/Note.tsx @@ -3,8 +3,11 @@ import { FC, HTMLAttributes, PropsWithChildren } from 'react'; export const Note: FC>> = ({ children, className, ...props }) => { return ( -
-
+
+
{children}
diff --git a/census/website/src/components/controls/ObservationEntry.tsx b/census/website/src/components/controls/ObservationEntry.tsx index 9a4ec4a..89fde7e 100644 --- a/census/website/src/components/controls/ObservationEntry.tsx +++ b/census/website/src/components/controls/ObservationEntry.tsx @@ -3,8 +3,7 @@ import { AnimatePresence, motion } from 'framer-motion'; import { FC, PropsWithChildren, SVGAttributes, useState } from 'react'; interface ObservationEntryProps { - id: string; - iNatId: number; + iNatId: string; } interface ObservationEntryActions { @@ -12,13 +11,12 @@ interface ObservationEntryActions { } export const ObservationEntry: FC> = ({ - id, iNatId, children, remove }) => { return ( -
+

self-reported

{remove && ( @@ -43,13 +41,12 @@ export const ObservationEntry: FC> = ({ - id, iNatId, children }) => { const [vote, setVote] = useState<'agree' | 'disagree' | undefined>(); return ( -
+
diff --git a/census/website/src/components/controls/button/button.ts b/census/website/src/components/controls/button/button.ts index 76b5cdf..fa80086 100644 --- a/census/website/src/components/controls/button/button.ts +++ b/census/website/src/components/controls/button/button.ts @@ -1,13 +1,6 @@ -export const variants = { - primary: 'bg-accent-400 hover:bg-accent-500 text-accent-950', - custom: 'bg-custom hover:bg-custom-darker text-white', - danger: 'bg-red-500 hover:bg-red-600 text-white', - alveus: 'bg-alveus hover:bg-alveus-darker text-white' -}; - export interface ButtonProps { loading?: boolean; - variant?: keyof typeof variants | false; + variant?: 'alveus' | 'primary' | 'custom' | 'danger' | false; disabled?: string | boolean; diff --git a/census/website/src/components/controls/button/juicy/index.tsx b/census/website/src/components/controls/button/juicy/index.tsx index 5d87418..0c76b3a 100644 --- a/census/website/src/components/controls/button/juicy/index.tsx +++ b/census/website/src/components/controls/button/juicy/index.tsx @@ -1,7 +1,14 @@ import { cn } from '@/utils/cn'; import { ButtonHTMLAttributes, forwardRef, PropsWithChildren, useEffect } from 'react'; import { Loader } from '../../../loaders/Loader'; -import { ButtonProps, variants } from '../button'; +import { ButtonProps } from '../button'; + +export const variants = { + primary: 'bg-accent-400 hover:bg-accent-500 text-accent-950', + custom: 'bg-custom hover:bg-custom-darker text-white', + danger: 'bg-red-500 hover:bg-red-600 text-white', + alveus: 'bg-alveus hover:bg-alveus-darker text-white' +}; export const Button = forwardRef< HTMLButtonElement, diff --git a/census/website/src/components/controls/button/paper/index.tsx b/census/website/src/components/controls/button/paper/index.tsx index 36b52d1..44d88d7 100644 --- a/census/website/src/components/controls/button/paper/index.tsx +++ b/census/website/src/components/controls/button/paper/index.tsx @@ -1,22 +1,25 @@ import { cn } from '@/utils/cn'; -import { ButtonHTMLAttributes, FC, PropsWithChildren } from 'react'; +import { ButtonHTMLAttributes, FC, forwardRef, PropsWithChildren } from 'react'; import { LinkProps, Link as RouterLink } from 'react-router-dom'; import { Loader } from '../../../loaders/Loader'; -import { ButtonProps, variants } from '../button'; +import { ButtonProps } from '../button'; + +export const variants = { + primary: 'bg-accent-200 hover:bg-accent-300 text-accent-900', + custom: 'bg-custom hover:bg-custom-darker text-white', + danger: 'bg-red-500 hover:bg-red-600 text-white', + alveus: 'bg-alveus hover:bg-alveus-darker text-white' +}; export type PaperButtonProps = ButtonProps & Omit, 'disabled'> & {}; -export const Button: FC> = ({ - children, - className, - variant = 'alveus', - type = 'button', - disabled, - loading, - ...props -}) => { +export const Button = forwardRef< + HTMLButtonElement, + PropsWithChildren, 'disabled'> & {}> +>(({ children, className, variant = 'primary', type = 'button', disabled, loading, ...props }, ref) => { return ( ); -}; +}); export const Link: FC> = ({ children, className, disabled, - variant = 'alveus', + variant = 'primary', ...props }) => { return ( diff --git a/census/website/src/components/editor/SubjectSelectionInput.tsx b/census/website/src/components/editor/SubjectSelectionInput.tsx index 4a6898d..7b1637a 100644 --- a/census/website/src/components/editor/SubjectSelectionInput.tsx +++ b/census/website/src/components/editor/SubjectSelectionInput.tsx @@ -1,6 +1,6 @@ import { useEditor } from '@/services/video/hooks'; import * as Media from '@react-av/core'; -import { SelectionInput } from '../inputs/SelectionInput'; +import { SelectionInput } from '../forms/inputs/SelectionInput'; export const SubjectSelectionInput = () => { const [time] = Media.useMediaCurrentTimeFine(); diff --git a/census/website/src/components/inputs/BoundingBoxInput.tsx b/census/website/src/components/forms/inputs/BoundingBoxInput.tsx similarity index 99% rename from census/website/src/components/inputs/BoundingBoxInput.tsx rename to census/website/src/components/forms/inputs/BoundingBoxInput.tsx index 75fb125..5d00568 100644 --- a/census/website/src/components/inputs/BoundingBoxInput.tsx +++ b/census/website/src/components/forms/inputs/BoundingBoxInput.tsx @@ -1,8 +1,8 @@ import { cn } from '@/utils/cn'; import { AnimatePresence, motion } from 'framer-motion'; import { FC, forwardRef, HTMLAttributes, MouseEventHandler, useCallback, useEffect, useRef } from 'react'; -import { Corner } from '../assets/icons/Corner'; -import { Trash } from '../assets/icons/Trash'; +import { Corner } from '../../assets/icons/Corner'; +import { Trash } from '../../assets/icons/Trash'; export interface InputProps { onChange: (value: T | ((value: T) => T)) => void; diff --git a/census/website/src/components/forms/inputs/INatTaxaInput.tsx b/census/website/src/components/forms/inputs/INatTaxaInput.tsx new file mode 100644 index 0000000..75b9c5a --- /dev/null +++ b/census/website/src/components/forms/inputs/INatTaxaInput.tsx @@ -0,0 +1,108 @@ +import SiBinoculars from '@/components/icons/SiBinoculars'; +import { useAPI } from '@/services/query/hooks'; +import { cn } from '@/utils/cn'; +import { useQuery } from '@tanstack/react-query'; +import { useDebounce, useMeasure } from '@uidotdev/usehooks'; +import { Command } from 'cmdk'; +import { FC, useState } from 'react'; + +export interface InputProps { + onSelect: (value: T) => void; + placeholder?: string; +} + +interface TaxaSearchResult { + id: number; + name: string; + family?: string; + scientific: string; +} + +export const INatTaxaInput: FC> = ({ onSelect, placeholder }) => { + const [query, setQuery] = useState(''); + const search = useDebounce(query, 300); + + const [ref, { width }] = useMeasure(); + const [open, setOpen] = useState(false); + + const api = useAPI(); + + const results = useQuery({ + queryKey: ['inat-taxa', search], + queryFn: () => api.identification.searchForTaxa.query({ query: search }), + enabled: !!search + }); + + return ( +
+ + {open && ( + <> +
setOpen(false)} /> +
+ +
+ + +
+ + {(search.length === 0 || + results.isLoading || + (results.isSuccess && results.data.results.length === 0)) && ( +
+ {search.length === 0 && Start typing to search} + {results.isLoading && Loading...} + {results.isSuccess && query.length > 0 && results.data.results.length === 0 && ( + No results found. + )} +
+ )} + + {results.data?.results.map(result => ( + { + onSelect({ + id: result.id, + name: result.preferred_common_name ?? result.name, + scientific: result.name, + family: result.iconic_taxon_name ?? undefined + }); + setOpen(false); + setQuery(''); + }} + > +

{result.preferred_common_name}

+

{result.name}

+
+ ))} +
+
+
+
+ + )} +
+ ); +}; diff --git a/census/website/src/components/inputs/SelectionInput.tsx b/census/website/src/components/forms/inputs/SelectionInput.tsx similarity index 98% rename from census/website/src/components/inputs/SelectionInput.tsx rename to census/website/src/components/forms/inputs/SelectionInput.tsx index 52cef01..1df5ce2 100644 --- a/census/website/src/components/inputs/SelectionInput.tsx +++ b/census/website/src/components/forms/inputs/SelectionInput.tsx @@ -1,10 +1,10 @@ +import SiTrash from '@/components/icons/SiTrash'; import type { BoundingBox, Selection } from '@/services/video/CaptureEditorProvider'; import { getColorForId } from '@/services/video/utils'; import { cn } from '@/utils/cn'; import { AnimatePresence, motion } from 'framer-motion'; import { ComponentProps, FC, forwardRef, HTMLAttributes, useEffect, useRef } from 'react'; -import { Corner } from '../assets/icons/Corner'; -import { Trash } from '../assets/icons/Trash'; +import { Corner } from '../../assets/icons/Corner'; interface SelectionInputProps { currentSubjectId: number; @@ -174,7 +174,7 @@ export const SelectionInput: FC< e.preventDefault(); }} > - + ))} diff --git a/census/website/src/components/inputs/INatTaxaInput.tsx b/census/website/src/components/inputs/INatTaxaInput.tsx deleted file mode 100644 index d5f59ee..0000000 --- a/census/website/src/components/inputs/INatTaxaInput.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { useAPI } from '@/services/query/hooks'; -import { cn } from '@/utils/cn'; -import { useQuery } from '@tanstack/react-query'; -import { useDebounce, useMeasure } from '@uidotdev/usehooks'; -import { Command } from 'cmdk'; -import { FC, useState } from 'react'; - -export interface InputProps { - onSelect: (value: T) => void; - placeholder?: string; -} - -interface TaxaSearchResult { - id: number; - name: string; - family?: string; - scientific: string; -} - -export const INatTaxaInput: FC> = ({ onSelect, placeholder }) => { - const [query, setQuery] = useState(''); - const search = useDebounce(query, 300); - - const [ref, { width }] = useMeasure(); - const [open, setOpen] = useState(false); - - const api = useAPI(); - - const results = useQuery({ - queryKey: ['inat-taxa', search], - queryFn: () => api.observation.search.query({ search }), - enabled: !!search - }); - - return ( -
- - {open && ( - <> -
setOpen(false)} /> -
- -
- - - - -
- - {(search.length === 0 || - results.isLoading || - (results.isSuccess && results.data.results.length === 0)) && ( -
- {search.length === 0 && Start typing to search} - {results.isLoading && Loading...} - {results.isSuccess && query.length > 0 && results.data.results.length === 0 && ( - No results found. - )} -
- )} - - {results.data?.results.map(result => ( - { - onSelect({ - id: result.id, - name: result.preferred_common_name ?? result.name, - scientific: result.name, - family: result.iconic_taxon_name ?? undefined - }); - setOpen(false); - setQuery(''); - }} - > -

{result.preferred_common_name}

-

{result.name}

-
- ))} -
-
-
-
- - )} -
- ); -}; diff --git a/census/website/src/components/points/PointOrigin.tsx b/census/website/src/components/points/PointOrigin.tsx index c016dee..b6a7b0d 100644 --- a/census/website/src/components/points/PointOrigin.tsx +++ b/census/website/src/components/points/PointOrigin.tsx @@ -1,6 +1,6 @@ import { cn } from '@/utils/cn'; import { motion } from 'framer-motion'; -import { FC, PropsWithChildren, RefObject } from 'react'; +import { ComponentProps, FC, PropsWithChildren, RefObject } from 'react'; export interface PointOriginProps { id: string; @@ -8,9 +8,16 @@ export interface PointOriginProps { textRef: RefObject; } -export const PointOrigin: FC> = ({ children, id, bubbleRef, textRef }) => { +export const PointOrigin: FC>> = ({ + children, + id, + bubbleRef, + textRef, + className, + ...props +}) => { return ( -
+
{children}
{ const createFromClipModalProps = useModal(); return ( -
+
@@ -20,6 +20,7 @@ export const Header = () => { submit new clip
+
); }; diff --git a/census/website/src/layouts/Main.tsx b/census/website/src/layouts/Main.tsx index 5bd9d87..63fe8ca 100644 --- a/census/website/src/layouts/Main.tsx +++ b/census/website/src/layouts/Main.tsx @@ -13,10 +13,10 @@ export const Main = () => {
-
+
-
+
diff --git a/census/website/src/pages/captures/Captures.tsx b/census/website/src/pages/captures/Captures.tsx new file mode 100644 index 0000000..a7b6e80 --- /dev/null +++ b/census/website/src/pages/captures/Captures.tsx @@ -0,0 +1,20 @@ +import { useCaptures } from '@/services/api/capture'; +const regex = /-\d+x\d+/; + +export const Captures = () => { + const result = useCaptures(); + return ( +
+ {result.data.pages.flatMap(page => { + return page.data.map(capture => { + return ( +
+
{capture.startCaptureAt}
+ +
+ ); + }); + })} +
+ ); +}; diff --git a/census/website/src/pages/captures/Editor.tsx b/census/website/src/pages/captures/Editor.tsx index 166e699..7ed49e9 100644 --- a/census/website/src/pages/captures/Editor.tsx +++ b/census/website/src/pages/captures/Editor.tsx @@ -13,10 +13,13 @@ import { Selection } from '@alveusgg/census-api/src/services/observations/observ import * as Media from '@react-av/core'; import { AnimatePresence } from 'framer-motion'; import { FC } from 'react'; +import { useNavigate } from 'react-router'; import { CaptureProps } from './Capture'; export const Editor: FC = ({ id }) => { const capture = useCapture(id); + const navigate = useNavigate(); + const createObservationsFromCapture = useCreateObservationsFromCapture(); const { selectedSubjectId, selections } = useEditor(state => state); @@ -36,6 +39,7 @@ export const Editor: FC = ({ id }) => { const payloads = Array.from(subjects.values()); await createObservationsFromCapture.mutateAsync({ captureId: id, observations: payloads }); + navigate(`/observations`); }; return ( @@ -62,7 +66,7 @@ export const Editor: FC = ({ id }) => {
-
+
+ ); +}; + +const Controls: FC = () => { + return ( + <> + + + + + + + + ); +}; + +const AutoplayOnHover: FC = ({ children }) => { + const next = useGallery(state => state.next); + const ref = useCallback((node: HTMLDivElement) => { + let isAutoplaying = false; + if (node) { + node.addEventListener('mouseenter', () => { + if (isAutoplaying) return; + isAutoplaying = true; + const interval = setInterval(() => { + next(); + }, 1000); + + node.addEventListener('mouseleave', () => { + clearInterval(interval); + isAutoplaying = false; + }); + }); + } + }, []); + return {children}; +}; + +export const Polaroid: FC = ({ children }) => { + return ( + + +
+ {children} + +
+
+
+ ); +}; + +export const Observations = () => { + const result = useObservations(); + const suggestIdentification = useSuggestIdentification(); + const action = usePointAction(); + + return ( +
+ {result.data.pages.flatMap(page => { + return page.data.map(observation => ( +
+ + + {observation.images.map(image => ( + + ))} + + {observation.images.map(image => ( + + + + + ))} + + +
+
+

strangecyan

+

{format(observation.observedAt, 'MM/dd/yyyy hh:mma')}

+
+
+ + + + + {observation.capture?.clipId && ( + + + + )} +
+
+
+

+ + no associated plants +

+
+ {observation.identifications.length > 0 && ( +
+ {observation.identifications.map(identification => ( +
+
+

+ + {identification.name} +

+

+ suggested by {identification.suggestedBy} +

+
+
+ + + + +
+
+ ))} +
+ )} +
+ { + await suggestIdentification.mutateAsync({ + observationId: observation.id, + iNatId: taxon.id + }); + }} + /> + {suggestIdentification.isPending && } +
+
+
+ )); + })} +
+ ); +}; + +export const Preloader: FC = ({ children }) => { + const [ref, inView] = useInView(); + return ( +
+ {inView && children} +
+ ); +}; diff --git a/census/website/src/pages/observations/gallery/GalleryProvider.tsx b/census/website/src/pages/observations/gallery/GalleryProvider.tsx new file mode 100644 index 0000000..66f788e --- /dev/null +++ b/census/website/src/pages/observations/gallery/GalleryProvider.tsx @@ -0,0 +1,83 @@ +import { createContext, FC, PropsWithChildren, useEffect, useRef } from 'react'; +import { createStore, StoreApi } from 'zustand'; +import { useGallery } from './hooks'; + +export interface GalleryStore { + slides: string[]; + current?: string; + register: (id: string) => void; + unregister: (id: string) => void; + next: () => void; + previous: () => void; +} + +interface GalleryProviderProps { + loop?: boolean; +} + +export const GalleryContext = createContext | null>(null); +export const GalleryProvider: FC> = ({ children, loop = false }) => { + const store = useRef( + createStore((set, get) => { + return { + slides: [], + current: undefined, + register: (id: string) => { + const { current } = get(); + set(state => ({ slides: [...state.slides, id] })); + if (!current) { + set({ current: id }); + } + }, + unregister: (id: string) => { + set(state => ({ slides: state.slides.filter(slide => slide !== id) })); + }, + next: () => { + const { current, slides } = get(); + if (!current) return; + const next = slides[slides.indexOf(current) + 1]; + if (!next) { + if (loop) { + set({ current: slides[0] }); + } + return; + } + set({ current: next }); + }, + previous: () => { + const { current, slides } = get(); + if (!current) return; + const previous = slides[slides.indexOf(current) - 1]; + if (!previous) { + if (loop) { + set({ current: slides[slides.length - 1] }); + } + return; + } + set({ current: previous }); + } + }; + }) + ); + + return {children}; +}; + +interface SlideProps { + id: string; +} + +export const Slide: FC> = ({ id, children }) => { + const [register, unregister, current] = useGallery(state => [state.register, state.unregister, state.current]); + useEffect(() => { + register(id); + return () => { + unregister(id); + }; + }, [register, unregister, id]); + + if (current === id) { + return <>{children}; + } + return null; +}; diff --git a/census/website/src/pages/observations/gallery/hooks.ts b/census/website/src/pages/observations/gallery/hooks.ts new file mode 100644 index 0000000..2bab5b0 --- /dev/null +++ b/census/website/src/pages/observations/gallery/hooks.ts @@ -0,0 +1,11 @@ +import { useContext } from 'react'; +import { useStore } from 'zustand'; +import { GalleryContext, GalleryStore } from './GalleryProvider'; + +export const useGallery = (selector: (store: GalleryStore) => U): U => { + const client = useContext(GalleryContext); + if (!client) { + throw new Error('useGallery must be used within a GalleryProvider'); + } + return useStore(client, selector); +}; diff --git a/census/website/src/router.tsx b/census/website/src/router.tsx index b17b336..52cfddb 100644 --- a/census/website/src/router.tsx +++ b/census/website/src/router.tsx @@ -7,7 +7,9 @@ import { SignIn } from './pages/authentication/SignIn'; import { SignOut } from './pages/authentication/SignOut'; import { SignOutRedirect } from './pages/authentication/SignOutRedirect'; import { Capture } from './pages/captures/Capture'; +import { Captures } from './pages/captures/Captures'; import { Home } from './pages/home/Home'; +import { Observations } from './pages/observations/Observations'; const auth: RouteObject = { path: 'auth', @@ -49,6 +51,14 @@ export const router = createBrowserRouter([ { path: '/captures/:id', element: + }, + { + path: '/observations', + element: + }, + { + path: '/captures', + element: } ] } diff --git a/census/website/src/services/api/capture.ts b/census/website/src/services/api/capture.ts index bd4be26..52f7245 100644 --- a/census/website/src/services/api/capture.ts +++ b/census/website/src/services/api/capture.ts @@ -1,5 +1,5 @@ import type { ObservationPayload } from '@alveusgg/census-api/src/services/observations/observations'; -import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; +import { useMutation, useQueryClient, useSuspenseInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query'; import { useEffect, useMemo } from 'react'; import { key, useAPI, useLiveQuery } from '../query/hooks'; @@ -26,19 +26,16 @@ export const useCapture = (id: number) => { return result; }; -export const useClipDetails = (id: string) => { +export const useCaptures = () => { const trpc = useAPI(); - return useSuspenseQuery({ - queryKey: key('twitch', 'clip', id), - queryFn: () => trpc.twitch.clip.query({ id }) - }); -}; - -export const useVODInfo = (id: string) => { - const trpc = useAPI(); - return useSuspenseQuery({ - queryKey: key('twitch', 'vod', id), - queryFn: () => trpc.twitch.vod.query({ id }) + return useSuspenseInfiniteQuery({ + queryKey: key('captures'), + queryFn: ({ pageParam }) => trpc.capture.captures.query({ meta: { page: pageParam, size: 30 } }), + initialPageParam: 1, + getNextPageParam: lastPage => { + if (lastPage.meta.page * lastPage.meta.size >= lastPage.meta.total) return undefined; + return lastPage.meta.page + 1; + } }); }; @@ -75,10 +72,3 @@ export const useCreateObservationsFromCapture = () => { } }); }; - -export const useAddPoints = () => { - const trpc = useAPI(); - return useMutation({ - mutationFn: (points: number) => trpc.capture.addPoints.mutate({ points }) - }); -}; diff --git a/census/website/src/services/api/identifications.ts b/census/website/src/services/api/identifications.ts new file mode 100644 index 0000000..1b4fd33 --- /dev/null +++ b/census/website/src/services/api/identifications.ts @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { key, useAPI } from '../query/hooks'; + +export const useSuggestIdentification = () => { + const trpc = useAPI(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ observationId, iNatId }: { observationId: number; iNatId: number }) => { + const results = await trpc.identification.suggest.mutate({ observationId, iNatId }); + await queryClient.invalidateQueries({ queryKey: key('observations') }); + return results; + } + }); +}; diff --git a/census/website/src/services/api/observations.ts b/census/website/src/services/api/observations.ts new file mode 100644 index 0000000..dbccf6c --- /dev/null +++ b/census/website/src/services/api/observations.ts @@ -0,0 +1,15 @@ +import { useSuspenseInfiniteQuery } from '@tanstack/react-query'; +import { key, useAPI } from '../query/hooks'; + +export const useObservations = () => { + const trpc = useAPI(); + return useSuspenseInfiniteQuery({ + queryKey: key('observations'), + queryFn: ({ pageParam }) => trpc.observation.list.query({ meta: { page: pageParam, size: 30 } }), + initialPageParam: 1, + getNextPageParam: lastPage => { + if (lastPage.meta.page * lastPage.meta.size >= lastPage.meta.total) return undefined; + return lastPage.meta.page + 1; + } + }); +}; diff --git a/census/website/src/services/video/CaptureEditorProvider.tsx b/census/website/src/services/video/CaptureEditorProvider.tsx index 68e1be7..05507ae 100644 --- a/census/website/src/services/video/CaptureEditorProvider.tsx +++ b/census/website/src/services/video/CaptureEditorProvider.tsx @@ -56,7 +56,9 @@ export const CaptureEditorProvider: FC s.nickname); const nickname = generatePlaceholderNickname(existing); set(draft => { - draft.subjects.push({ id: draft.subjects.length, nickname }); + const id = draft.subjects.length; + draft.subjects.push({ id, nickname }); + draft.selectedSubjectId = id; }); }, selectSubject: subjectId => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e44ce98..fb31de0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: postgres: specifier: ^3.4.4 version: 3.4.4 + sharp: + specifier: ^0.33.5 + version: 0.33.5 tsx: specifier: ^4.19.0 version: 4.19.1 @@ -227,6 +230,9 @@ importers: react-hook-form: specifier: ^7.53.0 version: 7.53.1(react@18.3.1) + react-intersection-observer: + specifier: ^9.13.1 + version: 9.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-markdown: specifier: ^9.0.1 version: 9.0.1(@types/react@18.3.11)(react@18.3.1) @@ -4984,6 +4990,15 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-intersection-observer@9.13.1: + resolution: {integrity: sha512-tSzDaTy0qwNPLJHg8XZhlyHTgGW6drFKTtvjdL+p6um12rcnp8Z5XstE+QNBJ7c64n5o0Lj4ilUleA41bmDoMw==} + peerDependencies: + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react-dom: + optional: true + react-markdown@9.0.1: resolution: {integrity: sha512-186Gw/vF1uRkydbsOIkcGXw7aHq0sZOCRFFjGrr7b9+nVZg4UfA4enXCaxm4fUzecU38sWfrNDitGhshuU7rdg==} peerDependencies: @@ -10402,6 +10417,12 @@ snapshots: dependencies: react: 18.3.1 + react-intersection-observer@9.13.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) + react-markdown@9.0.1(@types/react@18.3.11)(react@18.3.1): dependencies: '@types/hast': 3.0.4