Skip to content

Commit

Permalink
✨ feat(article): add sbs news
Browse files Browse the repository at this point in the history
  • Loading branch information
summerscar committed Dec 17, 2024
1 parent 04fe8e1 commit 6b10127
Show file tree
Hide file tree
Showing 11 changed files with 317 additions and 11 deletions.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ AUTH_GOOGLE_SECRET=""
# web push
NEXT_PUBLIC_VAPID_PUBLIC_KEY=""
VAPID_PRIVATE_KEY=""

# Google api key
YOUTUBE_API_KEY=""
13 changes: 13 additions & 0 deletions app/(home)/article/[slug]/_components/text.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { SelectToSearch } from "@/hooks/use-select-to-search";
import { renderMDTextServer } from "@/utils/render-md-server";

const Text = ({ content }: { content: string }) => {
const resolvedContent = content.replace("frameborder", "frameBorder");
return (
<SelectToSearch showAdd prompt="sentence">
{renderMDTextServer(resolvedContent)}
</SelectToSearch>
);
};

export { Text };
11 changes: 7 additions & 4 deletions app/(home)/article/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { redirect } from "next/navigation";
import { cache } from "react";
import { EPSelect } from "./_components/ep-select";
import { ArticleMovie } from "./_components/movie";
import { Text } from "./_components/text";

