diff --git a/k-tv-backend/Cargo.lock b/k-tv-backend/Cargo.lock index 1b9e8f5..c3f055d 100644 --- a/k-tv-backend/Cargo.lock +++ b/k-tv-backend/Cargo.lock @@ -1464,8 +1464,8 @@ dependencies = [ [[package]] name = "k-core" -version = "0.1.11" -source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-core#0ea9aa7870d73b5f665241a4183ffd899e628b9c" +version = "0.1.12" +source = "git+https://git.gabrielkaszewski.dev/GKaszewski/k-core#f17622306353b8b339d95bf37d43854f023c93da" dependencies = [ "anyhow", "async-nats", @@ -1727,7 +1727,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "chrono", "getrandom 0.2.16", "http", @@ -2384,7 +2384,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3102,7 +3102,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/k-tv-frontend/app/(auth)/layout.tsx b/k-tv-frontend/app/(auth)/layout.tsx new file mode 100644 index 0000000..69abc63 --- /dev/null +++ b/k-tv-frontend/app/(auth)/layout.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; + +export default function AuthLayout({ children }: { children: ReactNode }) { + return ( +
+
+ K-TV +
+ {children} +
+ ); +} diff --git a/k-tv-frontend/app/(auth)/login/page.tsx b/k-tv-frontend/app/(auth)/login/page.tsx new file mode 100644 index 0000000..2c9b133 --- /dev/null +++ b/k-tv-frontend/app/(auth)/login/page.tsx @@ -0,0 +1,74 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { useLogin } from "@/hooks/use-auth"; + +export default function LoginPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const { mutate: login, isPending, error } = useLogin(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + login({ email, password }); + }; + + return ( +
+
+

Sign in

+

to manage your channels

+
+ +
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none" + /> +
+ + {error &&

{error.message}

} + + +
+ +

+ No account?{" "} + + Create one + +

+
+ ); +} diff --git a/k-tv-frontend/app/(auth)/register/page.tsx b/k-tv-frontend/app/(auth)/register/page.tsx new file mode 100644 index 0000000..ccd4f68 --- /dev/null +++ b/k-tv-frontend/app/(auth)/register/page.tsx @@ -0,0 +1,74 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { useRegister } from "@/hooks/use-auth"; + +export default function RegisterPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const { mutate: register, isPending, error } = useRegister(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + register({ email, password }); + }; + + return ( +
+
+

Create account

+

start building your channels

+
+ +
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none" + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="••••••••" + className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none" + /> +
+ + {error &&

{error.message}

} + + +
+ +

+ Already have an account?{" "} + + Sign in + +

+
+ ); +} diff --git a/k-tv-frontend/app/(main)/components/nav-auth.tsx b/k-tv-frontend/app/(main)/components/nav-auth.tsx new file mode 100644 index 0000000..e0fb3a5 --- /dev/null +++ b/k-tv-frontend/app/(main)/components/nav-auth.tsx @@ -0,0 +1,41 @@ +"use client"; + +import Link from "next/link"; +import { useCurrentUser, useLogout } from "@/hooks/use-auth"; +import { useAuthContext } from "@/context/auth-context"; + +export function NavAuth() { + const { token, isLoaded } = useAuthContext(); + const { data: user } = useCurrentUser(); + const { mutate: logout, isPending } = useLogout(); + + if (!isLoaded) return null; + + if (!token) { + return ( + + Sign in + + ); + } + + return ( +
+ {user && ( + + {user.email} + + )} + +
+ ); +} diff --git a/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx b/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx new file mode 100644 index 0000000..ba746cb --- /dev/null +++ b/k-tv-frontend/app/(main)/dashboard/components/channel-card.tsx @@ -0,0 +1,96 @@ +import Link from "next/link"; +import { Pencil, Trash2, RefreshCw, Tv2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import type { ChannelResponse } from "@/lib/types"; + +interface ChannelCardProps { + channel: ChannelResponse; + isGenerating: boolean; + onEdit: () => void; + onDelete: () => void; + onGenerateSchedule: () => void; +} + +export function ChannelCard({ + channel, + isGenerating, + onEdit, + onDelete, + onGenerateSchedule, +}: ChannelCardProps) { + const blockCount = channel.schedule_config.blocks.length; + + return ( +
+ {/* Top row */} +
+
+

+ {channel.name} +

+ {channel.description && ( +

+ {channel.description} +

+ )} +
+ +
+ + +
+
+ + {/* Meta */} +
+ + {channel.timezone} + + + {blockCount} {blockCount === 1 ? "block" : "blocks"} + +
+ + {/* Actions */} +
+ + +
+
+ ); +} diff --git a/k-tv-frontend/app/(main)/dashboard/components/create-channel-dialog.tsx b/k-tv-frontend/app/(main)/dashboard/components/create-channel-dialog.tsx new file mode 100644 index 0000000..9b32d80 --- /dev/null +++ b/k-tv-frontend/app/(main)/dashboard/components/create-channel-dialog.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; + +interface CreateChannelDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (data: { + name: string; + timezone: string; + description: string; + }) => void; + isPending: boolean; + error?: string | null; +} + +export function CreateChannelDialog({ + open, + onOpenChange, + onSubmit, + isPending, + error, +}: CreateChannelDialogProps) { + const [name, setName] = useState(""); + const [timezone, setTimezone] = useState("UTC"); + const [description, setDescription] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit({ name, timezone, description }); + }; + + const handleOpenChange = (next: boolean) => { + if (!isPending) { + onOpenChange(next); + if (!next) { + setName(""); + setTimezone("UTC"); + setDescription(""); + } + } + }; + + return ( + + + + New channel + + +
+
+ + setName(e.target.value)} + placeholder="90s Sitcom Network" + className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none" + /> +
+ +
+ + setTimezone(e.target.value)} + placeholder="America/New_York" + className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-zinc-500 focus:outline-none" + /> +

+ IANA timezone, e.g. America/New_York, Europe/London, UTC +

+
+ +
+ +