diff --git a/.env.example b/.env.example index c7a886a..cc3ddec 100644 --- a/.env.example +++ b/.env.example @@ -17,11 +17,6 @@ CLOUDFLARE_STORAGE_BUCKET_NAME="jupiter-dev" OPENAI_API_KEY="" -# Panda - -PANDAVIDEO_API_KEY="" -PANDAVIDEO_UPLOAD_FOLDER="91568d19-8f77-40a7-b2c2-dd87502568ee" - # Auth NEXTAUTH_URL="http://localhost:3000" diff --git a/.eslintignore b/.eslintignore index 47f9975..daaf7c7 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,3 +1,4 @@ -next.config.js node_modules -.next \ No newline at end of file +.next +.turbo +.vercel \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 05b5725..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": [ - "@rocketseat/eslint-config/next", - "next/core-web-vitals" - ], - "plugins": ["simple-import-sort"], - "rules": { - "simple-import-sort/imports": "error", - "camelcase": "off" - } -} diff --git a/.gitignore b/.gitignore index 9b1913e..73adf6a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,29 +1,10 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - # dependencies /node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build # misc .DS_Store *.pem -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - # local env files .env .env*.local @@ -31,6 +12,5 @@ yarn-error.log* # vercel .vercel -# typescript -*.tsbuildinfo -next-env.d.ts +# turborepo +.turbo diff --git a/.vscode/settings.json b/.vscode/settings.json index 1a73a5e..ac17644 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,19 +1,25 @@ { "cSpell.words": [ "barberpole", + "bunnycdn", "cloudflare", "eden", "elysia", "hookform", "immer", "Immer", + "neondatabase", "NEXTAUTH", + "nextjs", + "nivo", "openai", "PANDAVIDEO", "presigner", "qstash", "QSTASH", + "superjson", "tanstack", - "upstash" + "upstash", + "webvtt" ] } \ No newline at end of file diff --git a/README.md b/README.md index a1c1b6e..c0443bf 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,3 @@ -Jupiter is a video upload hub that automates the generation of titles, descriptions and transcripts through AI. +# Nivo -### To-do - -- Whisper JAX -- New FFMPeg (@ffmpeg/core) -- Datadog / Sentry -- DeadLetter queue (manual retry) - - store number of retries and if its the last maybe notice somewhere and display a button for manual retry -- Find possibly typos in transcription based on commit diff -- Tests please?! +The all-in-one video solution for online learning. \ No newline at end of file diff --git a/apps/web/.gitignore b/apps/web/.gitignore new file mode 100644 index 0000000..8f6616c --- /dev/null +++ b/apps/web/.gitignore @@ -0,0 +1,39 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# turborepo +.turbo diff --git a/components.json b/apps/web/components.json similarity index 100% rename from components.json rename to apps/web/components.json diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs new file mode 100644 index 0000000..df00d2d --- /dev/null +++ b/apps/web/next.config.mjs @@ -0,0 +1,43 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + logging: { + fetches: { + fullUrl: true, + }, + }, + + typescript: { + ignoreBuildErrors: true, + }, + + eslint: { + ignoreDuringBuilds: true, + }, + + images: { + remotePatterns: [ + { hostname: 'lh3.googleusercontent.com' }, + { hostname: 'github.com' }, + ], + }, + + /** + * @param {import('webpack').Configuration} config + */ + webpack: (config) => { + /** + * Suppress warning about not found modules + */ + config.resolve.fallback = { + 'aws-crt': false, + encoding: false, + '@aws-sdk/signature-v4-crt': false, + bufferutil: false, + 'utf-8-validate': false, + } + + return config + }, +} + +export default nextConfig diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..d0f2b7c --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,95 @@ +{ + "name": "web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint ." + }, + "dependencies": { + "@hookform/resolvers": "3.3.4", + "@nivo/auth": "workspace:*", + "@nivo/bunny": "workspace:*", + "@nivo/cloudflare": "workspace:*", + "@nivo/dayjs": "workspace:*", + "@nivo/drizzle": "workspace:*", + "@nivo/env": "workspace:*", + "@nivo/ffmpeg": "workspace:*", + "@nivo/openai": "workspace:*", + "@nivo/qstash": "workspace:*", + "@nivo/trpc": "workspace:*", + "@nivo/webhooks": "workspace:*", + "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-alert-dialog": "1.0.4", + "@radix-ui/react-avatar": "1.0.3", + "@radix-ui/react-checkbox": "1.0.4", + "@radix-ui/react-dialog": "1.0.4", + "@radix-ui/react-dropdown-menu": "2.0.5", + "@radix-ui/react-hover-card": "1.0.6", + "@radix-ui/react-icons": "1.3.0", + "@radix-ui/react-label": "2.0.2", + "@radix-ui/react-popover": "1.0.6", + "@radix-ui/react-progress": "1.0.3", + "@radix-ui/react-scroll-area": "1.0.4", + "@radix-ui/react-select": "1.2.2", + "@radix-ui/react-separator": "1.0.3", + "@radix-ui/react-slot": "1.0.2", + "@radix-ui/react-switch": "1.0.3", + "@radix-ui/react-tabs": "1.0.4", + "@radix-ui/react-toast": "1.1.4", + "@radix-ui/react-tooltip": "1.0.6", + "@tanstack/react-query": "5.18.0", + "@trpc/client": "next", + "@trpc/react-query": "next", + "@trpc/server": "next", + "@upstash/kafka": "1.3.5", + "ai": "2.1.17", + "apexcharts": "^3.45.2", + "axios": "1.4.0", + "bunnycdn-stream": "^2.2.2", + "cmdk": "0.2.0", + "drizzle-orm": "^0.29.3", + "form-data": "4.0.0", + "immer": "10.0.3", + "jotai": "2.6.3", + "jotai-immer": "0.2.0", + "lucide-react": "0.319.0", + "next": "14.1.0", + "next-themes": "0.2.1", + "node-webvtt": "1.9.4", + "react": "^18.2.0", + "react-apexcharts": "^1.4.1", + "react-dom": "^18.2.0", + "react-dropzone": "14.2.3", + "react-hook-form": "7.49.3", + "recharts": "2.11.0", + "server-only": "^0.0.1", + "superjson": "^2.2.1", + "tailwind-merge": "2.2.1", + "tailwind-variants": "0.1.20", + "zod": "3.22.4" + }, + "devDependencies": { + "@types/node": "20.4.0", + "@types/react-dom": "18.2.19", + "@types/react": "18.2.55", + "@types/recharts": "1.8.24", + "autoprefixer": "10.4.17", + "postcss": "8.4.33", + "tailwindcss": "3.4.1", + "tailwindcss-animate": "1.0.6", + "@nivo/tsconfig": "workspace:*", + "@nivo/eslint-config": "workspace:*", + "@nivo/prettier": "workspace:*", + "typescript": "5.3.3" + }, + "eslintConfig": { + "root": true, + "extends": [ + "@nivo/eslint-config/next" + ] + }, + "prettier": "@nivo/prettier" +} diff --git a/postcss.config.js b/apps/web/postcss.config.js similarity index 100% rename from postcss.config.js rename to apps/web/postcss.config.js diff --git a/src/@types/node-webvtt.d.ts b/apps/web/src/@types/node-webvtt.d.ts similarity index 100% rename from src/@types/node-webvtt.d.ts rename to apps/web/src/@types/node-webvtt.d.ts diff --git a/src/app/(app)/(dashboard)/page.tsx b/apps/web/src/app/(app)/(dashboard)/page.tsx similarity index 63% rename from src/app/(app)/(dashboard)/page.tsx rename to apps/web/src/app/(app)/(dashboard)/page.tsx index 100754b..4973cfa 100644 --- a/src/app/(app)/(dashboard)/page.tsx +++ b/apps/web/src/app/(app)/(dashboard)/page.tsx @@ -1,9 +1,9 @@ import { Metadata } from 'next' +import { Suspense } from 'react' +import { Loading } from '@/components/summary/loading' import { Storage } from '@/components/summary/storage' import { TotalCount } from '@/components/summary/total-count' -import { ViewsCount } from '@/components/summary/views-count' -import { env } from '@/env' export const metadata: Metadata = { title: 'Dashboard', @@ -16,11 +16,12 @@ export default function DashboardPage() { <>

