Skip to content

Commit d5350dd

Browse files
authored
9 rework desktop and mobile UI (#10)
* feat: enhance video filtering and layout features - Added support for timeframe filtering in video queries, allowing users to filter videos by recent, weekly, monthly, quarterly, and yearly views. - Introduced a new `FilterModal` component for mobile devices to manage video filters more effectively. - Updated the `Sidebar` component to include timeframe selection alongside duration filters. - Enhanced the `VideosGrid` component to integrate the new filtering options and display active filters. - Refactored various components to improve layout and responsiveness, ensuring a better user experience across devices. - Added utility functions for managing filter states and URL parameters. * refactor: streamline layout and enhance component structure - Removed unnecessary props from the `Layout` and `Header` components, simplifying their interfaces. - Introduced a new `StreamInfoComponent` for better separation of concerns in displaying stream information. - Updated the `Sidebar` to include mobile-friendly components and improved social links section. - Enhanced the `VideosGrid` layout for better responsiveness and visual consistency. - Refactored CSS styles for improved clarity and organization, including custom scrollbar styling. - Added new utility classes for better control over layout and spacing. * fix: drawer on safari * fix: drawer on mobile * fix: update drawer height for mobile responsiveness * fix: update CSS styles and improve filter modal layout * feat: add support for 3xl breakpoint and enhance layout for large screens * feat: enhance filter modal and sidebar with new utility classes - Updated FilterModal to reflect active sorting state with visual indicators. - Simplified the rendering of active filter counts in both FilterModal and Sidebar, replacing larger indicators with smaller, consistent visual cues. * feat: enhance CSS styles and update header and sidebar components * fix: update GitHub link in header and sidebar components * feat: update Tailwind CSS breakpoints and enhance layout responsiveness - Adjusted the '2xl' and '3xl' breakpoints in tailwind.config.js for improved layout on larger screens. - Enhanced various UI components for better spacing and alignment, including the header, sidebar, and videos grid. * chore: remove unused API route files for Turso Prisma queries * feat: enhance video grid item display - Improved date formatting in VideoGridItem to show "today" and "yesterday" labels. - Adjusted layout in VideosGrid for better responsiveness and consistency. * fix: adjust thumbnail dimensions in VideoGridItem for consistency * Update README.md * Update README.md * feat: implement dark mode support and enhance CSS styles - Changed dark mode configuration in tailwind.config.js to use 'selector'. - Added media query listener in App component to toggle dark mode based on user preference. - Removed unused ContentHeader component and cleaned up sidebar styles for consistency.
1 parent 7d37c5d commit d5350dd

31 files changed

Lines changed: 3539 additions & 741 deletions

README.md

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Hasanhub
22

3-
![CleanShot 2024-11-06 at 00 14 23](https://github.com/user-attachments/assets/7df9a391-b909-4606-9a6d-03bc49d6bd9e)
3+
![Screenvideo of Hasanhub on desktop](https://github.com/user-attachments/assets/a539fd5d-7cf6-4ffc-a8f0-f6797aeebe74)
44

55
## Important Note
66

@@ -49,13 +49,3 @@ Syncing needs to be setup via Cron jobs (or similiar) by hitting the API routes
4949

5050
For sure! If you do pls [let me know](https://twitter.com/chrcit) so I can check it out.
5151
I'd also love a backlink to [my website](https://chrcit.com/projects/hasanhub-com?utm_source=github-hasanhub) somewhere on the site if you are awesome!
52-
53-
## Random stuff
54-
55-
## Refresh Twitch access token
56-
57-
```bash
58-
curl -X POST 'https://id.twitch.tv/oauth2/token' \
59-
-H 'Content-Type: application/x-www-form-urlencoded' \
60-
-d 'client_id=ttsgjnui5mqji4aqjvzahh3mrlyzy3&client_secret=[CLIENT_SECRET]&grant_type=client_credentials'
61-
```

app/hooks/use-action-url.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import useUrlState from "~/hooks/use-url-state";
2-
import type { DurationType } from "~/utils/validators";
2+
import type { DurationType, TimeframeType } from "~/utils/validators";
33
import type { OrderByType, OrderDirectionType } from "../utils/validators";
44

55
const useActionUrl = () => {
@@ -9,6 +9,7 @@ const useActionUrl = () => {
99
action: {
1010
tagSlugs?: string[];
1111
durations?: DurationType[];
12+
timeframe?: TimeframeType;
1213
ordering?: { by?: OrderByType; order?: OrderDirectionType };
1314
lastVideoId?: number;
1415
},
@@ -34,6 +35,10 @@ const useActionUrl = () => {
3435
searchParams.append("durations", duration);
3536
});
3637

38+
if (merged.timeframe) {
39+
searchParams.append("timeframe", merged.timeframe);
40+
}
41+
3742
if (merged.ordering.order && merged.ordering.order !== "desc") {
3843
searchParams.append("order", merged.ordering.order);
3944
}

app/hooks/use-mobile.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import * as React from "react"
2+
3+
const MOBILE_BREAKPOINT = 768
4+
5+
export function useIsMobile() {
6+
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
7+
8+
React.useEffect(() => {
9+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10+
const onChange = () => {
11+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12+
}
13+
mql.addEventListener("change", onChange)
14+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15+
return () => mql.removeEventListener("change", onChange)
16+
}, [])
17+
18+
return !!isMobile
19+
}

app/hooks/use-url-state.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,15 @@ import { useEffect, useState } from "react";
33
import type {
44
LastVideoIdType,
55
DurationListType,
6+
TimeframeType,
67
OrderByType,
78
OrderDirectionType,
89
} from "../utils/validators";
9-
import { UrlParamsSchema, DurationListValidator } from "../utils/validators";
10+
import {
11+
UrlParamsSchema,
12+
DurationListValidator,
13+
TimeframeValidator,
14+
} from "../utils/validators";
1015

1116
const getTagSlugsFromPathname = (location?: string | null) => {
1217
if (location === null || location === undefined) {
@@ -20,6 +25,7 @@ type UrlStateType = {
2025
tagSlugs: string[];
2126
lastVideoId?: LastVideoIdType;
2227
durations?: DurationListType;
28+
timeframe?: TimeframeType;
2329
ordering: {
2430
by: OrderByType;
2531
order: OrderDirectionType;
@@ -34,6 +40,12 @@ const useUrlState = () => {
3440
tagSlugs: getTagSlugsFromPathname(location?.pathname),
3541
durations:
3642
DurationListValidator.parse(searchParams.getAll("durations")) ?? null,
43+
timeframe: (() => {
44+
const timeframeParam = searchParams.get("timeframe");
45+
return timeframeParam
46+
? TimeframeValidator.parse(timeframeParam)
47+
: undefined;
48+
})(),
3749
ordering: {
3850
order: "desc",
3951
by: "publishedAt",
@@ -53,21 +65,25 @@ const useUrlState = () => {
5365
transition?.location?.pathname
5466
);
5567

56-
const { order, durations, by, lastVideoId } = UrlParamsSchema.parse({
57-
order: searchParams.get("order") ?? undefined,
58-
durations: searchParams.getAll("durations"),
59-
by: searchParams.get("by") ?? undefined,
60-
lastVideoId: lastVideoIdParam ? parseInt(lastVideoIdParam) : undefined,
61-
});
68+
const { order, durations, timeframe, by, lastVideoId } =
69+
UrlParamsSchema.parse({
70+
order: searchParams.get("order") ?? undefined,
71+
durations: searchParams.getAll("durations"),
72+
timeframe: searchParams.get("timeframe"),
73+
by: searchParams.get("by") ?? undefined,
74+
lastVideoId: lastVideoIdParam ? parseInt(lastVideoIdParam) : undefined,
75+
});
6276

6377
const {
6478
order: nextOrder,
6579
durations: nextDurations,
80+
timeframe: nextTimeframe,
6681
by: nextBy,
6782
lastVideoId: nextLastVideoId,
6883
} = UrlParamsSchema.parse({
6984
order: nextSearchParams.get("order") ?? undefined,
7085
durations: nextSearchParams.getAll("durations"),
86+
timeframe: nextSearchParams.get("timeframe"),
7187
by: nextSearchParams.get("by") ?? undefined,
7288
lastVideoId: nextLastVideoIdParam
7389
? parseInt(nextLastVideoIdParam)
@@ -76,6 +92,7 @@ const useUrlState = () => {
7692

7793
setUrlState({
7894
durations: nextDurations?.length !== 0 ? nextDurations : durations,
95+
timeframe: nextTimeframe ?? timeframe,
7996
ordering: {
8097
order: nextOrder ?? order ?? "desc",
8198
by: nextBy ?? by ?? "publishedAt",

app/lib/get-videos.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { z } from "zod";
22
import { publishStatus, videoSyncStatus } from "~/utils/dbEnums";
33
import { prisma } from "~/utils/prisma.server";
4-
import type { DurationListType, LastVideoIdType } from "~/utils/validators";
4+
import type {
5+
DurationListType,
6+
TimeframeType,
7+
LastVideoIdType,
8+
} from "~/utils/validators";
59
import {
610
DurationListValidator,
11+
TimeframeValidator,
712
LastVideoIdValidator,
813
OrderByValdiator,
914
OrderDirectionValidator,
@@ -25,11 +30,12 @@ const GetVideosValidator = z.object({
2530
by: OrderByValdiator,
2631
order: OrderDirectionValidator,
2732
durations: z.optional(DurationListValidator),
33+
timeframe: z.optional(TimeframeValidator),
2834
lastVideoId: LastVideoIdValidator,
2935
});
3036

3137
const getVideos = async (params: GetVideosArgs) => {
32-
const { order, durations, by, lastVideoId, tagSlugs, take } =
38+
const { order, durations, timeframe, by, lastVideoId, tagSlugs, take } =
3339
GetVideosValidator.parse(params);
3440

3541
let conditions: {
@@ -76,6 +82,16 @@ const getVideos = async (params: GetVideosArgs) => {
7682
}
7783
}
7884

85+
if (timeframe) {
86+
const earliestDate = getDateRangeForTimeframe(timeframe);
87+
if (earliestDate) {
88+
conditions["publishedAt"] = {
89+
...conditions["publishedAt"],
90+
gte: earliestDate,
91+
};
92+
}
93+
}
94+
7995
return await prisma.$transaction([
8096
prisma.video.findMany({
8197
select: {
@@ -135,4 +151,23 @@ const getMinxMaxForTimeFilter = (durations?: DurationListType) => {
135151
});
136152
};
137153

154+
const getDateRangeForTimeframe = (timeframe: TimeframeType) => {
155+
const now = new Date();
156+
157+
switch (timeframe) {
158+
case "recent": // Last 24h
159+
return new Date(now.getTime() - 24 * 60 * 60 * 1000);
160+
case "week": // Last week
161+
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
162+
case "month": // Last month
163+
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
164+
case "quarter": // Last quarter (3 months)
165+
return new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
166+
case "year": // Last year
167+
return new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
168+
default:
169+
return null;
170+
}
171+
};
172+
138173
export default getVideos;

app/lib/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { clsx, type ClassValue } from "clsx"
2+
import { twMerge } from "tailwind-merge"
3+
4+
export function cn(...inputs: ClassValue[]) {
5+
return twMerge(clsx(inputs))
6+
}

app/root.tsx

Lines changed: 27 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import type { LoaderFunction, MetaFunction } from "@remix-run/node";
2-
import { json } from "@remix-run/node";
1+
import type { MetaFunction } from "@remix-run/node";
32
import {
43
isRouteErrorResponse,
54
Links,
@@ -8,12 +7,11 @@ import {
87
Outlet,
98
Scripts,
109
ScrollRestoration,
11-
useLoaderData,
1210
useRouteError,
1311
} from "@remix-run/react";
12+
import { useEffect } from "react";
1413
import Layout from "./ui/layout";
1514
import styles from "./styles/app.css";
16-
import { getStreamInfo } from "./lib/get-stream-info.server";
1715

1816
export const meta: MetaFunction = () => ({
1917
charset: "utf-8",
@@ -74,38 +72,33 @@ export function links() {
7472
];
7573
}
7674

77-
export async function loader() {
78-
const [streamInfo, schedule] = await getStreamInfo();
75+
function App() {
76+
useEffect(() => {
77+
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
7978

80-
return json(
81-
{
82-
streamInfo: streamInfo.data?.length
83-
? {
84-
user_login: streamInfo.data[0].user_login,
85-
user_name: streamInfo.data[0].user_name,
86-
title: streamInfo.data[0].title,
87-
}
88-
: null,
89-
schedule: schedule.data?.segments.length
90-
? {
91-
broadcaster_login: schedule.data.broadcaster_login,
92-
broadcaster_name: schedule.data.broadcaster_name,
93-
start_time: schedule.data.segments[0].start_time,
94-
title: schedule.data.segments[0].title,
95-
}
96-
: null,
97-
},
98-
{
99-
status: 200,
100-
headers: {
101-
"Cache-Control": "max-age=60, s-maxage=60, stale-while-revalidate=360",
102-
},
79+
const handleChange = (e: MediaQueryListEvent | MediaQueryList) => {
80+
console.log("handleChange", e.matches);
81+
if (e.matches) {
82+
document.documentElement.classList.add("dark");
83+
} else {
84+
document.documentElement.classList.remove("dark");
85+
}
86+
};
87+
88+
// Set initial state
89+
handleChange(mediaQuery);
90+
91+
// Listen for changes
92+
if (mediaQuery.addEventListener) {
93+
mediaQuery.addEventListener("change", handleChange);
10394
}
104-
);
105-
}
10695

107-
function App() {
108-
const { streamInfo, schedule } = useLoaderData<typeof loader>();
96+
return () => {
97+
if (mediaQuery.removeEventListener) {
98+
mediaQuery.removeEventListener("change", handleChange);
99+
}
100+
};
101+
}, []);
109102

110103
return (
111104
<html lang="en">
@@ -119,7 +112,7 @@ function App() {
119112
<meta name="og:image" content="https://hasanhub.com/og.png" />
120113
</head>
121114
<body>
122-
<Layout streamInfo={streamInfo} streamSchedule={schedule}>
115+
<Layout>
123116
<Outlet />
124117
</Layout>
125118
<ScrollRestoration />

app/routes/[robots.txt].tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { cacheHeader } from "pretty-cache-header";
2+
13
export const loader = async () => {
24
const block = `Sitemap: https://hasanhub.com/sitemap.xml
35
Allow: /$
@@ -19,7 +21,10 @@ ${block}
1921
return new Response(robotText, {
2022
status: 200,
2123
headers: {
22-
"Cache-Control": "max-age=0, s-maxage=86400",
24+
"Cache-Control": cacheHeader({
25+
maxAge: "0s",
26+
sMaxage: "1day",
27+
}),
2328
"Content-Type": "text/plain",
2429
},
2530
});

app/routes/[sitemap.xml].tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { cacheHeader } from "pretty-cache-header";
12
import { prisma } from "~/utils/prisma.server";
23

34
export const loader = async () => {
@@ -29,7 +30,10 @@ export const loader = async () => {
2930
return new Response(sitemap, {
3031
status: 200,
3132
headers: {
32-
"Cache-Control": "max-age=0, s-maxage=86400",
33+
"Cache-Control": cacheHeader({
34+
maxAge: "0s",
35+
sMaxage: "1day",
36+
}),
3337
"Content-Type": "application/xml",
3438
"xml-version": "1.0",
3539
encoding: "UTF-8",

0 commit comments

Comments
 (0)