Skip to content

Commit af751b1

Browse files
authored
Upload flow refactor - Add video upload API endpoint and refactor video components and schema (#4)
2 parents 6ff9976 + afae484 commit af751b1

30 files changed

+2159
-729
lines changed

app/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import {
2+
createStartAPIHandler,
3+
defaultAPIFileRouteHandler,
4+
} from "@tanstack/start/api";
5+
6+
export default createStartAPIHandler(defaultAPIFileRouteHandler);

app/components/dialogs/trim-video-dialog.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ function TrimVideoDialogChild() {
5252
const [videoDurationSeconds, setVideoDurationSeconds] = useState<number>();
5353

5454
const videoUrl = useMemo(
55-
// biome-ignore lint/style/noNonNullAssertion: will never be null
5655
() => URL.createObjectURL(trimVideoData!.file),
5756
[trimVideoData]
5857
);

app/components/uploading-videos-container.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,13 @@ export function UploadingVideosContainer() {
2929
),
3030
};
3131
});
32-
onUploadCancelledServerFn({ data: { videoId } });
3332

34-
toast.success("Cancelled uploading video", { description: videoTitle });
33+
toast.promise(onUploadCancelledServerFn({ data: { videoId } }), {
34+
loading: "Cancelling upload...",
35+
success: "Upload cancelled",
36+
error: "Failed to cancel upload",
37+
description: videoTitle,
38+
});
3539
}
3640