const getArticle = cache(
async (slug: string) =>
Expand Down Expand Up @@ -84,6 +85,8 @@ const SlugPage = async ({
subtitleSeries={article.subtitles as SubtitleSeries}
/>
);
} else if (article.type === "TEXT") {
children = <Text content={article.content} />;
}

return (
Expand All @@ -94,7 +97,7 @@ const SlugPage = async ({
<img
src={article.poster}
alt={article.title}
className="w-full h-auto rounded-lg shadow-lg object-cover"
className="w-full h-auto rounded-lg shadow-lg object-cover aspect-video"
style={{ viewTransitionName: `article-image-${article.id}` }}
/>
</div>
Expand All @@ -106,9 +109,9 @@ const SlugPage = async ({
fontFamily: notoKR.style.fontFamily,
viewTransitionName: `article-title-${article.id}`,
}}
>
{article.title}
</div>
// biome-ignore lint/security/noDangerouslySetInnerHtml: <explanation>
dangerouslySetInnerHTML={{ __html: article.title }}
/>
<div
className="text-base text-base-content/70 leading-relaxed flex-grow"
style={{
Expand Down
6 changes: 3 additions & 3 deletions app/(home)/article/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ const ArticlePage = async () => {
<h2
className="card-title"
style={{ viewTransitionName: `article-title-${article.id}` }}
>
{article.title}
</h2>
// biome-ignore lint/security/noDangerouslySetInnerHtml: <explanation>
dangerouslySetInnerHTML={{ __html: article.title }}
/>
<div
style={{
viewTransitionName: `article-description-${article.id}`,
Expand Down
89 changes: 89 additions & 0 deletions app/api/cron/sbs-news/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { keystoneContext } from "@/../keystone/context";
import { allArticlesRevalidateKey } from "@/actions/user-dict-utils";
import { google } from "googleapis";
import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server";
import { buildContent } from "./template";

const youtube = google.youtube("v3");
const channelId = "UCkinYTS9IHqOEwR1Sze2JTw";
// Ensure you set this as an environment variable
const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY;

export async function GET() {
try {
if (!YOUTUBE_API_KEY) {
return NextResponse.json(
{ error: "YouTube API key is missing" },
{ status: 400 },
);
}

// Calculate the timestamp for 24 hours ago
const twentyFourHoursAgo = new Date(
Date.now() - 24 * 60 * 60 * 1000,
).toISOString();

const response = await youtube.search.list({
key: YOUTUBE_API_KEY,
channelId, // SBS News YouTube Channel ID
part: ["snippet"],
type: ["video"],
order: "viewCount",
publishedAfter: twentyFourHoursAgo,
maxResults: 1,
});

const videos = await Promise.all(
(response.data.items || []).map(async (item) => {
const videoId = item.id?.videoId;

if (!videoId) return null;

// Fetch detailed video statistics
const statsResponse = await youtube.videos.list({
key: YOUTUBE_API_KEY,
part: ["snippet", "statistics"],
id: [videoId],
});

const videoStats = statsResponse.data.items?.[0]?.statistics;
const fullSnippet = statsResponse.data.items?.[0]?.snippet;
console.log("item.snippet?.thumbnails", item.snippet?.thumbnails);
return {
videoId,
title: item.snippet?.title,
description: fullSnippet?.description || item.snippet?.description,
publishedAt: item.snippet?.publishedAt,
viewCount: Number(videoStats?.viewCount || 0),
thumbnailUrl: item.snippet?.thumbnails?.high?.url,
};
}),
);
// Filter out null results and sort by view count
const mostViewedVideo = videos[0];
if (!mostViewedVideo) {
throw new Error("No mostViewedVideo video found");
}

await keystoneContext.db.Article.createOne({
data: {
title: mostViewedVideo.title,
type: "TEXT",
description: `SBS News [Read more](https://www.youtube.com/watch?v=${mostViewedVideo.videoId})`,
poster: mostViewedVideo.thumbnailUrl,
content: buildContent(mostViewedVideo),
},
});

revalidateTag(allArticlesRevalidateKey);

return NextResponse.json({ status: 200 });
} catch (error) {
console.error("Error fetching YouTube videos:", error);
return NextResponse.json(
{ error: "Failed to fetch videos" },
{ status: 500 },
);
}
}
29 changes: 29 additions & 0 deletions app/api/cron/sbs-news/template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
type Video = {
videoId: string;
title: string | null | undefined;
description: string | null | undefined;
publishedAt: string | null | undefined;
viewCount: number;
thumbnailUrl: string | null | undefined;
};

const buildContent = (video: Video) => {
return `
<iframe
width="560"
height="315"
src="https://www.youtube.com/embed/${video.videoId}"
title="${video.title}"
frameborder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
/>
${video.description}
> Published at ${video.publishedAt}
`;
};

export { buildContent };
1 change: 1 addition & 0 deletions app/components/float-buttons-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
"use client";
import { generateWordSuggestionAction } from "@/actions/generate-word-action";
import { addWordsToUserDictAction } from "@/actions/user-dict-action";
import { SearchButton } from "@/components/select-search-button";
Expand Down
41 changes: 37 additions & 4 deletions app/hooks/use-select-to-search.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
"use client";
import { FloatButtonsPanel } from "@/components/float-buttons-panel";
import {
generateSentenceSuggestionPrompt,
generateWordSuggestionPrompt,
} from "@/utils/prompts";
import { useDebounceFn, useEventListener } from "ahooks";
import { type ComponentProps, useRef, useState } from "react";
import {
type ComponentProps,
type PropsWithChildren,
type RefObject,
useRef,
useState,
} from "react";

const useSelectToSearch = ({
showCopy = true,
Expand Down Expand Up @@ -39,6 +50,12 @@ const useSelectToSearch = ({
useEventListener("mouseup", showSearchButton, {
target: containerRef,
});
const promptFn =
prompt === "sentence"
? generateSentenceSuggestionPrompt
: prompt === "word"
? generateWordSuggestionPrompt
: prompt;

const panel = showPanel ? (
<FloatButtonsPanel
Expand All @@ -48,7 +65,7 @@ const useSelectToSearch = ({
showCopy={showCopy}
showAI={showAI}
showAdd={showAdd}
prompt={prompt}
prompt={promptFn}
onClose={() => {
setShowPanel(false);
}}
Expand All @@ -63,7 +80,23 @@ type Config = {
showSearch?: boolean;
showAI?: boolean;
showAdd?: boolean;
prompt?: ComponentProps<typeof FloatButtonsPanel>["prompt"];
prompt?:
| ComponentProps<typeof FloatButtonsPanel>["prompt"]
| "sentence"
| "word";
};

export { useSelectToSearch };
const SelectToSearch = ({
children,
...config
}: PropsWithChildren<Parameters<typeof useSelectToSearch>[0]>) => {
const [containerRef, panel] = useSelectToSearch({ ...config });
return (
<div ref={containerRef as RefObject<HTMLDivElement>}>
{children}
{panel}
</div>
);
};

export { useSelectToSearch, SelectToSearch };
Loading

0 comments on commit 6b10127

Please sign in to comment.