Signed-off-by: Sean Norwood <norwood.sean@gmail.com>
This commit is contained in:
Sean Norwood 2023-08-06 11:10:55 -05:00
parent 7389ed0b2b
commit 470c7a136f
No known key found for this signature in database
GPG Key ID: 2424DE581DEDB5F2
27 changed files with 10679 additions and 9602 deletions

9
.gitpod.Dockerfile vendored
View File

@ -1,9 +0,0 @@
FROM gitpod/workspace-full
# Install Fly
RUN curl -L https://fly.io/install.sh | sh
ENV FLYCTL_INSTALL="/home/gitpod/.fly"
ENV PATH="$FLYCTL_INSTALL/bin:$PATH"
# Install GitHub CLI
RUN brew install gh

View File

@ -1,48 +0,0 @@
# https://www.gitpod.io/docs/config-gitpod-file
image:
file: .gitpod.Dockerfile
ports:
- port: 3000
onOpen: notify
tasks:
- name: Restore .env file
command: |
if [ -f .env ]; then
# If this workspace already has a .env, don't override it
# Local changes survive a workspace being opened and closed
# but they will not persist between separate workspaces for the same repo
echo "Found .env in workspace"
else
# There is no .env
if [ ! -n "${ENV}" ]; then
# There is no $ENV from a previous workspace
# Default to the example .env
echo "Setting example .env"
cp .env.example .env
else
# After making changes to .env, run this line to persist it to $ENV
# eval $(gp env -e ENV="$(base64 .env | tr -d '\n')")
#
# Environment variables set this way are shared between all your workspaces for this repo
# The lines below will read $ENV and print a .env file
echo "Restoring .env from Gitpod"
echo "${ENV}" | base64 -d | tee .env > /dev/null
fi
fi
- init: npm install
command: npm run setup && npm run dev
vscode:
extensions:
- ms-azuretools.vscode-docker
- esbenp.prettier-vscode
- dbaeumer.vscode-eslint
- bradlc.vscode-tailwindcss

View File