Dashboard

- - -
-
- {env.PANDAVIDEO_API_KEY && } + }> + + + }> + +
) diff --git a/src/app/(app)/batches/[id]/batch-upload-list.tsx b/apps/web/src/app/(app)/batches/[id]/batch-upload-list.tsx similarity index 73% rename from src/app/(app)/batches/[id]/batch-upload-list.tsx rename to apps/web/src/app/(app)/batches/[id]/batch-upload-list.tsx index 55e028a..5ca7acc 100644 --- a/src/app/(app)/batches/[id]/batch-upload-list.tsx +++ b/apps/web/src/app/(app)/batches/[id]/batch-upload-list.tsx @@ -1,10 +1,9 @@ 'use client' +import { dayjs } from '@nivo/dayjs' import { SymbolIcon } from '@radix-ui/react-icons' -import { useQuery } from '@tanstack/react-query' -import dayjs from 'dayjs' -import relativeTime from 'dayjs/plugin/relativeTime' import { Cable, CopyIcon, Loader2, ReceiptText } from 'lucide-react' +import Image from 'next/image' import Link from 'next/link' import { CopyButton } from '@/components/copy-button' @@ -17,39 +16,37 @@ import { TableHeader, TableRow, } from '@/components/ui/table' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' import { UploadItemActions } from '@/components/upload-item-actions' -import { api } from '@/lib/eden' +import { trpc } from '@/lib/trpc/react' import { formatBytes } from '@/utils/format-bytes' import { formatSecondsToMinutes } from '@/utils/format-seconds-to-minutes' import { BatchUploadSkeletonTable } from './batch-upload-skeleton-table' -dayjs.extend(relativeTime) - export interface BatchUploadListProps { batchId: string } export function BatchUploadList({ batchId }: BatchUploadListProps) { const { - data: batch, + data, isLoading: isLoadingBatch, isRefetching: isRefetchingBatch, - } = useQuery({ - queryKey: ['batch', batchId], - queryFn: async () => { - const { data, error } = await api.batches[batchId].get() - - if (error) { - throw error - } - - return data.batch + } = trpc.getUploadBatch.useQuery( + { + batchId, + }, + { + refetchInterval: 15 * 1000, + refetchIntervalInBackground: false, + refetchOnWindowFocus: true, }, - refetchInterval: 15 * 1000, - refetchIntervalInBackground: false, - refetchOnWindowFocus: true, - }) + ) return ( <> @@ -57,7 +54,7 @@ export function BatchUploadList({ batchId }: BatchUploadListProps) { - +
Video @@ -66,7 +63,7 @@ export function BatchUploadList({ batchId }: BatchUploadListProps) { )}
- Duration + Duration Size
@@ -80,6 +77,7 @@ export function BatchUploadList({ batchId }: BatchUploadListProps) { External ID
+
@@ -88,8 +86,8 @@ export function BatchUploadList({ batchId }: BatchUploadListProps) { ) : ( - {batch && batch.videos.length ? ( - batch.videos.map((video) => ( + {data?.batch && data.batch.uploads.length ? ( + data.batch.uploads.map((video) => ( )} + +
+ + {video.author?.image && ( + +
+ by + + + + {video.author?.name && ( + + {video.author?.name} + + )} +
+
+ )} +
+
diff --git a/src/app/(app)/batches/[id]/batch-upload-skeleton-table.tsx b/apps/web/src/app/(app)/batches/[id]/batch-upload-skeleton-table.tsx similarity index 100% rename from src/app/(app)/batches/[id]/batch-upload-skeleton-table.tsx rename to apps/web/src/app/(app)/batches/[id]/batch-upload-skeleton-table.tsx diff --git a/src/app/(app)/batches/[id]/page.tsx b/apps/web/src/app/(app)/batches/[id]/page.tsx similarity index 100% rename from src/app/(app)/batches/[id]/page.tsx rename to apps/web/src/app/(app)/batches/[id]/page.tsx diff --git a/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx similarity index 100% rename from src/app/(app)/layout.tsx rename to apps/web/src/app/(app)/layout.tsx diff --git a/apps/web/src/app/(app)/settings/aside-link.tsx b/apps/web/src/app/(app)/settings/aside-link.tsx new file mode 100644 index 0000000..abc3e67 --- /dev/null +++ b/apps/web/src/app/(app)/settings/aside-link.tsx @@ -0,0 +1,13 @@ +import Link from 'next/link' +import { ComponentProps } from 'react' + +import { NavLink } from '@/components/nav-link' + +export function AsideLink(props: ComponentProps) { + return ( + + ) +} diff --git a/apps/web/src/app/(app)/settings/developers/create-webhook.tsx b/apps/web/src/app/(app)/settings/developers/create-webhook.tsx new file mode 100644 index 0000000..019e5da --- /dev/null +++ b/apps/web/src/app/(app)/settings/developers/create-webhook.tsx @@ -0,0 +1,39 @@ +'use client' + +import { Plus } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' + +import { WebhookForm } from './webhook-form' + +export function CreateWebhook() { + return ( + + + + + + + + Listen to Nivo events + + Set up your webhook endpoint to receive live events from Nivo. + + + + + + + ) +} diff --git a/apps/web/src/app/(app)/settings/developers/logs/page.tsx b/apps/web/src/app/(app)/settings/developers/logs/page.tsx new file mode 100644 index 0000000..554fbc8 --- /dev/null +++ b/apps/web/src/app/(app)/settings/developers/logs/page.tsx @@ -0,0 +1,67 @@ +import { ChevronDown } from 'lucide-react' +import { Metadata } from 'next' + +import { Button } from '@/components/ui/button' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' + +import { WebhookLogsFilters } from './webhook-logs-filters' + +export const metadata: Metadata = { + title: 'Webhook logs', +} + +export default async function WebhookLogsPage() { + return ( +
+ +
+
+ + + Execution ID + HTTP Status + Event + Executed At + Duration + + + + + + + clsd5tjb3000008kzglvfeet6 + + +
+ + 204 +
+
+ + + video.created + + + + 2 minutes ago + + 6 seconds + + + +
+
+
+ + + ) +} diff --git a/apps/web/src/app/(app)/settings/developers/logs/webhook-logs-filters.tsx b/apps/web/src/app/(app)/settings/developers/logs/webhook-logs-filters.tsx new file mode 100644 index 0000000..3cf161c --- /dev/null +++ b/apps/web/src/app/(app)/settings/developers/logs/webhook-logs-filters.tsx @@ -0,0 +1,99 @@ +'use client' + +import { Filter, Loader2, X } from 'lucide-react' +import { useRouter, useSearchParams } from 'next/navigation' +import { FormEvent, useState, useTransition } from 'react' + +import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' + +export function WebhookLogsFilters() { + const router = useRouter() + const searchParams = useSearchParams() + const [isPendingFilterTransition, startTransition] = useTransition() + + const [events, setEvents] = useState( + searchParams.getAll('eventsFilter') ?? [], + ) + + function handleFilter(event: FormEvent) { + event.preventDefault() + + const params = new URLSearchParams(searchParams) + + params.delete('eventsFilter') + + events.forEach((event) => params.append('eventsFilter', event)) + + startTransition(() => { + router.push(`/settings/developers/logs?${params.toString()}`) + }) + } + + function handleResetFilters() { + setEvents([]) + + const params = new URLSearchParams(searchParams) + + params.delete('eventsFilter') + + startTransition(() => { + router.push(`/settings/developers/logs?${params.toString()}`) + }) + } + + const hasFilters = events.length > 0 + + return ( +
+ + + + + +
+ ) +} diff --git a/apps/web/src/app/(app)/settings/developers/page.tsx b/apps/web/src/app/(app)/settings/developers/page.tsx new file mode 100644 index 0000000..d8b96a8 --- /dev/null +++ b/apps/web/src/app/(app)/settings/developers/page.tsx @@ -0,0 +1,101 @@ +import { ClipboardCopy, Code2 } from 'lucide-react' +import { Metadata } from 'next' +import Link from 'next/link' + +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Separator } from '@/components/ui/separator' + +import { CreateWebhook } from './create-webhook' +import { WebhookDocsButton } from './webhooks-docs-button' +import { WebhooksList } from './webhooks-list' + +export const metadata: Metadata = { + title: 'Developers settings', +} + +export default async function SettingsPage() { + return ( + + + Developers + + Integrate with Nivo using Webhooks or our API. + + + +
+
+ + +

+ Access our{' '} + + documentation + {' '} + to understand how to use our API. +

+
+
+ +
+ + +
+
+ + + +
+
+
+ +

+ Listen to Nivo events in your application. +

+
+ +
+ +
+ + + +
+ + + +
+
+
+
+ ) +} diff --git a/apps/web/src/app/(app)/settings/developers/webhook-events-chart.tsx b/apps/web/src/app/(app)/settings/developers/webhook-events-chart.tsx new file mode 100644 index 0000000..9d94789 --- /dev/null +++ b/apps/web/src/app/(app)/settings/developers/webhook-events-chart.tsx @@ -0,0 +1,83 @@ +'use client' + +import { useTheme } from 'next-themes' +import Chart from 'react-apexcharts' +import { teal } from 'tailwindcss/colors' + +export default function WebhookEventsChart() { + const { resolvedTheme } = useTheme() + + return ( + + ) +} diff --git a/apps/web/src/app/(app)/settings/developers/webhook-form.tsx b/apps/web/src/app/(app)/settings/developers/webhook-form.tsx new file mode 100644 index 0000000..da41382 --- /dev/null +++ b/apps/web/src/app/(app)/settings/developers/webhook-form.tsx @@ -0,0 +1,119 @@ +'use client' + +import { zodResolver } from '@hookform/resolvers/zod' +import { webhookEventTrigger } from '@nivo/drizzle/schema' +import { RouterOutput } from '@nivo/trpc' +import { Loader2 } from 'lucide-react' +import { FormProvider, useForm } from 'react-hook-form' +import { z } from 'zod' + +import { Button } from '@/components/ui/button' +import { DialogTrigger } from '@/components/ui/dialog' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useToast } from '@/components/ui/use-toast' +import { trpc } from '@/lib/trpc/react' + +import { WebhookTriggersInput } from './webhook-triggers-input' + +export const createWebhookSchema = z.object({ + url: z.string().url({ message: 'Enter a valid URL.' }), + triggers: z + .array(webhookEventTrigger) + .min(1, { message: 'Select at least one event trigger.' }), +}) + +export type CreateWebhookSchema = z.infer + +interface WebhookFormProps { + webhookToEdit?: RouterOutput['getCompanyWebhooks']['companyWebhooks'][number] +} + +export function WebhookForm({ webhookToEdit }: WebhookFormProps) { + const { toast } = useToast() + const utils = trpc.useUtils() + + const webhookForm = useForm({ + resolver: zodResolver(createWebhookSchema), + defaultValues: { + url: webhookToEdit?.url ?? '', + triggers: webhookToEdit?.triggers ?? [], + }, + }) + + const { mutateAsync: createWebhook } = trpc.createCompanyWebhook.useMutation() + const { mutateAsync: updateWebhook } = trpc.updateCompanyWebhook.useMutation() + + async function handleSaveWebhook({ url, triggers }: CreateWebhookSchema) { + try { + const isEditingWebhook = webhookToEdit !== undefined + + if (isEditingWebhook) { + await updateWebhook({ + companyWebhookId: webhookToEdit.id, + url, + triggers, + }) + } else { + await createWebhook({ + url, + triggers, + }) + } + + if (!isEditingWebhook) { + webhookForm.reset({ + url: '', + triggers: [], + }) + } + + utils.getCompanyWebhooks.invalidate() + + toast({ + title: 'Webhook successfully saved!', + description: `Now your endpoint is listening to Nivo events!`, + variant: 'default', + }) + } catch { + toast({ + title: 'Uh oh! Something went wrong.', + description: `An error ocurred while trying to save the webhook.`, + variant: 'destructive', + }) + } + } + + const { + register, + handleSubmit, + formState: { errors, isSubmitting }, + } = webhookForm + + return ( + +
+
+ + + {errors.url && ( +

+ {errors.url.message} +

+ )} +
+ + + +
+ + + + +
+ +
+ ) +} diff --git a/apps/web/src/app/(app)/settings/developers/webhook-list-item-actions.tsx b/apps/web/src/app/(app)/settings/developers/webhook-list-item-actions.tsx new file mode 100644 index 0000000..afc1e52 --- /dev/null +++ b/apps/web/src/app/(app)/settings/developers/webhook-list-item-actions.tsx @@ -0,0 +1,158 @@ +'use client' + +import { RouterOutput } from '@nivo/trpc' +import { Pencil2Icon } from '@radix-ui/react-icons' +import { Loader2, MoreHorizontal, X } from 'lucide-react' +import { useState } from 'react' + +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from '@/components/ui/alert-dialog' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { useToast } from '@/components/ui/use-toast' +import { trpc } from '@/lib/trpc/react' + +import { WebhookForm } from './webhook-form' + +interface WebhookListItemActionsProps { + webhook: RouterOutput['getCompanyWebhooks']['companyWebhooks'][number] +} + +export function WebhookListItemActions({ + webhook, +}: WebhookListItemActionsProps) { + const { toast } = useToast() + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) + const utils = trpc.useUtils() + + const { mutateAsync: deleteCompanyWebhook, isPending: isDeletingWebhook } = + trpc.deleteCompanyWebhook.useMutation({ + onSuccess() { + utils.getCompanyWebhooks.invalidate() + }, + }) + + async function handleDeleteWebhook() { + try { + await deleteCompanyWebhook({ + companyWebhookId: webhook.id, + }) + + setIsDeleteDialogOpen(false) + } catch { + toast({ + title: 'Uh oh! Something went wrong.', + description: `An error ocurred while trying to delete the webhook.`, + variant: 'destructive', + }) + } + } + + return ( + + + + + + + + + e.preventDefault()}> + + Edit + + + + + + Listen to Nivo events + + Set up your webhook endpoint to receive live events from Nivo. + + + + + + + + + + e.preventDefault()} + > + + Delete + + + + + + Are you sure? + +

+ This action can't be undone and the webhook will be + permanently deleted from the server. +

+

This webhook is listening to the following events:

+
    + {webhook.triggers.map((trigger) => { + return ( +
  1. + + {trigger} + +
  2. + ) + })} +
+
+
+ + Cancel + + +
+
+
+
+ ) +} diff --git a/apps/web/src/app/(app)/settings/developers/webhook-triggers-input.tsx b/apps/web/src/app/(app)/settings/developers/webhook-triggers-input.tsx new file mode 100644 index 0000000..c83ce15 --- /dev/null +++ b/apps/web/src/app/(app)/settings/developers/webhook-triggers-input.tsx @@ -0,0 +1,83 @@ +import { Controller, useFormContext } from 'react-hook-form' + +import { Badge } from '@/components/ui/badge' +import { Checkbox } from '@/components/ui/checkbox' +import { Label } from '@/components/ui/label' +import { Skeleton } from '@/components/ui/skeleton' +import { trpc } from '@/lib/trpc/react' + +import { CreateWebhookSchema } from './create-webhook' + +export function WebhookTriggersInput() { + const { data, isLoading } = trpc.getAvailableTriggers.useQuery() + + const { + control, + formState: { errors }, + } = useFormContext() + + if (isLoading) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => { + return ( +
+ +
+ + +
+
+ ) + })} +
+ ) + } + + return ( +
+
+ {data && + data.triggers.map(({ trigger, description }) => { + return ( +
+ +

+ {description} +

+
+ ) + })} +
+ {errors.triggers && ( +

+ {errors.triggers.message} +

+ )} +
+ ) +} diff --git a/apps/web/src/app/(app)/settings/developers/webhooks-docs-button.tsx b/apps/web/src/app/(app)/settings/developers/webhooks-docs-button.tsx new file mode 100644 index 0000000..938779f --- /dev/null +++ b/apps/web/src/app/(app)/settings/developers/webhooks-docs-button.tsx @@ -0,0 +1,55 @@ +import { BookText } from 'lucide-react' + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { serverClient } from '@/lib/trpc/server' + +export async function WebhookDocsButton() { + const { triggers } = await serverClient.getAvailableTriggers() + + return ( + + + + + + + + Webhook events + + Listen to events that happen inside Nivo in your application. + + + +
+ + {triggers.map(({ trigger, description }) => { + return ( + + {trigger} + {description} + + ) + })} + +
+
+
+ ) +} diff --git a/apps/web/src/app/(app)/settings/developers/webhooks-list-loading.tsx b/apps/web/src/app/(app)/settings/developers/webhooks-list-loading.tsx new file mode 100644 index 0000000..052e61a --- /dev/null +++ b/apps/web/src/app/(app)/settings/developers/webhooks-list-loading.tsx @@ -0,0 +1,59 @@ +import { Skeleton } from '@/components/ui/skeleton' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' + +export default function WebhooksListLoading() { + return ( +
+ + + + URL + Triggers + Last 7 days + + Error rate + + Status + + + + + {Array.from({ length: 2 }).map((_, row) => { + return ( + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ ) + })} +
+
+
+ ) +} diff --git a/apps/web/src/app/(app)/settings/developers/webhooks-list.tsx b/apps/web/src/app/(app)/settings/developers/webhooks-list.tsx new file mode 100644 index 0000000..75b15b2 --- /dev/null +++ b/apps/web/src/app/(app)/settings/developers/webhooks-list.tsx @@ -0,0 +1,100 @@ +'use client' + +import { ClipboardCopy, Globe } from 'lucide-react' +import dynamic from 'next/dynamic' + +import { Button } from '@/components/ui/button' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { trpc } from '@/lib/trpc/react' + +import { WebhookListItemActions } from './webhook-list-item-actions' +import WebhooksListLoading from './webhooks-list-loading' + +const WebhookEventsChart = dynamic(() => import('./webhook-events-chart'), { + ssr: false, +}) + +export function WebhooksList() { + const { data, isLoading } = trpc.getCompanyWebhooks.useQuery() + + if (isLoading) { + return + } + + return ( +
+ + + + URL + Triggers + Last 7 days + + Error rate + + Status + + + + + {data?.companyWebhooks.map((webhook) => { + return ( + + +
+ + + {webhook.url} + +
+
+ + + + {webhook.triggers.length} event(s) + + + {webhook.triggers.join(', ')} + + + + + + + 3% + +
+ + ACTIVE +
+
+ +
+ + + +
+
+
+ ) + })} +
+
+
+ ) +} diff --git a/apps/web/src/app/(app)/settings/layout.tsx b/apps/web/src/app/(app)/settings/layout.tsx new file mode 100644 index 0000000..d59fe30 --- /dev/null +++ b/apps/web/src/app/(app)/settings/layout.tsx @@ -0,0 +1,23 @@ +import { ReactNode } from 'react' + +import { AsideLink } from './aside-link' + +export default async function Layout({ children }: { children: ReactNode }) { + return ( + <> +

Settings

+ +
+ + +
{children}
+
+ + ) +} diff --git a/apps/web/src/app/(app)/settings/organization/page.tsx b/apps/web/src/app/(app)/settings/organization/page.tsx new file mode 100644 index 0000000..7d4b7b5 --- /dev/null +++ b/apps/web/src/app/(app)/settings/organization/page.tsx @@ -0,0 +1,117 @@ +import { Metadata } from 'next' +import { unstable_noStore } from 'next/cache' +import Image from 'next/image' + +import { Avatar } from '@/components/ui/avatar' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Select, SelectTrigger } from '@/components/ui/select' +import { Separator } from '@/components/ui/separator' +import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table' +import { serverClient } from '@/lib/trpc/server' + +export const metadata: Metadata = { + title: 'Organization settings', +} + +export default async function OrganizationPage() { + unstable_noStore() + + const { company } = await serverClient.getCurrentUserCompany() + + return ( + + + Organization + Update your organization information. + + +
+
+ + +
+ +
+ + +

