Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add home page and news feed #125

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .env.development

This file was deleted.

9 changes: 7 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/calendar_2023"
# NB: These are _not_ prefixed with NEXT_PUBLIC because we don't want their values to be sewn into the Docker image
PUBLIC_URL=
PUBLIC_URL=http://localhost:3000
GOOGLE_CLIENT_ID=
GOOGLE_PERMITTED_DOMAINS=
GOOGLE_PERMITTED_DOMAINS=ystv.co.uk
# Needed for youtube integration, optional otherwise. This is *not* the client secret!
GOOGLE_API_KEY=
ADAMRMS_EMAIL=
ADAMRMS_PASSWORD=
ADAMRMS_BASE=
ADAMRMS_PROJECT_TYPE_ID=
SESSION_SECRET=
COOKIE_DOMAIN=localhost
SLACK_ENABLED=
SLACK_BOT_TOKEN=
SLACK_APP_TOKEN=
Expand All @@ -21,3 +24,5 @@ SLACK_CLIENT_SECRET=
# SLACK_CLIENT_SECRET=
# Should be set if the slack integration is used across multiple workspaces
SLACK_TEAM_ID=
# Use https://www.streamweasels.com/tools/youtube-channel-id-and-user-id-convertor/ to convert
YOUTUBE_CHANNEL_ID=UCwViVJcFiwBSDmzhaHiagqw
16 changes: 13 additions & 3 deletions app/(authenticated)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,29 @@ import Image from "next/image";
import Logo from "@/app/_assets/logo.png";
import Link from "next/link";
import { UserProvider } from "@/components/UserContext";
import { mustGetCurrentUser } from "@/lib/auth/server";
import { getCurrentUser, mustGetCurrentUser } from "@/lib/auth/server";
import YSTVBreadcrumbs from "@/components/Breadcrumbs";
import * as Sentry from "@sentry/nextjs";
import { UserMenu } from "@/components/UserMenu";
import { WebsocketProvider } from "@/components/WebsocketProvider";
import { useCreateSocket } from "@/lib/socket";
import { NotLoggedIn } from "@/lib/auth/errors";
import { redirect } from "next/navigation";

