initial commit

This commit is contained in:
shafin-r
2025-07-03 01:43:25 +06:00
commit 5dc53b896e
279 changed files with 28956 additions and 0 deletions

24
epaper-client/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

54
epaper-client/README.md Normal file
View File

@ -0,0 +1,54 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```

BIN
epaper-client/bun.lockb Normal file

Binary file not shown.

View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

13
epaper-client/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2538
epaper-client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,43 @@
{
"name": "epaper-client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-slot": "^1.2.2",
"@tailwindcss/vite": "^4.1.4",
"class-variance-authority": "^0.7.1",
"classnames": "^2.5.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.503.0",
"react": "^19.0.0",
"react-day-picker": "8.10.1",
"react-dom": "^19.0.0",
"react-grid-layout": "^1.5.1",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.4"
},
"devDependencies": {
"@eslint/js": "^9.22.0",
"@types/node": "^22.15.3",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@types/react-grid-layout": "^1.3.5",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"tw-animate-css": "^1.2.8",
"typescript": "~5.7.2",
"typescript-eslint": "^8.26.1",
"vite": "^6.3.1"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

25
epaper-client/src/App.tsx Normal file
View File

@ -0,0 +1,25 @@
import Header from "./components/Header";
import DateHeader from "./components/DateHeader";
import Sidebar from "./components/Sidebar";
import NewspaperViewer from "./components/NewspaperViewer";
import { DateProvider } from "./context/DateContext";
import { PageProvider } from "./context/PageContext";
function App() {
return (
<PageProvider>
<DateProvider>
<main className="pt-4">
<Header />
<DateHeader />
<section className="px-96 flex pt-8 gap-7">
<NewspaperViewer />
<Sidebar />
</section>
</main>
</DateProvider>
</PageProvider>
);
}
export default App;

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,21 @@
import { weekdays } from "../../utils/date-info";
import { months } from "../../utils/date-info";
const DateHeader = () => {
const currentDate = new Date();
return (
<div className="bg-slate-500 px-96 py-2 flex justify-between">
<section className="flex gap-10">
<button className="text-white font-bold">Home</button>
</section>
<h1 className="text-white">
{weekdays[currentDate.getDay() - 1]} {currentDate.getDate()}{" "}
{months[currentDate.getMonth()]} {currentDate.getFullYear()}
</h1>
<div></div>
</div>
);
};
export default DateHeader;

View File

@ -0,0 +1,9 @@
const Header = () => {
return (
<div className="flex justify-between items-center px-96">
<img src="/logo.png" alt="" className="w-60" />
</div>
);
};
export default Header;

View File

@ -0,0 +1,280 @@
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import { useDateContext } from "@/context/DateContext";
import { useEffect, useMemo, useState } from "react";
import {
autoPlaceArticles,
createArticlesFromFilenames,
isSameDay,
} from "../../utils/helpers";
import { usePageContext } from "@/context/PageContext";
// Type Definitions
interface ArticleImage {
page: number;
date: string;
articles: string[];
}
interface Origin {
x: number;
y: number;
}
const NewspaperViewer = () => {
const { currentPage, setCurrentPage } = usePageContext();
const [selectedArticle, setSelectedArticle] = useState<string | null>(null);
const [images, setImages] = useState<ArticleImage[]>([]);
const [colCount, setColCount] = useState(8);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { date } = useDateContext();
const [origin, setOrigin] = useState<Origin>({ x: 0, y: 0 });
// Update column count based on page
useEffect(() => {
setColCount(currentPage === 1 ? 6 : 8);
}, [currentPage]);
// Simulated async fetch (replace with actual fetch if needed)
useEffect(() => {
const fetchImages = async () => {
try {
setIsLoading(true);
setError(null);
// Simulate delay
await new Promise((res) => setTimeout(res, 500));
setImages([
{
page: 1,
date: "Mon May 13 2025 22:00:46 GMT+0600 (Bangladesh Standard Time)",
articles: [
"1_r2_c6.jpg",
"1_r3_c3.jpg",
"1_r3_c1.jpg",
"1_r3_c2.jpg",
"1_r4_c2.jpg",
"(1_r4_c1).jpg",
"(1_r4_c2).jpg",
"1_r2_c1.jpg",
"1_r2_c1_g6.jpg",
],
},
{
page: 2,
date: "Mon May 13 2025 22:00:00 GMT+0600 (Bangladesh Standard Time)",
articles: [
"2_r1_c8.jpg",
"2_r2_c2_g1).jpg",
"2_r1_c2_g3.jpg",
"2_r1_c4_g5.jpg",
"(2_r1_c4_g5.jpg",
"2_r1_c1_g8).jpg",
"2_r1_c1_g8.jpg",
"2_r1_c2_g3).jpg",
"2_r2_c2_g3.jpg",
"2_r2_c3_g5.jpg",
"2_r3_c3_g3.jpg",
"(2_r3_c3)_g5.jpg",
"(2_r1_c2)_g1.jpg",
"((2_r1_c2)_g1.jpg",
"2_r2_c2_g1.jpg",
],
},
]);
} catch (err) {
setError("Failed to load newspaper data.");
} finally {
setIsLoading(false);
}
};
fetchImages();
}, []);
const zoomIn = (articleUrl: string, e: React.MouseEvent) => {
const { clientX, clientY } = e;
setOrigin({ x: clientX, y: clientY });
setSelectedArticle(articleUrl);
};
const zoomOut = () => {
setSelectedArticle(null);
};
const dateFilteredContent = useMemo(
() => images.filter((item) => isSameDay(item.date, date)),
[images, date]
);
const hasContentForDate = dateFilteredContent.length > 0;
const dateArticles = useMemo(
() =>
hasContentForDate
? dateFilteredContent.flatMap((pageGroup) =>
createArticlesFromFilenames(pageGroup.articles, pageGroup.page)
)
: [],
[dateFilteredContent, hasContentForDate]
);
const currentPageArticles = dateArticles.filter(
(a) => a.page === currentPage
);
const placedArticles = useMemo(
() => autoPlaceArticles(currentPageArticles, colCount),
[currentPageArticles, colCount]
);
const totalPages = hasContentForDate
? Math.max(...dateFilteredContent.map((item) => item.page))
: 0;
return (
<div className="w-3/4 mx-auto">
{isLoading ? (
<div className="flex justify-center items-center h-64">Loading...</div>
) : error ? (
<div className="text-red-600 text-center h-64 flex items-center justify-center">
{error}
</div>
) : hasContentForDate ? (
<>
{/* Pagination */}
<section className="my-4">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault();
setCurrentPage((prev: number) => Math.max(1, prev - 1));
}}
/>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(n) => (
<PaginationItem key={n}>
<PaginationLink
href="#"
isActive={n === currentPage}
onClick={(e) => {
e.preventDefault();
setCurrentPage(n);
}}
>
{n}
</PaginationLink>
</PaginationItem>
)
)}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
setCurrentPage((prev) => Math.min(totalPages, prev + 1));
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</section>
{/* Articles */}
<section
className="gap-1"
style={{
display: "grid",
gridTemplateColumns: `repeat(${colCount}, 1fr)`,
}}
>
{placedArticles.map((article) => (
<div
key={article.id}
style={{
gridColumn: `${article.gridColumnStart} / span ${article.colSpan}`,
gridRow: `${article.gridRowStart} / span ${article.rowSpan}`,
overflow: "hidden",
}}
className="hover:opacity-60 hover:cursor-pointer transition-opacity"
>
<img
onClick={(e) => zoomIn(`/${article.image}`, e)}
src={`/${article.image}`}
alt={`Article ${article.id}`}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
display: "block",
}}
/>
</div>
))}
</section>
</>
) : (
<div className="flex flex-col items-center justify-center h-64 text-center">
<h2 className="text-2xl font-semibold mb-2">
No newspaper available
</h2>
<p className="text-gray-600">
There is no newspaper edition for{" "}
{date?.toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})}
</p>
</div>
)}
{/* Zoomed View */}
{selectedArticle && (
<div
className="fixed inset-0 bg-black/70 flex justify-center items-center z-50"
onClick={zoomOut}
>
<div
className="relative bg-white rounded-lg overflow-hidden shadow-lg"
style={{
animation: "zoomIn 0.3s ease-out",
transformOrigin: `${origin.x}px ${origin.y}px`,
}}
onClick={(e) => e.stopPropagation()}
>
<img
src={selectedArticle}
alt="Zoomed Article"
className="rounded max-h-[90vh] max-w-[90vw]"
/>
<button
onClick={zoomOut}
className="absolute top-2 right-2 text-black text-2xl"
aria-label="Close"
>
&times;
</button>
</div>
</div>
)}
</div>
);
};
export default NewspaperViewer;

View File

@ -0,0 +1,54 @@
"use client";
import { Calendar } from "@/components/ui/calendar";
import { useDateContext } from "@/context/DateContext";
import { usePageContext } from "@/context/PageContext";
import classNames from "classnames"; // Optional: use if you're using clsx/classnames for cleaner conditional styling
const Sidebar = () => {
const { date, setDate } = useDateContext();
const { currentPage, setCurrentPage } = usePageContext();
const pages = [
`${date?.toLocaleDateString()}`,
...Array.from({ length: 10 }, (_, i) => `Page ${i + 1}`),
];
return (
<div className="w-1/4 flex flex-col gap-6 p-4">
<h1 className="text-2xl font-semibold">Today's Paper</h1>
{/* Page Buttons */}
<div className="flex flex-col w-full gap-1">
{pages.map((page, idx) => (
<button
key={idx}
className={classNames(
"py-2 px-4 text-left text-lg tracking-tight rounded transition-colors duration-200 hover:cursor-pointer",
{
"bg-slate-800 text-white": currentPage === idx,
"bg-slate-200 hover:bg-slate-300 text-black":
currentPage !== idx,
"bg-slate-700 text-white": idx === 0, // disabled style
}
)}
disabled={idx === 0}
onClick={() => setCurrentPage(idx)}
>
{page}
</button>
))}
</div>
{/* Calendar Date Picker */}
<Calendar
mode="single"
selected={date}
onSelect={setDate}
className="border rounded-md p-2 shadow-sm"
/>
</div>
);
};
export default Sidebar;

View File

@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@ -0,0 +1,73 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
function Calendar({
className,
classNames,
showOutsideDays = true,
...props
}: React.ComponentProps<typeof DayPicker>) {
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn("p-3", className)}
classNames={{
months: "flex flex-col sm:flex-row gap-2",
month: "flex flex-col gap-4",
caption: "flex justify-center pt-1 relative items-center w-full",
caption_label: "text-sm font-medium",
nav: "flex items-center gap-1",
nav_button: cn(
buttonVariants({ variant: "outline" }),
"size-7 bg-transparent p-0 opacity-50 hover:opacity-100"
),
nav_button_previous: "absolute left-1 hover:cursor-pointer",
nav_button_next: "absolute right-1 hover:cursor-pointer",
table: "w-full border-collapse space-x-1",
head_row: "flex",
head_cell:
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
row: "flex w-full mt-2",
cell: cn(
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-range-end)]:rounded-r-md",
props.mode === "range"
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
: "[&:has([aria-selected])]:rounded-md"
),
day: cn(
buttonVariants({ variant: "ghost" }),
"size-8 p-0 font-normal aria-selected:opacity-100 hover:cursor-pointer"
),
day_range_start:
"day-range-start aria-selected:bg-primary aria-selected:text-primary-foreground",
day_range_end:
"day-range-end aria-selected:bg-primary aria-selected:text-primary-foreground",
day_selected:
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
day_today: "bg-accent text-accent-foreground",
day_outside:
"day-outside text-muted-foreground aria-selected:text-muted-foreground",
day_disabled: "text-muted-foreground opacity-50",
day_range_middle:
"aria-selected:bg-accent aria-selected:text-accent-foreground",
day_hidden: "invisible",
...classNames,
}}
components={{
IconLeft: ({ className, ...props }) => (
<ChevronLeft className={cn("size-4", className)} {...props} />
),
IconRight: ({ className, ...props }) => (
<ChevronRight className={cn("size-4", className)} {...props} />
),
}}
{...props}
/>
);
}
export { Calendar };

View File

@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}

View File

@ -0,0 +1,31 @@
"use client";
import * as React from "react";
// 1. Create the context
type DateContextType = {
date: Date | undefined;
setDate: (date: Date | undefined) => void;
};
const DateContext = React.createContext<DateContextType | undefined>(undefined);
// 2. Create a custom hook to use the context
export function useDateContext() {
const context = React.useContext(DateContext);
if (context === undefined) {
throw new Error("useDateContext must be used within a DateProvider");
}
return context;
}
// 3. Create the provider component
export function DateProvider({ children }: { children: React.ReactNode }) {
const [date, setDate] = React.useState<Date | undefined>(new Date());
const value = React.useMemo(() => {
return { date, setDate };
}, [date]);
return <DateContext.Provider value={value}>{children}</DateContext.Provider>;
}

View File

@ -0,0 +1,28 @@
"use client";
import * as React from "react";
type PageContextType = {
currentPage: Number | undefined;
setCurrentPage: (currentPage: Number | undefined) => void;
};
const PageContext = React.createContext<PageContextType | undefined>(undefined);
export function usePageContext() {
const context = React.useContext(PageContext);
if (context === undefined) {
throw new Error("usePageContext must be used within a PageProvider");
}
return context;
}
export function PageProvider({ children }: { children: React.ReactNode }) {
const [currentPage, setCurrentPage] = React.useState<Number | undefined>(1);
const value = React.useMemo(() => {
return { currentPage, setCurrentPage };
}, [currentPage]);
return <PageContext.Provider value={value}>{children}</PageContext.Provider>;
}

131
epaper-client/src/index.css Normal file
View File

@ -0,0 +1,131 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
@keyframes zoomIn {
from {
opacity: 0;
transform: scale(0.3);
}
to {
opacity: 1;
transform: scale(1);
}
}

View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

1
epaper-client/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,32 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": ["src"]
}

View File

@ -0,0 +1,13 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,23 @@
export const months = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
export const weekdays = [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday",
];

View File

@ -0,0 +1,108 @@
export function autoPlaceArticles(articles, columnCount) {
const columnHeights = Array(columnCount).fill(0); // Track max height per column
const placements = [];
for (let article of articles) {
const { colSpan, rowSpan, gridColumnStart } = article;
let colStart, rowStart;
if (gridColumnStart) {
// Manual column override
colStart = gridColumnStart;
// Get current max height in that column group
const currentHeight = Math.max(
...columnHeights.slice(colStart - 1, colStart - 1 + colSpan)
);
rowStart = currentHeight + 1;
} else {
// Auto-placement: find best column group with lowest stack height
let bestStart = 0;
let minHeight = Infinity;
for (let start = 0; start <= columnCount - colSpan; start++) {
const slice = columnHeights.slice(start, start + colSpan);
const height = Math.max(...slice);
if (height < minHeight) {
minHeight = height;
bestStart = start;
}
}
colStart = bestStart + 1;
rowStart = minHeight + 1;
}
// Place the article
placements.push({
...article,
gridColumnStart: colStart,
gridRowStart: rowStart,
});
// Update column heights
for (let i = colStart - 1; i < colStart - 1 + colSpan; i++) {
columnHeights[i] = rowStart - 1 + rowSpan;
}
}
return placements;
}
export function parseImageFilename(filename) {
// Updated regex to capture optional gridColumnStart (_gZ) and handle parentheses/brackets
const regex =
/(?<page>\d+)_r(?<rowSpan>\d+)_c(?<colSpan>\d+)(?:_g(?<gridColumnStart>\d+))?.*\.\w+$/;
const match = filename.match(regex);
if (!match || !match.groups) {
throw new Error(`Invalid filename format: ${filename}`);
}
const { page, rowSpan, colSpan, gridColumnStart } = match.groups;
return {
page: parseInt(page, 10),
rowSpan: parseInt(rowSpan, 10),
colSpan: parseInt(colSpan, 10),
// Only include gridColumnStart if it exists in the filename
...(gridColumnStart && { gridColumnStart: parseInt(gridColumnStart, 10) }),
};
}
export function createArticlesFromFilenames(filenames, pageNumber) {
return filenames
.map((filename, index) => {
try {
const properties = parseImageFilename(filename);
return {
id: index + 1,
page: pageNumber, // inject page from outer structure
colSpan: properties.colSpan,
rowSpan: properties.rowSpan,
image: filename,
...(properties.gridColumnStart && {
gridColumnStart: properties.gridColumnStart,
}),
};
} catch (error) {
console.error(`Error processing filename ${filename}:`, error.message);
return null;
}
})
.filter((article) => article !== null);
}
export function isSameDay(date1, date2) {
if (!date1 || !date2) return false;
// Convert string dates to Date objects if needed
const d1 = typeof date1 === "string" ? new Date(date1) : date1;
const d2 = typeof date2 === "string" ? new Date(date2) : date2;
return (
d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate()
);
}

View File

@ -0,0 +1,14 @@
import path from "path";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});