@ -5,10 +5,10 @@ type Props = {
children: React.ReactNode; children: React.ReactNode;
}; };
export const Layout: React.FC<Props> = ({ children }) => { export const Layout = ({ children }: Props) => {
return ( return (
<> <>
<div style={{ minHeight: "100vh" }} className="main-content"> <div className="main-content">
<Nav /> <Nav />
{children} {children}
</div> </div>

View File

@ -1,12 +1,11 @@
import { useState } from "react"; import { useState } from "react";
import IconPlay from "~/images/icon-play.svg"; import IconPlay from "~/images/icon-play.svg";
import type { Videos } from "~/routes/index";
import type { Datum } from "~/types/Videos";
import VideoPlayer from "../VideoPlayer/VideoPlayer"; import VideoPlayer from "../VideoPlayer/VideoPlayer";
import type { Datum } from "~/types/Videos";
type ViewStyle = "grid grid-3" | "list"; type ViewStyle = "grid grid-3" | "list";
const VideoList = ({ videos }: { videos: Videos }) => { const VideoList = ({ videos }: { videos: Datum[] }) => {
const [selectedVideoUrl, setSelectedVideoUrl] = useState<Datum>(); const [selectedVideoUrl, setSelectedVideoUrl] = useState<Datum>();
const [viewStyle, setViewStyle] = useState<ViewStyle>("grid grid-3"); const [viewStyle, setViewStyle] = useState<ViewStyle>("grid grid-3");
@ -22,7 +21,7 @@ const VideoList = ({ videos }: { videos: Videos }) => {
setViewStyle(selectedViewStyle); setViewStyle(selectedViewStyle);
}; };
if (videos.length < 1) { if (!videos) {
return ( return (
<div className="boxed-content"> <div className="boxed-content">
<h2>No videos found...</h2> <h2>No videos found...</h2>
@ -110,7 +109,7 @@ const VideoList = ({ videos }: { videos: Videos }) => {
<div style={{ cursor: "pointer" }} onClick={() => handleSelectedVideo(video)}> <div style={{ cursor: "pointer" }} onClick={() => handleSelectedVideo(video)}>
<div className={`video-thumb-wrap ${viewStyle}`}> <div className={`video-thumb-wrap ${viewStyle}`}>
<div className="video-thumb"> <div className="video-thumb">
<img src={video.resolved_thumb_url} alt="video-thumb" /> <img src={`http://localhost:8000${video.vid_thumb_url}`} alt="video-thumb" />
{/* {% if video.source.player.progress %} */} {/* {% if video.source.player.progress %} */}
<div <div
className="video-progress-bar" className="video-progress-bar"

5
app/cookies.ts Normal file
View File

@ -0,0 +1,5 @@
import { createCookie } from "@remix-run/node";
export const csrfCookie = createCookie("csrftoken", {
maxAge: 604_800,
});

View File

@ -1,3 +1,2 @@
export const API_KEY = export const API_KEY = process.env.API_KEY;
process.env.API_KEY || `1954c006f731df60bf4c9f027d6b7076d699d319`; export const API_URL = process.env.API_URL;
export const API_URL = process.env.API_URL || `https://tube.stiforr.tech`;

View File

@ -18,13 +18,10 @@ export const getChannels = async (token: string): Promise<Channels> => {
if (!response.ok) { if (!response.ok) {
throw new Error("Error getting channel information"); throw new Error("Error getting channel information");
} }
return response.json(); return await response.json();
}; };
export const getChannel = async ( export const getChannel = async (token: string, id: string | undefined): Promise<Channel> => {
token: string,
id: string | undefined
): Promise<Channel> => {
if (!token) { if (!token) {
throw new Error(`Unable to fetch channels, no token provided`); throw new Error(`Unable to fetch channels, no token provided`);
} }

View File

@ -1,10 +1,13 @@
import type { Datum, Videos } from "~/types/Videos"; import type { Datum, Videos } from "~/types/Videos";
import { API_URL } from "./constants.server"; import { API_URL } from "./constants.server";
import { getSession } from "~/session.server";
export const getVideos = async (token: string): Promise<Videos> => { export const getVideos = async (request: Request) => {
if (!token) { // if (!token) {
throw new Error("Missing API token in request to get videos"); // throw new Error("Missing API token in request to get videos");
} // }
const session = await getSession(request);
const token = session.get("token");
const response = await fetch(`${API_URL}/api/video/`, { const response = await fetch(`${API_URL}/api/video/`, {
headers: { headers: {
Accept: "application/json", Accept: "application/json",
@ -15,10 +18,15 @@ export const getVideos = async (token: string): Promise<Videos> => {
}); });
if (!response.ok) { if (!response.ok) {
// if (response.status === 403) {
// return redirect("/login");
// }
throw new Error(`Failed to fetch videos: ${response.statusText}`); throw new Error(`Failed to fetch videos: ${response.statusText}`);
} }
return response.json(); const data: Videos = await response.json();
return data;
}; };
export const getVideo = async (token: string, videoId: string): Promise<Datum> => { export const getVideo = async (token: string, videoId: string): Promise<Datum> => {

View File

@ -10,11 +10,10 @@ import {
} from "@remix-run/react"; } from "@remix-run/react";
import { Layout } from "./components/Layout"; import { Layout } from "./components/Layout";
import styles from "./styles/dark.css"; import styles from "./styles/dark.css";
import global from "./styles/globals.css"; import global from "./styles/style.css";
export const links: LinksFunction = () => { export const links: LinksFunction = () => {
return [ return [
// { rel: "stylesheet", href: tailwindStylesheetUrl },
{ rel: "stylesheet", href: styles }, { rel: "stylesheet", href: styles },
{ rel: "stylesheet", href: global }, { rel: "stylesheet", href: global },
]; ];
@ -35,14 +34,14 @@ export async function loader({ request }: LoaderArgs) {
} }
export default function App() { export default function App() {
const data = useLoaderData(); const data = useLoaderData<typeof loader>();
return ( return (
<html lang="en" className="h-full"> <html lang="en">
<head> <head>
<Meta /> <Meta />
<Links /> <Links />
</head> </head>
<body className="h-full"> <body>
<Layout> <Layout>
<Outlet /> <Outlet />
</Layout> </Layout>

33
app/routes/_index.tsx Normal file
View File

@ -0,0 +1,33 @@
import { useLoaderData } from "@remix-run/react";
import type { ErrorBoundaryComponent, LoaderArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import VideoList from "~/components/VideoList/VideoList";
import { getVideos } from "~/lib/getVideos";
export const loader = async ({ context, request }: LoaderArgs) => {
const data = await getVideos(request);
return json(data);
};
export default function Index() {
const { data: videos } = useLoaderData<typeof loader>();
// console.log(videos);
return (
<main>
<VideoList videos={videos} />
</main>
);
}
export const ErrorBoundary: ErrorBoundaryComponent = ({ error }: { error: Error }) => {
console.warn(error);
return (
<div className="boxed-content">
<div className="title-bar">
<h1>Error: </h1>
<p>{error.message}</p>
</div>
</div>
);
};

View File

@ -5,7 +5,7 @@ import { API_URL } from "~/lib/constants";
import { API_KEY } from "~/lib/constants.server"; import { API_KEY } from "~/lib/constants.server";
import { getChannels } from "~/lib/getChannels"; import { getChannels } from "~/lib/getChannels";
import type { Channels } from "~/types/channels"; import type { Channels } from "~/types/channels";
import { formatNumbers } from "../../lib/utils"; import { formatNumbers } from "../lib/utils";
export const loader: LoaderFunction = async () => { export const loader: LoaderFunction = async () => {
const channels = await getChannels(API_KEY); const channels = await getChannels(API_KEY);
@ -93,10 +93,7 @@ const ChannelsPage = () => {
) : ( ) : (
channels.data?.map((channel) => { channels.data?.map((channel) => {
return ( return (
<div <div key={channel.channel_id} className={`channel-item ${viewStyle}`}>
key={channel.channel_id}
className={`channel-item ${viewStyle}`}
>
<div className={`channel-banner ${viewStyle}`}> <div className={`channel-banner ${viewStyle}`}>
<Link to={channel.channel_id}> <Link to={channel.channel_id}>
<img <img
@ -109,22 +106,15 @@ const ChannelsPage = () => {
<div className="info-box-item"> <div className="info-box-item">
<div className="round-img"> <div className="round-img">
<Link to={channel.channel_id}> <Link to={channel.channel_id}>
<img <img src={`${API_URL}${channel.channel_thumb_url}`} alt="channel-thumb" />
src={`${API_URL}${channel.channel_thumb_url}`}
alt="channel-thumb"
/>
</Link> </Link>
</div> </div>
<div> <div>
<h3> <h3>
<Link to={channel.channel_id}> <Link to={channel.channel_id}>{channel?.channel_name}</Link>
{channel?.channel_name}
</Link>
</h3> </h3>
{/* {% if channel.source.channel_subs >= 1000000 %} */} {/* {% if channel.source.channel_subs >= 1000000 %} */}
<p> <p>Subscribers: {formatNumbers(channel?.channel_subs)}</p>
Subscribers: {formatNumbers(channel?.channel_subs)}
</p>
{/* {% else %} */} {/* {% else %} */}
</div> </div>
</div> </div>
@ -136,20 +126,12 @@ const ChannelsPage = () => {
className="unsubscribe" className="unsubscribe"
type="button" type="button"
id="{{ channel.source.channel_id }}" id="{{ channel.source.channel_id }}"
onClick={() => onClick={() => console.log("unsubscribe(this.id) -> toggleSubscribe()")}
console.log(
"unsubscribe(this.id) -> toggleSubscribe()"
)
}
title={`${ title={`${
channel?.channel_subscribed channel?.channel_subscribed ? "Unsubscribe from" : "Subscribe to"
? "Unsubscribe from"
: "Subscribe to"
} ${channel?.channel_name}`} } ${channel?.channel_name}`}
> >
{channel?.channel_subscribed {channel?.channel_subscribed ? "Unsubscribe" : "Subscribe"}
? "Unsubscribe"
: "Subscribe"}
</button> </button>
{/* {% else %} */} {/* {% else %} */}
{/* <button {/* <button

View File

@ -1,43 +0,0 @@
import { useLoaderData } from "@remix-run/react";
import type { ErrorBoundaryComponent, LoaderFunction } from "@remix-run/server-runtime";
import VideoList from "~/components/VideoList/VideoList";
import { API_KEY } from "~/lib/constants.server";
import { getVideos } from "~/lib/getVideos";
import type { Datum } from "~/types/Videos";
export const loader: LoaderFunction = async ({ context, request }) => {
const { data } = await getVideos(API_KEY);
const withVideoThumbs = data.map((d) => ({
...d,
resolved_thumb_url: `${process.env.PUBLIC_API_URL}${d.vid_thumb_url}`,
}));
return withVideoThumbs;
};
export type Videos = Datum[] & {
resolved_thumb_url: string;
};
export default function Index() {
const videos = useLoaderData<Videos>();
return (
<main>
<VideoList videos={videos} />
</main>
);
}
export const ErrorBoundary: ErrorBoundaryComponent = ({ error }: { error: Error }) => {
console.warn(error);
return (
<div className="boxed-content">
<div className="title-bar">
<h1>Error: </h1>
<p>{error.message}</p>
</div>
</div>
);
};

106
app/routes/login.tsx Normal file
View File

@ -0,0 +1,106 @@
import { Form, useLoaderData, useSearchParams } from "@remix-run/react";
import type { ActionArgs, ErrorBoundaryComponent, LoaderArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { csrfCookie } from "~/cookies";
import { createCsrfToken, createUserSession, login } from "~/session.server";
import { safeRedirect } from "~/utils";
export const loader = async ({ request }: LoaderArgs) => {
const csrf = await createCsrfToken(request);
return json(csrf, {
headers: {
"Set-Cookie": await csrfCookie.serialize(csrf),
},
});
};
export const action = async ({ request }: ActionArgs) => {
const formData = await request.formData();
const username = formData.get("username");
const password = formData.get("password");
const redirectTo = safeRedirect(formData.get("next"), "/");
const remember = formData.get("remember");
const user = await login({ username, password, request });
console.log(user);
return createUserSession({
redirectTo,
request,
userId: user.user_id,
token: user.token,
remember: remember === "on" ? true : false,
});
};
const LoginPage = () => {
const [searchParams] = useSearchParams();
const redirectTo = searchParams.get("redirectTo") || "/";
const csrf = useLoaderData<typeof loader>();
console.log(csrf);
return (
<>
<div className="boxed-content login-page">
<img src="img/logo-tube-archivist-dark.png" alt="tube-archivist-logo" />
<h1>Tube Archivist</h1>
<h2>Your Self Hosted YouTube Media Server</h2>
<p className="danger-zone">Failed to login.</p>
<Form method="POST" name="login">
<input type="text" name="username" />
<br />
<input type="password" name="password" />
<br />
<p>
Remember me: <input type="checkbox" name="remember" />
</p>
<input type="hidden" name="next" value={redirectTo} />
<input type="hidden" name="csrf" value={csrf} />
<button type="submit">Login</button>
</Form>
<p className="login-links">
<span>
<a
href="https://github.com/tubearchivist/tubearchivist"
target="_blank"
rel="noreferrer"
>
Github
</a>
</span>{" "}
<span>
<a
href="https://github.com/tubearchivist/tubearchivist#donate"
target="_blank"
rel="noreferrer"
>
Donate
</a>
</span>
</p>
</div>
<div className="footer-colors">
<div className="col-1"></div>
<div className="col-2"></div>
<div className="col-3"></div>
</div>
</>
);
};
export default LoginPage;
export const ErrorBoundary: ErrorBoundaryComponent = ({ error }: { error: Error }) => {
console.warn(error);
return (
<div className="boxed-content">
<div className="title-bar">
<h1>Error: </h1>
<p>{error.message}</p>
</div>
</div>
);
};

6
app/routes/logout.tsx Normal file
View File

@ -0,0 +1,6 @@
import type { LoaderArgs } from "@remix-run/server-runtime";
import { logout } from "~/session.server";
export const loader = async ({ request }: LoaderArgs) => {
return logout(request);
};

View File

@ -1,15 +1,14 @@
import { useState } from "react";
import { Link, useLoaderData } from "@remix-run/react"; import { Link, useLoaderData } from "@remix-run/react";
import { useState } from "react";
import IconAdd from "~/images/icon-add.svg"; import IconAdd from "~/images/icon-add.svg";
import IconGridView from "~/images/icon-gridview.svg"; import IconGridView from "~/images/icon-gridview.svg";
import IconListView from "~/images/icon-listview.svg"; import IconListView from "~/images/icon-listview.svg";
import type { LoaderFunction } from "@remix-run/server-runtime";
import { getPlaylists } from "~/lib/getPlaylists";
import { API_KEY } from "~/lib/constants.server";
import type { Playlists } from "~/types/playlists";
import { API_URL } from "~/lib/constants"; import { API_URL } from "~/lib/constants";
import { API_KEY } from "~/lib/constants.server";
import { getPlaylists } from "~/lib/getPlaylists";
import type { Playlists } from "~/types/playlists";
export const loader: LoaderFunction = async () => { export const loader = async () => {
const playlists = await getPlaylists(API_KEY); const playlists = await getPlaylists(API_KEY);
return playlists; return playlists;
@ -97,10 +96,7 @@ const Playlist = () => {
) : ( ) : (
playlists.data.map((playlist) => { playlists.data.map((playlist) => {
return ( return (
<div <div key={playlist.playlist_id} className={`playlist-item ${viewStyle}`}>
key={playlist.playlist_id}
className={`playlist-item ${viewStyle}`}
>
<div className="playlist-thumbnail"> <div className="playlist-thumbnail">
<Link to={playlist.playlist_id}> <Link to={playlist.playlist_id}>
<img <img

View File

@ -1,9 +1,8 @@
import { createCookieSessionStorage, redirect } from "@remix-run/node"; import { createCookieSessionStorage, json, redirect } from "@remix-run/node";
import { randomBytes } from "crypto";
import invariant from "tiny-invariant"; import invariant from "tiny-invariant";
import { API_URL } from "./lib/constants.server";
import type { User } from "~/models/user.server"; import { csrfCookie } from "./cookies";
import { getUserById } from "~/models/user.server";
invariant(process.env.SESSION_SECRET, "SESSION_SECRET must be set"); invariant(process.env.SESSION_SECRET, "SESSION_SECRET must be set");
export const sessionStorage = createCookieSessionStorage({ export const sessionStorage = createCookieSessionStorage({
@ -17,6 +16,17 @@ export const sessionStorage = createCookieSessionStorage({
}, },
}); });
export const csrfStorage = createCookieSessionStorage({
cookie: {
name: "csrftoken",
httpOnly: false,
path: "/",
sameSite: "lax",
secrets: [process.env.SESSION_SECRET],
secure: process.env.NODE_ENV === "production",
},
});
const USER_SESSION_KEY = "userId"; const USER_SESSION_KEY = "userId";
export async function getSession(request: Request) { export async function getSession(request: Request) {
@ -24,61 +34,121 @@ export async function getSession(request: Request) {
return sessionStorage.getSession(cookie); return sessionStorage.getSession(cookie);
} }
export async function getUserId( export const getCsrfSession = async (request: Request) => {
request: Request const cookie = request.headers.get("Cookie");
): Promise<User["id"] | undefined> { return csrfStorage.getSession(cookie);
const session = await getSession(request); };
const userId = session.get(USER_SESSION_KEY);
return userId; export const createCsrfToken = async (request: Request) => {
const cookieHeaders = request.headers.get("Cookie");
const cookie = await csrfCookie.parse(cookieHeaders);
if (cookie) {
return cookie;
} }
export async function getUser(request: Request) { const token = randomBytes(100).toString("base64");
const userId = await getUserId(request); return token;
if (userId === undefined) return null; };
const user = await getUserById(userId); type LoginResponse = {
if (user) return user; token: string;
user_id: string;
};
throw await logout(request); export const login = async ({
username,
password,
request,
}: {
username: string;
password: string;
request: Request;
}) => {
if (!username || !password) {
return redirect("/login");
} }
const csrfmiddlewaretoken = csrfCookie.parse(request.headers.get("Cookie"));
const creds = JSON.stringify({ username, password, csrfmiddlewaretoken });
const response = await fetch(`${API_URL}/api/login/`, {
method: "POST",
body: creds,
headers: {
"Content-Type": "application/json",
"Accept-Language": "en-US",
},
});
export async function requireUserId( if (!response.ok) {
request: Request, throw new Error(`Failed to login: ${response.statusText}`);
redirectTo: string = new URL(request.url).pathname
) {
const userId = await getUserId(request);
if (!userId) {
const searchParams = new URLSearchParams([["redirectTo", redirectTo]]);
throw redirect(`/login?${searchParams}`);
}
return userId;
} }
const cookie = response.headers.get("Cookie");
const data: LoginResponse = await response.json();
return data;
};
export async function requireUser(request: Request) { // export async function getUserId(
const userId = await requireUserId(request); // request: Request
// ): Promise<User["id"] | undefined> {
// const session = await getSession(request);
// const userId = session.get(USER_SESSION_KEY);
// return userId;
// }
const user = await getUserById(userId); // export async function getUser(request: Request) {
if (user) return user; // const userId = await getUserId(request);
// if (userId === undefined) return null;
throw await logout(request); // const user = await getUserById(userId);
} // if (user) return user;
// throw await logout(request);
// }
// export async function requireUserId(
// request: Request,
// redirectTo: string = new URL(request.url).pathname
// ) {
// const userId = await getUserId(request);
// if (!userId) {
// const searchParams = new URLSearchParams([["redirectTo", redirectTo]]);
// throw redirect(`/login?${searchParams}`);
// }
// return userId;
// }
// export async function requireUser(request: Request) {
// const userId = await requireUserId(request);
// const user = await getUserById(userId);
// if (user) return user;
// throw await logout(request);
// }
export async function createUserSession({ export async function createUserSession({
request, request,
userId, userId,
remember, token,
remember = false,
redirectTo, redirectTo,
}: { }: {
request: Request; request: Request;
userId: string; userId: string;
remember: boolean; token: string;
remember?: boolean;
redirectTo: string; redirectTo: string;
}) { }) {
const session = await getSession(request); // const session = await getSession(request);
session.set(USER_SESSION_KEY, userId); const csrf = await getCsrfSession(request);
// csrf.set(USER_SESSION_KEY, userId);
// csrf.set("token", token);
csrf.set("csrftoken", token);
return redirect(redirectTo, { return redirect(redirectTo, {
headers: { headers: {
"Set-Cookie": await sessionStorage.commitSession(session, { "Set-Cookie": await csrfStorage.commitSession(csrf, {
maxAge: remember maxAge: remember
? 60 * 60 * 24 * 7 // 7 days ? 60 * 60 * 24 * 7 // 7 days
: undefined, : undefined,
@ -88,10 +158,10 @@ export async function createUserSession({
} }
export async function logout(request: Request) { export async function logout(request: Request) {
const session = await getSession(request); const session = await getCsrfSession(request);
return redirect("/", { return redirect("/login", {
headers: { headers: {
"Set-Cookie": await sessionStorage.destroySession(session), "Set-Cookie": await csrfStorage.destroySession(session),
}, },
}); });
} }

0
app/styles/dark.css Normal file → Executable file
View File

0
app/styles/light.css Normal file → Executable file
View File

View File

@ -125,6 +125,10 @@ button:hover {
color: var(--main-bg); color: var(--main-bg);
} }
.button-box {
padding: 5px 0;
}
.unsubscribe { .unsubscribe {
background-color: var(--accent-font-light); background-color: var(--accent-font-light);
} }
@ -349,6 +353,7 @@ button:hover {
.grid-count { .grid-count {
display: flex; display: flex;
justify-content: end; justify-content: end;
align-items: center;
} }
.view-icons img { .view-icons img {
@ -369,6 +374,10 @@ button:hover {
display: none; display: none;
} }
#hidden-form button {
margin-right: 1rem;
}
#text-reveal { #text-reveal {
height: 0; height: 0;
overflow: hidden; overflow: hidden;
@ -391,6 +400,11 @@ button:hover {
display: grid; display: grid;
align-content: space-evenly; align-content: space-evenly;
height: 100vh; height: 100vh;
position: relative; /* needed for modal */
}
#notifications {
position: relative;
} }
.notifications { .notifications {
@ -460,7 +474,7 @@ video:-webkit-full-screen {
overflow: hidden; overflow: hidden;
} }
.video-item:hover .video-thumb span { .video-item:hover .video-tags {
opacity: 1; opacity: 1;
} }
@ -471,7 +485,8 @@ video:-webkit-full-screen {
align-items: center; align-items: center;
} }
.video-progress-bar { .video-progress-bar,
.notification-progress-bar {
position: absolute; position: absolute;
background-color: var(--accent-font-dark); background-color: var(--accent-font-dark);
height: 7px; height: 7px;
@ -484,16 +499,20 @@ video:-webkit-full-screen {
position: relative; position: relative;
} }
.video-thumb span { .video-tags {
position: absolute; position: absolute;
top: 5px; top: 5px;
left: 5px; left: 0;
background-color: var(--accent-font-light);
padding: 5px; padding: 5px;
opacity: 0; opacity: 0;
transition: 300ms ease-in-out; transition: 300ms ease-in-out;
} }
.video-tags span {
background-color: var(--accent-font-light);
padding: 5px;
}
.video-play img { .video-play img {
width: 40px; width: 40px;
filter: var(--img-filter); filter: var(--img-filter);
@ -619,7 +638,8 @@ video:-webkit-full-screen {
margin-top: 1rem; margin-top: 1rem;
} }
.description-box { .description-box,
.comments-section {
margin-top: 1rem; margin-top: 1rem;
padding: 15px; padding: 15px;
background-color: var(--highlight-bg); background-color: var(--highlight-bg);
@ -640,11 +660,16 @@ video:-webkit-full-screen {
.info-box-item { .info-box-item {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
padding: 15px; padding: 15px;
background-color: var(--highlight-bg); background-color: var(--highlight-bg);
} }
.info-box-item p {
width: 100%;
}
.description-text { .description-text {
width: 100%; width: 100%;
} }
@ -745,6 +770,22 @@ video:-webkit-full-screen {
/* video page */ /* video page */
.video-main { .video-main {
margin: 1rem 0; margin: 1rem 0;
position: relative; /* needed for modal */
}
.video-modal {
position: absolute;
z-index: 1;
top: 20%;
width: 100%;
text-align: center;
}
.video-modal-text {
background: rgba(0, 0, 0, 0.5);
color: #eeeeee;
font-size: 1.3em;
display: none;
} }
.video-main video { .video-main video {
@ -762,10 +803,26 @@ video:-webkit-full-screen {
margin-left: 5px; margin-left: 5px;
} }
.thumb-icon {
display: flex;
}
.video-tag-box {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.video-tag {
padding: 5px 10px;
margin: 5px;
border: 1px solid var(--accent-font-light);
}
.thumb-icon img, .thumb-icon img,
.rating-stars img { .rating-stars img {
width: 20px; width: 20px;
margin: 0; margin: 0 5px;
filter: var(--img-filter); filter: var(--img-filter);
} }
@ -803,6 +860,44 @@ video:-webkit-full-screen {
width: 100%; width: 100%;
} }
.comment-box {
padding-bottom: 1rem;
overflow: hidden;
}
.comment-box h3 {
line-break: anywhere;
}
.comments-replies {
display: none;
padding-left: 1rem;
border-left: 1px solid var(--accent-font-light);
margin-top: 1rem;
}
.comment-highlight {
background-color: var(--main-font);
padding: 3px;
color: var(--accent-font-dark);
font-family: Sen-bold, sans-serif;
width: fit-content;
}
.comment-meta {
display: flex;
}
.space-carrot {
margin: 0 5px;
}
.comment-like img {
width: 20px;
margin-left: 5px;
filter: var(--img-filter-error);
}
/* multi search page */ /* multi search page */
.multi-search-box { .multi-search-box {
padding-right: 20px; padding-right: 20px;
@ -812,10 +907,25 @@ video:-webkit-full-screen {
width: 100%; width: 100%;
} }
.multi-search-result { .multi-search-result,
#multi-search-results-placeholder {
padding: 1rem 0; padding: 1rem 0;
} }
#multi-search-results-placeholder span {
font-family: monospace;
color: var(--accent-font-dark);
background-color: var(--highlight-bg);
}
#multi-search-results-placeholder span.value {
color: var(--accent-font-light);
}
#multi-search-results-placeholder ul {
margin-top: 10px;
}
/* channel overview page */ /* channel overview page */
.channel-list.list { .channel-list.list {
display: block; display: block;
@ -923,13 +1033,12 @@ video:-webkit-full-screen {
cursor: pointer; cursor: pointer;
} }
.dl-control-icons { .task-control-icons {
display: flex; display: flex;
justify-content: center; justify-content: center;
padding-bottom: 10px;
} }
.dl-control-icons img { .task-control-icons img {
width: 30px; width: 30px;
cursor: pointer; cursor: pointer;
margin: 5px; margin: 5px;
@ -950,6 +1059,7 @@ video:-webkit-full-screen {
/* status message */ /* status message */
.notification { .notification {
position: relative;
background-color: var(--highlight-bg); background-color: var(--highlight-bg);
text-align: center; text-align: center;
padding: 30px 0 15px 0; padding: 30px 0 15px 0;
@ -1166,6 +1276,10 @@ video:-webkit-full-screen {
margin: 15px; margin: 15px;
text-align: center; text-align: center;
} }
.view-controls.three {
grid-template-columns: unset;
justify-content: center;
}
.sort { .sort {
display: block; display: block;
} }

View File

@ -4,14 +4,12 @@
"sideEffects": false, "sideEffects": false,
"scripts": { "scripts": {
"build": "remix build", "build": "remix build",
"bun:build": "bun run node_modules/@remix-run/dev/dist/cli.js build",
"docker:build": "docker build -t remix .", "docker:build": "docker build -t remix .",
"dev": "remix dev", "dev": "remix dev",
"dev:remix": "cross-env NODE_ENV=development binode --require ./mocks -- @remix-run/dev:remix dev", "dev:remix": "cross-env NODE_ENV=development binode --require ./mocks -- @remix-run/dev:remix dev",
"format": "prettier --write .", "format": "prettier --write .",
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
"start": "remix-serve build", "start": "remix-serve build",
"bun:start": "bun run ./server.ts",
"start:mocks": "binode --require ./mocks -- @remix-run/serve:remix-serve build", "start:mocks": "binode --require ./mocks -- @remix-run/serve:remix-serve build",
"test": "vitest", "test": "vitest",
"test:e2e:dev": "start-server-and-test dev http://localhost:3000 \"npx cypress open\"", "test:e2e:dev": "start-server-and-test dev http://localhost:3000 \"npx cypress open\"",
@ -29,61 +27,64 @@
"/public/build" "/public/build"
], ],
"dependencies": { "dependencies": {
"@remix-run/node": "^1.7.2", "@remix-run/node": "^1.15.0",
"@remix-run/react": "^1.7.2", "@remix-run/react": "^1.15.0",
"@remix-run/serve": "^1.7.2", "@remix-run/serve": "^1.15.0",
"@remix-run/server-runtime": "^1.7.2", "@remix-run/server-runtime": "^1.15.0",
"isbot": "^3.6.1", "isbot": "^3.6.10",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-player": "^2.11.0", "react-player": "^2.12.0",
"tiny-invariant": "^1.2.0" "tiny-invariant": "^1.3.1"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "^7.5.0", "@faker-js/faker": "^7.6.0",
"@remix-run/dev": "^1.7.2", "@remix-run/dev": "^1.15.0",
"@remix-run/eslint-config": "^1.7.2", "@remix-run/eslint-config": "^1.15.0",
"@testing-library/cypress": "^8.0.3", "@testing-library/cypress": "^9.0.0",
"@testing-library/dom": "^8.18.1", "@testing-library/dom": "^9.2.0",
"@testing-library/jest-dom": "^5.16.5", "@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0", "@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3", "@testing-library/user-event": "^14.4.3",
"@types/bcryptjs": "^2.4.2", "@types/bcryptjs": "^2.4.2",
"@types/eslint": "^8.4.6", "@types/eslint": "^8.37.0",
"@types/node": "^18.7.18", "@types/node": "^18.16.3",
"@types/react": "^18.0.20", "@types/react": "^18.2.0",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.2.1",
"@vitejs/plugin-react": "^2.1.0", "@vitejs/plugin-react": "^4.0.0",
"@vitest/coverage-c8": "^0.23.4", "@vitest/coverage-c8": "^0.30.1",
"autoprefixer": "^10.4.11", "autoprefixer": "^10.4.14",
"binode": "^1.0.5", "binode": "^1.0.5",
"bun-types": "^0.2.0", "c8": "^7.13.0",
"c8": "^7.12.0",
"cookie": "^0.5.0", "cookie": "^0.5.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"cypress": "^10.8.0", "cypress": "^12.11.0",
"eslint": "^8.23.1", "eslint": "^8.39.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-cypress": "^2.12.1", "eslint-plugin-cypress": "^2.13.3",
"happy-dom": "^6.0.4", "happy-dom": "^9.10.1",
"msw": "^0.47.3", "msw": "^1.2.1",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"postcss": "^8.4.16", "postcss": "^8.4.23",
"prettier": "2.7.1", "prettier": "2.8.8",
"prettier-plugin-tailwindcss": "^0.1.13", "prettier-plugin-tailwindcss": "^0.2.8",
"start-server-and-test": "^1.14.0", "start-server-and-test": "^2.0.0",
"tailwindcss": "^3.1.8", "tailwindcss": "^3.3.2",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.0", "tsconfig-paths": "^4.2.0",
"typescript": "^4.8.3", "typescript": "^4.8.3",
"vite": "^3.1.3", "vite": "^4.3.3",
"vite-tsconfig-paths": "^3.5.0", "vite-tsconfig-paths": "^4.2.0",
"vitest": "^0.23.4" "vitest": "^0.30.1"
}, },
"engines": { "engines": {
"node": ">=14" "node": ">=18"
}, },
"prisma": { "prisma": {
"seed": "ts-node --require tsconfig-paths/register prisma/seed.ts" "seed": "ts-node --require tsconfig-paths/register prisma/seed.ts"
},
"volta": {
"node": "18.16.0",
"pnpm": "8.3.1"
} }
} }

10202
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -4,4 +4,9 @@
module.exports = { module.exports = {
cacheDirectory: "./node_modules/.cache/remix", cacheDirectory: "./node_modules/.cache/remix",
ignoredRouteFiles: ["**/.*", "**/*.css", "**/*.test.{js,jsx,ts,tsx}"], ignoredRouteFiles: ["**/.*", "**/*.css", "**/*.test.{js,jsx,ts,tsx}"],
future: {
v2_routeConvention: true,
v2_normalizeFormMethod: true,
v2_meta: false,
},
}; };

View File

@ -1,70 +0,0 @@
import * as fs from "fs";
import * as path from "path";
import { createRequestHandler } from "@remix-run/server-runtime";
import * as build from "./build";
const mode = process.argv[2] === "dev" ? "development" : "production";
let requestHandler = createRequestHandler(build, mode);
setInterval(() => {
Bun.gc(true);
}, 9000);
async function handler(request: Request): Promise<Response> {
if (mode === "development") {
let newBuild = await import("./build"); // <- This is the segfault source
requestHandler = createRequestHandler(newBuild, mode);
}
const file = tryServeStaticFile("public", request);
if (file) return file;
return requestHandler(request);
}
const server = Bun.serve({
port: 3000,
fetch: mode === "development" ? liveReload(handler) : handler,
});
console.log(`Server started at ${server.hostname}`);
function tryServeStaticFile(staticDir: string, request: Request): Response | undefined {
const url = new URL(request.url);
if (url.pathname.length < 2) return undefined;
const filePath = path.join(staticDir, url.pathname);
if (fs.existsSync(filePath)) {
const file = Bun.file(filePath);
return new Response(file, {
headers: {
"Content-Type": file.type,
"Cache-Control": "public, max-age=31536000",
},
});
}
return undefined;
}
function liveReload<TFunc extends Function>(callback: TFunc) {
const registry = new Map([...Loader.registry.entries()]);
function reload() {
if (Loader.registry.size !== registry.size) {
for (const key of Loader.registry.keys()) {
if (!registry.has(key)) {
Loader.registry.delete(key);
}
}
}
}
return async (...args: unknown[]) => {
reload();
return callback(...args);
};
}

9275
yarn.lock

File diff suppressed because it is too large Load Diff