3741
return (

app/components/videos-board.tsx

Lines changed: 50 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,10 @@ type Video = {
4747
smallThumbnailUrl?: string | null;
4848
triggerAccessToken?: string;
4949
videoLengthSeconds?: number | null;
50-
isProcessing: boolean;
50+
status: "uploading" | "processing" | "ready" | "deleting";
5151
isPrivate: boolean;
5252
createdAt: string;
53-
deletionDate?: string | null;
53+
pendingDeletionDate?: string | null;
5454
};
5555

5656
export function VideosBoard({ videos }: { videos: Video[] }) {
@@ -85,24 +85,17 @@ function UploadedVideo({ video }: { video: Video }) {
8585
className="dark relative bg-card rounded-lg overflow-hidden shadow-md transition-shadow duration-300 ease-in-out hover:shadow-lg group aspect-video border-border/50 hover:border-border flex flex-col justify-between"
8686
>
8787
<div className="absolute inset-0">
88-
{video.smallThumbnailUrl ? (
89-
<img
90-
src={video.smallThumbnailUrl}
91-
alt={`${video.title} thumbnail`}
92-
className="transition-transform duration-200 ease-in-out group-hover:scale-105 w-full"
93-
/>
94-
) : (
95-
<TriggerAuthContext.Provider
96-
value={{ accessToken: video.triggerAccessToken }}
97-
>
98-
<ThumbnailPlaceholder videoId={video.id} />
99-
</TriggerAuthContext.Provider>
100-
)}
101-
<div className="absolute inset-0 bg-black bg-opacity-60 transition-opacity duration-300 ease-in-out group-hover:bg-opacity-40" />
88+
<VideoThumbnail
89+
smallThumbnailUrl={video.smallThumbnailUrl}
90+
title={video.title}
91+
videoId={video.id}
92+
triggerAccessToken={video.triggerAccessToken}
93+
/>
94+
<div className="absolute inset-0 bg-black bg-opacity-40 transition-opacity duration-300 ease-in-out group-hover:bg-opacity-20" />
10295
</div>
10396
<div className="absolute right-0 text-xs flex gap-1 m-1">
104-
{!!video.deletionDate && (
105-
<PendingDeletionChip deletionDate={video.deletionDate} />
97+
{!!video.pendingDeletionDate && (
98+
<PendingDeletionChip deletionDate={video.pendingDeletionDate} />
10699
)}
107100
{!Number.isNaN(Number.parseFloat(`${video.videoLengthSeconds}`)) && (
108101
<span className="p-1 bg-black/50 rounded-md backdrop-blur-md">
@@ -199,11 +192,49 @@ function UploadedVideo({ video }: { video: Video }) {
199192
);
200193
}
201194

195+
type VideoThumbnailProps = {
196+
smallThumbnailUrl?: string | null;
197+
title: string;
198+
videoId: string;
199+
triggerAccessToken?: string;
200+
};
201+
202+
function VideoThumbnail({
203+
smallThumbnailUrl,
204+
title,
205+
triggerAccessToken,
206+
videoId,
207+
}: VideoThumbnailProps) {
208+
if (smallThumbnailUrl) {
209+
return (
210+
<img
211+
src={smallThumbnailUrl}
212+
alt={`Thumbnail for ${title}`}
213+
className="transition-transform duration-200 ease-in-out group-hover:scale-105 w-full"
214+
loading="lazy"
215+
decoding="async"
216+
draggable="false"
217+
/>
218+
);
219+
}
220+
221+
if (triggerAccessToken) {
222+
return (
223+
<TriggerAuthContext.Provider value={{ accessToken: triggerAccessToken }}>
224+
<ThumbnailPlaceholder videoId={videoId} />
225+
</TriggerAuthContext.Provider>
226+
);
227+
}
228+
229+
return null;
230+
}
231+
202232
type ThumbnailPlaceholderProps = {
203233
videoId: string;
204234
};
235+
205236
function ThumbnailPlaceholder(props: ThumbnailPlaceholderProps) {
206-
const { runs } = useRealtimeRunsWithTag(`video-processing-${props.videoId}`);
237+
const { runs } = useRealtimeRunsWithTag(`videoProcessing_${props.videoId}`);
207238

208239
useEffect(() => {
209240
for (const run of runs) {

app/lib/env.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,11 @@ export const env = createEnv({
2323
PREMIUM_PRODUCT_ID: z.string(),
2424
PRO_PRODUCT_ID: z.string(),
2525
TOKEN_SIGNING_SECRET: z.string(),
26+
UPLOADTHING_TOKEN: z.string(),
27+
UPLOADTHING_APP_ID: z.string(),
2628
},
2729
clientPrefix: "VITE_",
28-
client: {
29-
VITE_CLERK_PUBLISHABLE_KEY: z.string().min(1),
30-
},
30+
client: {},
3131
skipValidation: process.env.NODE_ENV === undefined,
3232
runtimeEnv: process.env,
3333
emptyStringAsUndefined: true,

app/lib/query-utils.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,18 @@ const fetchVideos = createServerFn({ method: "GET" })
3535
id: true,
3636
isPrivate: true,
3737
videoLengthSeconds: true,
38-
isProcessing: true,
38+
status: true,
3939
createdAt: true,
4040
smallThumbnailKey: true,
41-
deletionDate: true,
41+
pendingDeletionDate: true,
4242
},
4343
});
4444

4545
const mappedVideos = (videoData ?? []).map((video) => {
4646
const data = {
4747
...video,
4848
createdAt: video.createdAt.toISOString(),
49-
deletionDate: video.deletionDate?.toISOString(),
49+
pendingDeletionDate: video.pendingDeletionDate?.toISOString(),
5050
smallThumbnailUrl: video.smallThumbnailKey
5151
? `${env.THUMBNAIL_BASE_URL}/${video.smallThumbnailKey}`
5252
: undefined,

app/lib/schema.ts

Lines changed: 40 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,38 @@ export const usersRelations = relations(users, ({ many }) => ({
3838
videos: many(videos),
3939
}));
4040

41+
export type UtVideoSource = {
42+
source: "ut";
43+
url: string;
44+
key: string;
45+
isNative: boolean;
46+
type?: string;
47+
};
48+
49+
export type S3VideoSource = {
50+
source: "s3";
51+
key: string;
52+
isNative: boolean;
53+
type?: string;
54+
width?: number;
55+
height?: number;
56+
bitrate?: number;
57+
};
58+
59+
export type VideoSource = UtVideoSource | S3VideoSource;
60+
61+
export type VideoStatus = "uploading" | "processing" | "ready" | "deleting";
62+
63+
export type VideoStoryboard = {
64+
tileWidth: number;
65+
tileHeight: number;
66+
tiles: {
67+
startTime: number;
68+
x: number;
69+
y: number;
70+
}[];
71+
};
72+
4173
export const videos = pgTable(
4274
"videos",
4375
{
@@ -46,7 +78,8 @@ export const videos = pgTable(
4678
.references(() => users.id, { onDelete: "cascade" })
4779
.notNull(),
4880
title: text("title").notNull(),
49-
nativeFileKey: text("native_file_key").notNull(),
81+
status: text("status").$type<VideoStatus>().notNull().default("uploading"),
82+
sources: jsonb("sources").$type<VideoSource[]>().notNull().default([]),
5083
smallThumbnailKey: text("small_thumbnail_key"),
5184
largeThumbnailKey: text("large_thumbnail_key"),
5285
createdAt: timestamp("created_at", { withTimezone: false })
@@ -55,40 +88,20 @@ export const videos = pgTable(
5588
updatedAt: timestamp("updated_at", { withTimezone: false })
5689
.notNull()
5790
.defaultNow(),
58-
deletionDate: timestamp("deletion_date", { withTimezone: false }),
59-
isPrivate: boolean("is_private").notNull().default(false),
6091
views: bigint("views", { mode: "number" }).notNull().default(0),
6192
fileSizeBytes: real("file_size_bytes").notNull(),
6293
videoLengthSeconds: integer("video_length_seconds"),
63-
isProcessing: boolean("is_processing").notNull().default(true),
64-
storyboardJson: jsonb("storyboard_json").$type<{
65-
tileWidth: number;
66-
tileHeight: number;
67-
tiles: {
68-
startTime: number;
69-
x: number;
70-
y: number;
71-
}[];
72-
}>(),
73-
sources: jsonb("sources")
74-
.$type<
75-
{
76-
key: string;
77-
type: string;
78-
width?: number;
79-
height?: number;
80-
bitrate?: number;
81-
isNative: boolean;
82-
}[]
83-
>()
84-
.notNull()
85-
.default([]),
94+
isPrivate: boolean("is_private").notNull().default(false),
95+
storyboardJson: jsonb("storyboard_json").$type<VideoStoryboard>(),
96+
pendingDeletionDate: timestamp("pending_deletion_date"),
8697
},
8798
(table) => ({
8899
authorId_idx: index("authorId_idx").on(table.authorId),
89100
videoId_idx: index("videoId_idx").on(table.id),
90101
createdAt_idx: index("createdAt_idx").on(table.createdAt),
91-
deletionDate_idx: index("deletionDate_idx").on(table.deletionDate),
102+
pendingDeletionDate_idx: index("pendingDeletionDate_idx").on(
103+
table.pendingDeletionDate
104+
),
92105
})
93106
);
94107

0 commit comments

Comments
 (0)