Merge pull request 'Nico / 4-Feb-2026' (#60) from nico/4-feb-26 into staggingweb
Reviewed-on: http://wibugit.wibudev.com/wibu/desa-darmasaba/pulls/60
This commit is contained in:
@@ -50,6 +50,7 @@
|
|||||||
"add": "^2.0.6",
|
"add": "^2.0.6",
|
||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
|
"async-mutex": "^0.5.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"bun": "^1.2.2",
|
"bun": "^1.2.2",
|
||||||
"chart.js": "^4.4.8",
|
"chart.js": "^4.4.8",
|
||||||
|
|||||||
@@ -60,3 +60,12 @@ export async function seedDataPerpustakaan() {
|
|||||||
}
|
}
|
||||||
console.log("✅ Data perpustakaan seeded successfully");
|
console.log("✅ Data perpustakaan seeded successfully");
|
||||||
}
|
}
|
||||||
|
if (import.meta.main) {
|
||||||
|
seedDataPerpustakaan()
|
||||||
|
.then(() => {
|
||||||
|
console.log("seed data perpustakaan success");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log("gagal seed data perpustakaan", JSON.stringify(err));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -138,5 +138,61 @@
|
|||||||
"deskripsi": "<p>Cerita edukatif yang mengenalkan sains kepada anak dengan bahasa sederhana</p>",
|
"deskripsi": "<p>Cerita edukatif yang mengenalkan sains kepada anak dengan bahasa sederhana</p>",
|
||||||
"kategoriId": "cmkqb11mc000104jibqh7bdzu",
|
"kategoriId": "cmkqb11mc000104jibqh7bdzu",
|
||||||
"imageName": "G0iELZb2DhQDCCP5OdzJR-desktop.webp"
|
"imageName": "G0iELZb2DhQDCCP5OdzJR-desktop.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cml7fq776000104jscnj58sgm",
|
||||||
|
"judul": "Pedagogy of the Oppressed",
|
||||||
|
"deskripsi": "<p>Klasik pemikiran pendidikan kritis; menggali hubungan guru-murid dan peran pendidikan dalam pembebasan sosial</p>",
|
||||||
|
"kategoriId": "cmkqb11mc000104jibq97bdzu",
|
||||||
|
"imageName": "pendidikan-1.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cml7fqurm000204js5p60hkym",
|
||||||
|
"judul": "The Courage to Teach",
|
||||||
|
"deskripsi": "<p>Tentang refleksi diri seorang pendidik; cocok untuk pengajar yang ingin lebih dari sekedar “metode mengajar”</p>",
|
||||||
|
"kategoriId": "cmkqb11mc000104jibq97bdzu",
|
||||||
|
"imageName": "pendidikan-2.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cml7fqurm000204js5p60hkzn",
|
||||||
|
"judul": "A Brief History of Time",
|
||||||
|
"deskripsi": "<p>Penjelasan kosmologi yang terkenal dunia; sains kompleks dibahas dengan bahasa yang bisa dinikmati pembaca umum</p>",
|
||||||
|
"kategoriId": "cmkqb11mc000104jibqa7bdzu",
|
||||||
|
"imageName": "ilmiah-1.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cml7fqurm000204js5p60hkao",
|
||||||
|
"judul": "The Selfish Gene",
|
||||||
|
"deskripsi": "<p>Membawa perspektif baru tentang evolusi melalui “gen” sebagai unit seleksi</p>",
|
||||||
|
"kategoriId": "cmkqb11mc000104jibqa7bdzu",
|
||||||
|
"imageName": "ilmiah-2.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cml7fx09c000304jshams3xbg",
|
||||||
|
"judul": "A Little Life",
|
||||||
|
"deskripsi": "<p>Novel yang menggambarkan hidup seorang remaja yang mengalami kehidupan yang sangat sulit</p>",
|
||||||
|
"kategoriId": "cmkqb11mc000104jibqb7bdzu",
|
||||||
|
"imageName": "drama-1.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cml7fx09c000304jshams3xch",
|
||||||
|
"judul": "Death of a Salesman",
|
||||||
|
"deskripsi": "<p>Drama teater klasik Amerika tentang harapan, keluarga, dan realitas hidup.</p>",
|
||||||
|
"kategoriId": "cmkqb11mc000104jibqb7bdzu",
|
||||||
|
"imageName": "drama-2.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cml7fx09c000304jshams3xdi",
|
||||||
|
"judul": "How Not to Die",
|
||||||
|
"deskripsi": "<p>Panduan berbasis penelitian tentang pola makan untuk mencegah dan menangani penyakit.</p>",
|
||||||
|
"kategoriId": "cmkqb11mc000104jibqg7bdzu",
|
||||||
|
"imageName": "kesehatan-1.webp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cml7fx09c000304jshams3xej",
|
||||||
|
"judul": "The Body Keeps the Score",
|
||||||
|
"deskripsi": "<p>Fokus pada trauma, otak & tubuh; penting untuk memahami kesehatan mental secara mendalam.</p>",
|
||||||
|
"kategoriId": "cmkqb11mc000104jibqg7bdzu",
|
||||||
|
"imageName": "kesehatan-2.webp"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { getValidAuthToken } from "../../src/lib/seafile-auth-service";
|
||||||
|
|
||||||
type DirItem = {
|
type DirItem = {
|
||||||
type: "file" | "dir";
|
type: "file" | "dir";
|
||||||
name: string;
|
name: string;
|
||||||
@@ -5,7 +7,6 @@ type DirItem = {
|
|||||||
size?: number;
|
size?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const TOKEN = process.env.SEAFILE_TOKEN!;
|
|
||||||
const REPO_ID = process.env.SEAFILE_REPO_ID!;
|
const REPO_ID = process.env.SEAFILE_REPO_ID!;
|
||||||
|
|
||||||
// ⛔ PENTING: RELATIVE PATH (tanpa slash depan)
|
// ⛔ PENTING: RELATIVE PATH (tanpa slash depan)
|
||||||
@@ -13,11 +14,12 @@ const DIR_TARGET = "asset-web";
|
|||||||
|
|
||||||
const BASE_URL = process.env.SEAFILE_URL;
|
const BASE_URL = process.env.SEAFILE_URL;
|
||||||
|
|
||||||
const headers = {
|
|
||||||
Authorization: `Token ${TOKEN}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
async function getDirItems(): Promise<DirItem[]> {
|
async function getDirItems(): Promise<DirItem[]> {
|
||||||
|
const token = await getValidAuthToken();
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Token ${token}`,
|
||||||
|
};
|
||||||
|
|
||||||
const res = await fetch(`${BASE_URL}/repos/${REPO_ID}/dir/?p=${DIR_TARGET}`, {
|
const res = await fetch(`${BASE_URL}/repos/${REPO_ID}/dir/?p=${DIR_TARGET}`, {
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
@@ -30,6 +32,11 @@ async function getDirItems(): Promise<DirItem[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getDownloadUrl(filePath: string): Promise<string> {
|
async function getDownloadUrl(filePath: string): Promise<string> {
|
||||||
|
const token = await getValidAuthToken();
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Token ${token}`,
|
||||||
|
};
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${BASE_URL}/repos/${REPO_ID}/file/?p=${encodeURIComponent(filePath)}&reuse=1`,
|
`${BASE_URL}/repos/${REPO_ID}/file/?p=${encodeURIComponent(filePath)}&reuse=1`,
|
||||||
{ headers },
|
{ headers },
|
||||||
|
|||||||
@@ -38,12 +38,12 @@ export default async function seedAssets() {
|
|||||||
console.log("🎉 Image seeding completed");
|
console.log("🎉 Image seeding completed");
|
||||||
}
|
}
|
||||||
|
|
||||||
// if (import.meta.main) {
|
if (import.meta.main) {
|
||||||
// seedAssets()
|
seedAssets()
|
||||||
// .then(() => {
|
.then(() => {
|
||||||
// console.log("seed assets success");
|
console.log("seed assets success");
|
||||||
// })
|
})
|
||||||
// .catch((err) => {
|
.catch((err) => {
|
||||||
// console.log("gagal seed assets", JSON.stringify(err));
|
console.log("gagal seed assets", JSON.stringify(err));
|
||||||
// });
|
});
|
||||||
// }
|
}
|
||||||
|
|||||||
@@ -19,19 +19,27 @@ function Content({ kategoriBuku }: { kategoriBuku: string }) {
|
|||||||
const searchQuery = searchParams.get('search') || '';
|
const searchQuery = searchParams.get('search') || '';
|
||||||
const router = useTransitionRouter()
|
const router = useTransitionRouter()
|
||||||
|
|
||||||
|
// Convert kebab-case back to original category name format
|
||||||
|
// This reverses the transformation done in layoutTabs: item.name.toLowerCase().replace(/\s+/g, '-')
|
||||||
|
const convertKebabCaseToOriginal = (kebabStr: string): string => {
|
||||||
|
// Replace hyphens with spaces
|
||||||
|
return kebabStr.replace(/-/g, ' ');
|
||||||
|
};
|
||||||
|
|
||||||
const decodedKategoriBuku = decodeURIComponent(kategoriBuku);
|
const decodedKategoriBuku = decodeURIComponent(kategoriBuku);
|
||||||
|
const originalKategoriName = convertKebabCaseToOriginal(decodedKategoriBuku);
|
||||||
|
|
||||||
const loadData = useCallback(async (searchQuery: string = '', page: number = 1) => {
|
const loadData = useCallback(async (searchQuery: string = '', page: number = 1) => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const currentKategoriFilter = decodedKategoriBuku.toLowerCase() === 'semua' ? '' : decodedKategoriBuku;
|
const currentKategoriFilter = decodedKategoriBuku.toLowerCase() === 'semua' ? '' : originalKategoriName;
|
||||||
await state.dataPerpustakaan.findMany.load(page, 3, searchQuery, currentKategoriFilter);
|
await state.dataPerpustakaan.findMany.load(page, 3, searchQuery, currentKategoriFilter);
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
setTotalPages(state.dataPerpustakaan.findMany.totalPages);
|
setTotalPages(state.dataPerpustakaan.findMany.totalPages);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [state.dataPerpustakaan.findMany, decodedKategoriBuku]);
|
}, [state.dataPerpustakaan.findMany, originalKategoriName, decodedKategoriBuku]);
|
||||||
|
|
||||||
useShallowEffect(() => {
|
useShallowEffect(() => {
|
||||||
loadData(searchQuery);
|
loadData(searchQuery);
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ const getWorkStatus = (day: string, currentTime: string): { status: string; mess
|
|||||||
|
|
||||||
let workHoursMessage = "";
|
let workHoursMessage = "";
|
||||||
if (["Senin", "Selasa", "Rabu", "Kamis"].includes(day)) {
|
if (["Senin", "Selasa", "Rabu", "Kamis"].includes(day)) {
|
||||||
workHoursMessage = "07:30 - 15:10";
|
workHoursMessage = "07:30 - 15:30";
|
||||||
} else if (day === "Jumat") {
|
} else if (day === "Jumat") {
|
||||||
workHoursMessage = "07:30 - 12:00";
|
workHoursMessage = "07:30 - 12:00";
|
||||||
}
|
}
|
||||||
|
|||||||
109
src/lib/seafile-auth-service.ts
Normal file
109
src/lib/seafile-auth-service.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { Mutex } from 'async-mutex';
|
||||||
|
|
||||||
|
// Store the token and its expiration time
|
||||||
|
let authToken: string | null = null;
|
||||||
|
let tokenExpirationTime: number | null = null;
|
||||||
|
|
||||||
|
// Mutex to prevent multiple simultaneous token refresh attempts
|
||||||
|
const mutex = new Mutex();
|
||||||
|
|
||||||
|
// Function to authenticate with Seafile and get a new token
|
||||||
|
async function authenticateWithSeafile(): Promise<{token: string, expirationTime: number}> {
|
||||||
|
// First, check if we have a static token as fallback
|
||||||
|
const staticToken = process.env.SEAFILE_TOKEN;
|
||||||
|
if (staticToken) {
|
||||||
|
console.log("Using static SEAFILE_TOKEN from environment variables");
|
||||||
|
// For static tokens, we'll set a conservative expiration (e.g., 6 days) to force periodic refresh attempts
|
||||||
|
const conservativeExpiration = Date.now() + (6 * 24 * 60 * 60 * 1000); // 6 days from now
|
||||||
|
return {
|
||||||
|
token: staticToken,
|
||||||
|
expirationTime: conservativeExpiration
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, use username/password to get a new token
|
||||||
|
const username = process.env.SEAFILE_USERNAME;
|
||||||
|
const password = process.env.SEAFILE_PASSWORD;
|
||||||
|
const baseUrl = process.env.SEAFILE_URL;
|
||||||
|
|
||||||
|
if (!username || !password || !baseUrl) {
|
||||||
|
throw new Error('Missing required Seafile environment variables (either SEAFILE_TOKEN or SEAFILE_USERNAME/SEAFILE_PASSWORD/SEAFILE_URL)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${baseUrl}/api2/auth-token/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
// Note: Seafile tokens typically last 7 days by default, but we'll refresh earlier
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Authentication failed: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Calculate expiration time (set to refresh 1 hour before actual expiration)
|
||||||
|
// Seafile tokens typically last 7 days (604800 seconds), so we refresh after 6 days and 23 hours (601200 seconds)
|
||||||
|
const refreshTokenThreshold = 601200; // 6 days and 23 hours in seconds
|
||||||
|
const expirationTime = Date.now() + (refreshTokenThreshold * 1000);
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: data.token,
|
||||||
|
expirationTime
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get a valid authentication token
|
||||||
|
export async function getValidAuthToken(): Promise<string> {
|
||||||
|
// Check if we have a valid token that hasn't expired
|
||||||
|
if (authToken && tokenExpirationTime && Date.now() < tokenExpirationTime) {
|
||||||
|
return authToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire lock to prevent multiple simultaneous refresh attempts
|
||||||
|
return mutex.runExclusive(async () => {
|
||||||
|
// Double-check after acquiring the lock
|
||||||
|
if (authToken && tokenExpirationTime && Date.now() < tokenExpirationTime) {
|
||||||
|
return authToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a new token
|
||||||
|
const { token, expirationTime } = await authenticateWithSeafile();
|
||||||
|
|
||||||
|
// Update the stored token and expiration time
|
||||||
|
authToken = token;
|
||||||
|
tokenExpirationTime = expirationTime;
|
||||||
|
|
||||||
|
console.log('New Seafile token acquired and cached');
|
||||||
|
|
||||||
|
return token;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to force refresh the token (useful for manual refresh or testing)
|
||||||
|
export async function refreshAuthToken(): Promise<string> {
|
||||||
|
return mutex.runExclusive(async () => {
|
||||||
|
const { token, expirationTime } = await authenticateWithSeafile();
|
||||||
|
|
||||||
|
// Update the stored token and expiration time
|
||||||
|
authToken = token;
|
||||||
|
tokenExpirationTime = expirationTime;
|
||||||
|
|
||||||
|
console.log('Seafile token refreshed');
|
||||||
|
|
||||||
|
return token;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to check if the token is still valid
|
||||||
|
export function isTokenValid(): boolean {
|
||||||
|
return authToken !== null &&
|
||||||
|
tokenExpirationTime !== null &&
|
||||||
|
Date.now() < tokenExpirationTime;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user