export default async function AuthenticatedLayout({
children,
}: {
children: React.ReactNode;
}) {
const user = await mustGetCurrentUser();
let user;
try {
user = await getCurrentUser();
} catch (e) {
if (e instanceof NotLoggedIn) {
redirect("/login");
}
throw e;
}

Sentry.setUser({
id: user.user_id,
username: user.username,
Expand Down
175 changes: 175 additions & 0 deletions app/(authenticated)/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { getCurrentUser } from "@/lib/auth/server";
import { YouTubeEmbed } from "./youtubeEmbed";
import { Suspense } from "react";
import * as News from "@/features/news";
import * as YouTube from "@/features/youtube";
import * as Calendar from "@/features/calendar";
import { getUserName } from "@/components/UserHelpers";
import { DateTime } from "@/components/DateTimeHelpers";
import { Button, Paper } from "@mantine/core";
import { isSameDay } from "date-fns";
import { TbArticle } from "react-icons/tb";
import { PermissionGate } from "@/components/UserContext";
import Link from "next/link";

async function YouTubeTile() {
if (!YouTube.isEnabled()) {
return null;
}
const video = await YouTube.getLatestUpload();
if (!video) {
return null;
}
return (
<div>
<h2>Latest Upload</h2>
<div className="aspect-video w-full">
<YouTubeEmbed
id={video.id!.videoId!}
title={video.snippet?.title ?? ""}
poster="hqdefault"
/>
</div>
</div>
);
}

async function NewsRow() {
const newsItem = await News.getLatestNewsItem();
if (!newsItem) {
return null;
}
return (
<div>
<h2>Latest News</h2>
<Paper shadow="md" withBorder className="p-2">
<h3>{newsItem.title}</h3>
{newsItem.content.split("\n").map((line, idx) => (
<p key={idx}>{line}</p>
))}
<small>
Posted by {getUserName(newsItem.author)},{" "}
<DateTime val={newsItem.time.toISOString()} format="datetime" />
</small>
</Paper>
</div>
);
}

async function ProductionsNeedingCrew() {
const prods = await Calendar.listVacantEvents({});
if (!prods.events.length) {
return null;
}
return (
<div>
<h2>{prods.events.length} productions need crew</h2>
{prods.events.map((event) => (
<Paper
key={event.event_id}
shadow="sm"
radius="md"
withBorder
className="flex-grow-1 !flex w-full flex-col p-[var(--mantine-spacing-md)] md:w-[calc(50%-theme(gap.4)/2)] lg:flex-grow-0 lg:p-[var(--mantine-spacing-xl)]"
>
{/* TODO: this copies a lot from discoverView, could they be refactored */}
<h3 className={"m-0"}>{event.name}</h3>
<p className={"m-0 mb-2 text-sm"}>
<strong>
<DateTime
val={event.start_date.toISOString()}
format="datetime"
/>
{" - "}
{isSameDay(event.start_date, event.end_date) ? (
<DateTime val={event.end_date.toISOString()} format="time" />
) : (
<DateTime
val={event.end_date.toISOString()}
format="datetime"
/>
)}
</strong>
</p>
{event.signup_sheets
.filter((sheet) => sheet.crews.length > 0)
.map((sheet) => (
<div key={sheet.signup_id}>
<h3 className={"m-0 text-lg"}>{sheet.title}</h3>
<p className={"m-0 text-xs"}>
<DateTime
val={sheet.arrival_time.toISOString()}
format="datetime"
/>{" "}
-{" "}
{isSameDay(sheet.arrival_time, sheet.end_time) ? (
<DateTime
val={sheet.end_time.toISOString()}
format="time"
/>
) : (
<DateTime
val={sheet.end_time.toISOString()}
format="datetime"
/>
)}
</p>
{sheet.crews.map((crew) => (
<div key={crew.crew_id}>
<li className={"ml-6 text-base"}>{crew.positions.name}</li>
</div>
))}
</div>
))}
<div className={"flex grow items-end justify-end"}>
<Button
component={Link}
href={`/calendar/${event.event_id}`}
leftSection={<TbArticle />}
>
Event Details
</Button>
</div>
</Paper>
))}
</div>
);
}

export default async function HomePage() {
const me = await getCurrentUser();
return (
<div>
<h1>Welcome back, {me.first_name}!</h1>
<div className="mt-2 space-x-2">
<Button component={Link} href="/calendar" size="md">
Calendar
</Button>
{/* TODO not implemented yet */}
{/* <PermissionGate required="News.Admin">
<Button component={Link} href="/news" size="md">
News
</Button>
</PermissionGate> */}
<PermissionGate required="ManageQuotes">
<Button component={Link} href="/quotes" size="md">
Quotes
</Button>
</PermissionGate>
</div>
<div>
<Suspense fallback={<div>Loading...</div>}>
<NewsRow />
</Suspense>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Suspense fallback={<div>Loading...</div>}>
<ProductionsNeedingCrew />
</Suspense>
<Suspense fallback={<div>Loading...</div>}>
<YouTubeTile />
</Suspense>
</div>
</div>
);
}
8 changes: 8 additions & 0 deletions app/(authenticated)/youtubeEmbed.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"use client";

import LiteYouTubeEmbed, { LiteYouTubeProps } from "react-lite-youtube-embed";
import "react-lite-youtube-embed/dist/LiteYouTubeEmbed.css";

export function YouTubeEmbed(props: LiteYouTubeProps) {
return <LiteYouTubeEmbed {...props} />;
}
15 changes: 0 additions & 15 deletions app/page.tsx

This file was deleted.

16 changes: 16 additions & 0 deletions features/news/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { prisma } from "@/lib/db";

export async function getLatestNewsItem() {
return await prisma.newsItem.findFirst({
where: {
OR: [{ expires: null }, { expires: { gt: new Date() } }],
},
orderBy: {
time: "desc",
},
take: 1,
include: {
author: true,
},
});
}
29 changes: 29 additions & 0 deletions features/youtube/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { env } from "@/lib/env";
import invariant from "@/lib/invariant";
import { google } from "googleapis";

export function isEnabled() {
return env.GOOGLE_API_KEY && env.YOUTUBE_CHANNEL_ID;
}

export async function getLatestUpload() {
if (!env.GOOGLE_API_KEY || !env.YOUTUBE_CHANNEL_ID) {
return null;
}
const videos = await google.youtube("v3").search.list(
{
part: ["snippet"],
channelId: env.YOUTUBE_CHANNEL_ID,
type: ["video"],
key: env.GOOGLE_API_KEY,
order: "date",
},
{},
);
if (!videos.data.items?.length) {
return null;
}
const video = videos.data.items[0];
invariant(video.id?.videoId, "Video must have an ID");
return video;
}
14 changes: 14 additions & 0 deletions lib/db/migrations/20240819182647_add_newsitems/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "NewsItem" (
"id" SERIAL NOT NULL,
"author_id" INTEGER NOT NULL,
"time" TIMESTAMP(3) NOT NULL,
"expires" TIMESTAMP(3),
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,

CONSTRAINT "NewsItem_pkey" PRIMARY KEY ("id")
);

-- AddForeignKey
ALTER TABLE "NewsItem" ADD CONSTRAINT "NewsItem_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "users"("user_id") ON DELETE RESTRICT ON UPDATE CASCADE;
12 changes: 12 additions & 0 deletions lib/db/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ model User {
/// [UserPreferences]
preferences Json @default("{}")

newsItems NewsItem[]

@@map("users")
}

Expand Down Expand Up @@ -205,3 +207,13 @@ model EquipmentListTemplate {

@@map("equipment_list_templates")
}

model NewsItem {
id Int @id @default(autoincrement())
author_id Int
author User @relation(fields: [author_id], references: [user_id])
time DateTime
expires DateTime?
title String
content String
}
1 change: 1 addition & 0 deletions lib/db/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ export * from "./position"
export * from "./signupsheet"
export * from "./quote"
export * from "./equipmentlisttemplate"
export * from "./newsitem"
24 changes: 24 additions & 0 deletions lib/db/types/newsitem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as z from "zod"
import { CompleteUser, UserModel } from "./index"

export const _NewsItemModel = z.object({
id: z.number().int(),
author_id: z.number().int(),
time: z.date(),
expires: z.date().nullish(),
title: z.string(),
content: z.string(),
})

export interface CompleteNewsItem extends z.infer<typeof _NewsItemModel> {
author: CompleteUser
}

/**
* NewsItemModel contains all relations on your model in addition to the scalars
*
* NOTE: Lazy required in case of potential circular dependencies within schema
*/
export const NewsItemModel: z.ZodSchema<CompleteNewsItem> = z.lazy(() => _NewsItemModel.extend({
author: UserModel,
}))
4 changes: 3 additions & 1 deletion lib/db/types/user.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as z from "zod"
import { CompleteIdentity, IdentityModel, CompleteAttendee, AttendeeModel, CompleteCrew, CrewModel, CompleteEvent, EventModel, CompleteRoleMember, RoleMemberModel } from "./index"
import { CompleteIdentity, IdentityModel, CompleteAttendee, AttendeeModel, CompleteCrew, CrewModel, CompleteEvent, EventModel, CompleteRoleMember, RoleMemberModel, CompleteNewsItem, NewsItemModel } from "./index"

// Helper schema for JSON fields
type Literal = boolean | number | string
Expand Down Expand Up @@ -30,6 +30,7 @@ export interface CompleteUser extends z.infer<typeof _UserModel> {
events_events_updated_byTousers: CompleteEvent[]
role_members: CompleteRoleMember[]
hosted_events: CompleteEvent[]
newsItems: CompleteNewsItem[]
}

/**
Expand All @@ -46,4 +47,5 @@ export const UserModel: z.ZodSchema<CompleteUser> = z.lazy(() => _UserModel.exte
events_events_updated_byTousers: EventModel.array(),
role_members: RoleMemberModel.array(),
hosted_events: EventModel.array(),
newsItems: NewsItemModel.array(),
}))
Loading
Loading