+ All users that authenticate with this domain will automatically + join your organization. +

+
+ +
+ +
+ + + {company.members.map((member) => { + return ( + + + + {member.image ? ( + + ) : ( +
+ )} + + + +
+ + {member.name} + + + + {member.email} + +
+
+ + + + + ) + })} + +
+
+
+ + + +

+ You cannot update your organization profile yet. +

+ +
+
+ ) +} diff --git a/apps/web/src/app/(app)/settings/page.tsx b/apps/web/src/app/(app)/settings/page.tsx new file mode 100644 index 0000000..73eb6f3 --- /dev/null +++ b/apps/web/src/app/(app)/settings/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from 'next/navigation' + +export default async function SettingsPage() { + return redirect('/settings/profile') +} diff --git a/apps/web/src/app/(app)/settings/profile/page.tsx b/apps/web/src/app/(app)/settings/profile/page.tsx new file mode 100644 index 0000000..7cb4aae --- /dev/null +++ b/apps/web/src/app/(app)/settings/profile/page.tsx @@ -0,0 +1,82 @@ +import { auth } from '@nivo/auth' +import { Metadata } from 'next' +import Image from 'next/image' + +import { Avatar } from '@/components/ui/avatar' +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Separator } from '@/components/ui/separator' + +export const metadata: Metadata = { + title: 'Profile settings', +} + +export default async function ProfilePage() { + const session = await auth() + + if (!session || !session.user) { + throw new Error('Invalid session data.') + } + + const { user } = session + + return ( + + + Profile + Update your profile information. + + +
+
+ + + {user.image ? ( + + ) : ( +
+ )} + +
+ +
+ + +
+ +
+ + +
+ +

+ You cannot update your profile yet. +

+ + + + ) +} diff --git a/src/app/(app)/upload/page.tsx b/apps/web/src/app/(app)/upload/page.tsx similarity index 100% rename from src/app/(app)/upload/page.tsx rename to apps/web/src/app/(app)/upload/page.tsx diff --git a/src/app/(app)/upload/upload-list/columns/audio-conversion-progress-column.tsx b/apps/web/src/app/(app)/upload/upload-list/columns/audio-conversion-progress-column.tsx similarity index 100% rename from src/app/(app)/upload/upload-list/columns/audio-conversion-progress-column.tsx rename to apps/web/src/app/(app)/upload/upload-list/columns/audio-conversion-progress-column.tsx diff --git a/src/app/(app)/upload/upload-list/columns/audio-upload-progress-column.tsx b/apps/web/src/app/(app)/upload/upload-list/columns/audio-upload-progress-column.tsx similarity index 100% rename from src/app/(app)/upload/upload-list/columns/audio-upload-progress-column.tsx rename to apps/web/src/app/(app)/upload/upload-list/columns/audio-upload-progress-column.tsx diff --git a/src/app/(app)/upload/upload-list/columns/video-upload-progress-column.tsx b/apps/web/src/app/(app)/upload/upload-list/columns/video-upload-progress-column.tsx similarity index 100% rename from src/app/(app)/upload/upload-list/columns/video-upload-progress-column.tsx rename to apps/web/src/app/(app)/upload/upload-list/columns/video-upload-progress-column.tsx diff --git a/src/app/(app)/upload/upload-list/header.tsx b/apps/web/src/app/(app)/upload/upload-list/header.tsx similarity index 95% rename from src/app/(app)/upload/upload-list/header.tsx rename to apps/web/src/app/(app)/upload/upload-list/header.tsx index 18f1bcf..b12bb9e 100644 --- a/src/app/(app)/upload/upload-list/header.tsx +++ b/apps/web/src/app/(app)/upload/upload-list/header.tsx @@ -25,7 +25,6 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Separator } from '@/components/ui/separator' -import { api } from '@/lib/eden' import { amountOfUploadsAtom, areUploadsEmptyAtom, @@ -60,17 +59,15 @@ export function Header({ onSubmit }: HeaderProps) { Array.from(uploads.values()).map(async (upload, index) => { const fileName = upload.file.name - const { data, error } = await api.ai.generate.title.post({ - $query: { + const response = await axios.get('/api/ai/generate/title', { + params: { slug: fileName, }, }) - if (error) { - throw error - } + const { title } = response.data - setValue(`files.${index}.title`, data.title, { + setValue(`files.${index}.title`, title, { shouldValidate: true, }) }), diff --git a/src/app/(app)/upload/upload-list/index.tsx b/apps/web/src/app/(app)/upload/upload-list/index.tsx similarity index 89% rename from src/app/(app)/upload/upload-list/index.tsx rename to apps/web/src/app/(app)/upload/upload-list/index.tsx index 6e6e529..a511b73 100644 --- a/src/app/(app)/upload/upload-list/index.tsx +++ b/apps/web/src/app/(app)/upload/upload-list/index.tsx @@ -1,14 +1,13 @@ 'use client' import { zodResolver } from '@hookform/resolvers/zod' -import axios from 'axios' import { useRouter } from 'next/navigation' import { FormProvider, useForm } from 'react-hook-form' import { z } from 'zod' import { ToastAction } from '@/components/ui/toast' import { useToast } from '@/components/ui/use-toast' -import { api } from '@/lib/eden' +import { trpc } from '@/lib/trpc/react' import { Header } from './header' import { UploadDropArea } from './upload-drop-area' @@ -44,17 +43,14 @@ export function UploadList() { formState: { isSubmitting }, } = uploadsForm + const { mutateAsync: createUploadBatch } = + trpc.createUploadBatch.useMutation() + async function handleCreateUploadBatch({ files }: UploadsFormSchema) { try { - const { data, error } = await api.batches.post({ - files, - }) - - if (error) { - throw error - } + const { batchId } = await createUploadBatch({ files }) - router.push(`/batches/${data.batchId}`) + router.push(`/batches/${batchId}`) } catch { toast({ title: 'Uh oh! Something went wrong.', diff --git a/src/app/(app)/upload/upload-list/upload-drop-area.tsx b/apps/web/src/app/(app)/upload/upload-list/upload-drop-area.tsx similarity index 100% rename from src/app/(app)/upload/upload-list/upload-drop-area.tsx rename to apps/web/src/app/(app)/upload/upload-list/upload-drop-area.tsx diff --git a/src/app/(app)/upload/upload-list/upload-language-input.tsx b/apps/web/src/app/(app)/upload/upload-list/upload-language-input.tsx similarity index 100% rename from src/app/(app)/upload/upload-list/upload-language-input.tsx rename to apps/web/src/app/(app)/upload/upload-list/upload-language-input.tsx diff --git a/src/app/(app)/upload/upload-list/upload-table.tsx b/apps/web/src/app/(app)/upload/upload-list/upload-table.tsx similarity index 98% rename from src/app/(app)/upload/upload-list/upload-table.tsx rename to apps/web/src/app/(app)/upload/upload-list/upload-table.tsx index 47b4efb..15264e8 100644 --- a/src/app/(app)/upload/upload-list/upload-table.tsx +++ b/apps/web/src/app/(app)/upload/upload-list/upload-table.tsx @@ -53,7 +53,7 @@ export function UploadTable() { useEffect(() => { if (isThereAnyPendingUpload) { window.onbeforeunload = function () { - return 'Você possui uploads pendentes. Deseja realmente sair e cancelar TODOS uploads?' + return 'You have pending uploads. Do you really want to exit and cancel ALL uploads?' } } else { window.onbeforeunload = null diff --git a/src/app/(app)/upload/upload-list/upload-tag-input.tsx b/apps/web/src/app/(app)/upload/upload-list/upload-tag-input.tsx similarity index 92% rename from src/app/(app)/upload/upload-list/upload-tag-input.tsx rename to apps/web/src/app/(app)/upload/upload-list/upload-tag-input.tsx index 9b37207..9ef689b 100644 --- a/src/app/(app)/upload/upload-list/upload-tag-input.tsx +++ b/apps/web/src/app/(app)/upload/upload-list/upload-tag-input.tsx @@ -1,7 +1,7 @@ 'use client' import { useAtomValue } from 'jotai' -import { useController, useFieldArray, useFormContext } from 'react-hook-form' +import { useController, useFormContext } from 'react-hook-form' import { TagInput } from '@/components/tag-input' import { amountOfUploadsAtom } from '@/state/uploads' diff --git a/src/app/(app)/uploads/layout.tsx b/apps/web/src/app/(app)/uploads/layout.tsx similarity index 80% rename from src/app/(app)/uploads/layout.tsx rename to apps/web/src/app/(app)/uploads/layout.tsx index b27c3bb..859e8cf 100644 --- a/src/app/(app)/uploads/layout.tsx +++ b/apps/web/src/app/(app)/uploads/layout.tsx @@ -1,11 +1,7 @@ -import dayjs from 'dayjs' -import relativeTime from 'dayjs/plugin/relativeTime' import { ReactNode, Suspense } from 'react' import { UploadsFilters } from './uploads-filters' -dayjs.extend(relativeTime) - export default async function Layout({ children }: { children: ReactNode }) { return ( <> diff --git a/src/app/(app)/uploads/loading.tsx b/apps/web/src/app/(app)/uploads/loading.tsx similarity index 100% rename from src/app/(app)/uploads/loading.tsx rename to apps/web/src/app/(app)/uploads/loading.tsx diff --git a/src/app/(app)/uploads/page.tsx b/apps/web/src/app/(app)/uploads/page.tsx similarity index 70% rename from src/app/(app)/uploads/page.tsx rename to apps/web/src/app/(app)/uploads/page.tsx index ec55a3c..8ebe243 100644 --- a/src/app/(app)/uploads/page.tsx +++ b/apps/web/src/app/(app)/uploads/page.tsx @@ -1,16 +1,15 @@ import { SymbolIcon } from '@radix-ui/react-icons' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' -import { Cable, CopyIcon, ReceiptText } from 'lucide-react' +import { Cable, CopyIcon } from 'lucide-react' import { Metadata } from 'next' import { unstable_noStore } from 'next/cache' -import { headers } from 'next/headers' +import Image from 'next/image' import Link from 'next/link' import { Suspense } from 'react' import { z } from 'zod' import { CopyButton } from '@/components/copy-button' -import { TranscriptionPreview } from '@/components/transcription-preview' import { Table, TableBody, @@ -19,8 +18,13 @@ import { TableHeader, TableRow, } from '@/components/ui/table' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' import { UploadItemActions } from '@/components/upload-item-actions' -import { api } from '@/lib/eden' +import { serverClient } from '@/lib/trpc/server' import { formatBytes } from '@/utils/format-bytes' import { formatSecondsToMinutes } from '@/utils/format-seconds-to-minutes' @@ -51,21 +55,16 @@ export default async function UploadsPage({ }) { unstable_noStore() - const query = uploadsPageSearchParams.parse(searchParams) + const { pageIndex, pageSize, titleFilter, tagsFilter } = + uploadsPageSearchParams.parse(searchParams) - const { data, error } = await api.videos.get({ - $query: query, - $fetch: { - headers: Object.fromEntries(headers().entries()), - }, + const { videos, pageCount } = await serverClient.getUploads({ + pageIndex, + pageSize, + titleFilter, + tagsFilter, }) - if (error) { - throw error - } - - const { videos, pageCount } = data - return ( <>
@@ -75,19 +74,13 @@ export default async function UploadsPage({ Video Duration Size - -
- - Transcription -
-
- External ID + External
- Uploaded at + @@ -115,25 +108,12 @@ export default async function UploadsPage({
- + {formatSecondsToMinutes(video.duration)} - + {formatBytes(video.sizeInBytes)} - - {video.transcription ? ( - - ) : ( -
- - - - Processing - -
- )} -
{video.externalProviderId ? (
@@ -159,12 +139,32 @@ export default async function UploadsPage({ )} - +
+ + {video.author?.image && ( + +
+ by + + + + {video.author?.name && ( + + {video.author?.name} + + )} +
+
+ )} +
@@ -185,8 +185,8 @@ export default async function UploadsPage({ diff --git a/src/app/(app)/uploads/uploads-filters.tsx b/apps/web/src/app/(app)/uploads/uploads-filters.tsx similarity index 100% rename from src/app/(app)/uploads/uploads-filters.tsx rename to apps/web/src/app/(app)/uploads/uploads-filters.tsx diff --git a/src/app/(app)/uploads/uploads-pagination.tsx b/apps/web/src/app/(app)/uploads/uploads-pagination.tsx similarity index 100% rename from src/app/(app)/uploads/uploads-pagination.tsx rename to apps/web/src/app/(app)/uploads/uploads-pagination.tsx diff --git a/src/app/(app)/videos/[id]/page.tsx b/apps/web/src/app/(app)/videos/[id]/page.tsx similarity index 80% rename from src/app/(app)/videos/[id]/page.tsx rename to apps/web/src/app/(app)/videos/[id]/page.tsx index 2c631ae..600875a 100644 --- a/src/app/(app)/videos/[id]/page.tsx +++ b/apps/web/src/app/(app)/videos/[id]/page.tsx @@ -4,7 +4,6 @@ import { Metadata } from 'next' import { Button } from '@/components/ui/button' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { prisma } from '@/lib/prisma' import { Overview } from './tabs/overview' import { Webhooks } from './tabs/webhooks' @@ -13,23 +12,12 @@ interface VideoPageProps { params: { id: string } } -export async function generateMetadata({ - params, -}: VideoPageProps): Promise { - const videoId = params.id - - const { title } = await prisma.video.findFirstOrThrow({ - where: { id: videoId }, - select: { title: true }, - }) - +export async function generateMetadata(): Promise { return { - title: `${title}`, + title: 'Video', } } -export const dynamic = 'force-dynamic' - export default async function VideoPage({ params }: VideoPageProps) { const videoId = params.id @@ -41,7 +29,7 @@ export default async function VideoPage({ params }: VideoPageProps) {
- -
- - + +
+ + {video.externalStatus || 'waiting'} + + + +
@@ -116,7 +134,11 @@ export function VideoForm({ video }: VideoFormProps) { Commit reference{' '} (synced with Skylab) - + {errors.commitUrl && (

{errors.commitUrl.message} @@ -127,7 +149,7 @@ export function VideoForm({ video }: VideoFormProps) {