Skip to content

Commit 1f82c92

Browse files
authored
Fix search & use native pdf view (#375)
* If not logged in then route to login * Fix validation on sign up * Fix search listing & Cache * use native pdf viewer and fix s3 files * fix build
1 parent 5b4e76c commit 1f82c92

File tree

14 files changed

+393
-126
lines changed

14 files changed

+393
-126
lines changed

.vscode/settings.json

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,12 @@
55
"editor.codeActionsOnSave": {
66
"source.fixAll.eslint": "explicit"
77
},
8-
"eslint.validate": [
9-
"javascript",
10-
"javascriptreact",
11-
"typescript",
12-
"typescriptreact"
13-
],
8+
"eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"],
149
"editor.rulers": [100],
1510
"[svg]": {
1611
"editor.defaultFormatter": "jock.svg"
1712
},
1813
"[typescriptreact]": {
19-
"editor.defaultFormatter": "vscode.typescript-language-features"
14+
"editor.defaultFormatter": "esbenp.prettier-vscode"
2015
}
2116
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { listAllFiles, updateContentType } from '@app/api/file-upload/s3';
2+
import { NextResponse } from 'next/server';
3+
4+
export async function POST() {
5+
try {
6+
const files = await listAllFiles();
7+
let updatedCount = 0;
8+
let errors = 0;
9+
10+
for (const key of files) {
11+
if (!key) continue;
12+
try {
13+
await updateContentType(key);
14+
updatedCount++;
15+
} catch (error) {
16+
console.error(`Failed to update ${key}:`, error);
17+
errors++;
18+
}
19+
}
20+
21+
return NextResponse.json({
22+
success: true,
23+
updated: updatedCount,
24+
errors: errors,
25+
message: `Updated ${updatedCount} files with ${errors} errors`,
26+
});
27+
} catch (error) {
28+
console.error('Content type update failed:', error);
29+
return NextResponse.json({ success: false, error: 'Failed to update content types' }, { status: 500 });
30+
}
31+
}

src/app/api/file-upload/s3.ts

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { S3Client, PutObjectCommand, ObjectCannedACL } from '@aws-sdk/client-s3';
1+
import {
2+
S3Client,
3+
PutObjectCommand,
4+
ObjectCannedACL,
5+
CopyObjectCommand,
6+
ListObjectsV2Command,
7+
} from '@aws-sdk/client-s3';
28

39
type T_s3Upload = {
410
file: Buffer;
@@ -19,24 +25,87 @@ const s3Client = new S3Client({
1925
async function uploadFile({ file, fileName, ext }: T_s3Upload) {
2026
try {
2127
const imageUrl = getKeyName({ name: fileName, ext });
28+
const contentType = getContentType(ext);
2229
const uploadParams = {
2330
Bucket: process.env.S3_BUCKET,
2431
Key: imageUrl,
2532
Body: file,
2633
ACL: 'public-read' as ObjectCannedACL,
34+
ContentType: contentType,
35+
ContentDisposition: `inline; filename="${encodeURIComponent(fileName)}.${ext}"`,
2736
};
2837

2938
const uploadCommand = new PutObjectCommand(uploadParams);
3039
await s3Client.send(uploadCommand);
3140
return `${process.env.S3_BUCKET_NAME_URL}/${imageUrl}`;
32-
} catch(error) {
41+
} catch (error) {
3342
console.error(error);
43+
throw error; // Re-throw to handle in calling code
3444
}
3545
}
3646

47+
function getContentType(ext: string): string {
48+
const typeMap: Record<string, string> = {
49+
jpg: 'image/jpeg',
50+
jpeg: 'image/jpeg',
51+
png: 'image/png',
52+
gif: 'image/gif',
53+
pdf: 'application/pdf',
54+
mp4: 'video/mp4',
55+
mov: 'video/quicktime',
56+
doc: 'application/msword',
57+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
58+
xls: 'application/vnd.ms-excel',
59+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
60+
ppt: 'application/vnd.ms-powerpoint',
61+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
62+
zip: 'application/zip',
63+
txt: 'text/plain',
64+
csv: 'text/csv',
65+
json: 'application/json',
66+
};
67+
68+
return typeMap[ext.toLowerCase()] || 'application/octet-stream';
69+
}
70+
3771
const today = new Date().getTime();
3872

3973
const getKeyName = ({ name, ext }: { name: string; ext: string }) =>
4074
`${process.env.S3_MEDIA_CONTENT_FOLDER}/${name}-${today}.${ext}`;
4175

76+
// This is used for some files that have incorrect content types hence not displaying properly in the browser
77+
export async function updateContentType(key: string) {
78+
try {
79+
const ext = key.split('.').pop()?.toLowerCase() || '';
80+
const contentType = getContentType(ext);
81+
82+
const encodedKey = encodeURIComponent(key);
83+
84+
const copyParams = {
85+
Bucket: process.env.S3_BUCKET,
86+
CopySource: `${process.env.S3_BUCKET}/${encodedKey}`,
87+
Key: key,
88+
ContentType: contentType,
89+
MetadataDirective: 'REPLACE' as const,
90+
ACL: 'public-read' as ObjectCannedACL,
91+
};
92+
93+
await s3Client.send(new CopyObjectCommand(copyParams));
94+
return true;
95+
} catch (error) {
96+
console.error(`Failed to update content type for ${key}:`, error);
97+
throw error;
98+
}
99+
}
100+
101+
export async function listAllFiles(prefix = process.env.S3_MEDIA_CONTENT_FOLDER) {
102+
const response = await s3Client.send(
103+
new ListObjectsV2Command({
104+
Bucket: process.env.S3_BUCKET,
105+
Prefix: prefix,
106+
}),
107+
);
108+
return response.Contents?.map((obj) => obj.Key) || [];
109+
}
110+
42111
export default uploadFile;

src/app/api/media-content/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,11 +229,12 @@ export async function findMediaContentByName_(request: any) {
229229
course_id: z.string().optional(),
230230
unit_id: z.string().optional(),
231231
topic_id: z.string().optional(),
232+
grade_id: z.string().optional(),
232233
});
233234
// const formBody = await request.json();
234235
const params = request.nextUrl.searchParams;
235236

236-
const { name, limit, skip, school_id, program_id, course_id, unit_id, topic_id } = schema.parse(params);
237+
const { name, limit, skip, school_id, program_id, course_id, unit_id, topic_id, grade_id } = schema.parse(params);
237238

238239
const isWithMetaData = params.withMetaData === 'true' ? true : false;
239240

@@ -260,6 +261,7 @@ export async function findMediaContentByName_(request: any) {
260261
if (course_id) query.course_id = new BSON.ObjectId(course_id);
261262
if (unit_id) query.unit_id = new BSON.ObjectId(unit_id);
262263
if (topic_id) query.topic_id = new BSON.ObjectId(topic_id);
264+
if (grade_id) query.grade_id = new BSON.ObjectId(grade_id);
263265
const project = await getDbFieldNamesConfigStatus({ dbConfigData });
264266

265267
if (isWithMetaData) {
Lines changed: 123 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,141 @@
11
'use client';
22

3-
import React from 'react';
4-
import { T_RawMediaContentFields } from 'types/media-content';
5-
import { useLibraryInfiniteScroll } from '@hooks/useLibrary/useLibraryInfiniteScroll';
6-
import { searchMedia } from 'fetchers/library/searchMedia';
7-
import { useSearchParams } from 'next/navigation';
83
import { LibraryInfiniteScrollList } from '@components/library/LibraryInfiniteScrollList';
4+
import { useFetch } from '@hooks/use-swr';
5+
import { useSearchMediaSWR } from '@hooks/useLibrary/useSearchMediaSWR';
6+
import { API_LINKS } from 'app/links';
97
import { Dropdown } from 'flowbite-react';
10-
import { useSearchQuery } from '@hooks/useSearchQuery';
118
import i18next from 'i18next';
9+
import { useSearchParams } from 'next/navigation';
10+
import { useState } from 'react';
11+
import NETWORK_UTILS from 'utils/network';
12+
import { SearchSkeleton } from './SearchSkeleton';
13+
14+
import Link from 'next/link';
15+
import { HiOutlineArrowLeft, HiOutlineSearch } from 'react-icons/hi';
1216

13-
export function SearchMediaContentList({ initialMediaContent }: { initialMediaContent: T_RawMediaContentFields[] }) {
14-
let params = useSearchParams();
15-
const { createQueryString, pathname } = useSearchQuery();
16-
const baseSearchPath = `${pathname}?`;
17+
const GradePicker = ({
18+
grades,
19+
gradeId,
20+
setGradeId,
21+
}: {
22+
grades: any[];
23+
gradeId: string;
24+
setGradeId: (id: string) => void;
25+
}) => (
26+
<Dropdown
27+
label={
28+
gradeId
29+
? (grades instanceof Array && grades.find((g) => g._id === gradeId)?.name) || i18next.t('Select Grade')
30+
: i18next.t('Select Grade')
31+
}
32+
dismissOnClick={true}
33+
>
34+
{gradeId && <Dropdown.Item onClick={() => setGradeId('')}>{i18next.t('All Grades')}</Dropdown.Item>}
35+
{grades instanceof Array &&
36+
grades.map((grade) => (
37+
<Dropdown.Item
38+
key={grade._id}
39+
onClick={() => {
40+
setGradeId(grade._id);
41+
}}
42+
>
43+
{grade.name}
44+
</Dropdown.Item>
45+
))}
46+
</Dropdown>
47+
);
48+
49+
export function SearchMediaContentList() {
50+
const params = useSearchParams();
51+
const [gradeId, setGradeId] = useState('');
1752
const searchTerm = params.get('q') || '';
1853
const sortBy = params.get('sort_by') || '';
54+
const query = { limit: '50', skip: '0', withMetaData: 'true' };
55+
const { data } = useFetch(`${API_LINKS.FETCH_GRADES}${NETWORK_UTILS.formatGetParams(query)}`);
56+
57+
const grades = data?.grades;
1958

20-
let { error, hasMore, loadMore, mediaContent } = useLibraryInfiniteScroll(initialMediaContent, async (offset) =>
21-
searchMedia(offset, searchTerm, sortBy),
59+
const { mediaContent, error, hasMore, loadMore, isLoadingInitialData, isValidating } = useSearchMediaSWR(
60+
searchTerm,
61+
sortBy,
62+
gradeId,
2263
);
2364

65+
if (isLoadingInitialData || isValidating) {
66+
return <SearchSkeleton />;
67+
}
68+
69+
if (error) {
70+
return (
71+
<div className="mt-6 p-4 text-center">
72+
<div className="flex justify-end mx-3 mb-4">
73+
<GradePicker grades={grades || []} gradeId={gradeId} setGradeId={setGradeId} />
74+
</div>
75+
<div className="bg-red-50 border border-red-200 rounded-lg p-6 inline-block mx-auto">
76+
<h3 className="text-red-700 text-lg font-medium mb-2">Error loading results</h3>
77+
<p className="text-red-600">There was a problem loading search results. Please try again later.</p>
78+
</div>
79+
</div>
80+
);
81+
}
82+
83+
if (!isLoadingInitialData && (!mediaContent || mediaContent.length === 0)) {
84+
return (
85+
<div>
86+
<div className="flex justify-end mx-3 mt-6 mb-4">
87+
<GradePicker grades={grades || []} gradeId={gradeId} setGradeId={setGradeId} />
88+
</div>
89+
<div className="flex flex-col items-center justify-center py-8 px-4">
90+
<div className=" p-6 rounded-full mb-6">
91+
<HiOutlineSearch className="w-16 h-16 text-gray-400" />
92+
</div>
93+
94+
<h2 className="text-2xl font-bold mb-2">No results found</h2>
95+
96+
<p className="text-gray-400 mb-6 max-w-md text-center">
97+
We couldn&lsquo;t find any content matching &quot;<span className="font-semibold">{searchTerm}</span>&quot;
98+
{gradeId && grades instanceof Array && ' for grade ' + grades.find((g) => g._id === gradeId)?.name}. Try
99+
selecting a different grade or modifying your search terms.
100+
</p>
101+
102+
<div className="flex flex-col sm:flex-row gap-4 mt-2">
103+
<Link
104+
href="/library"
105+
className="flex items-center justify-center gap-2 px-6 py-2 bg-teal-600 text-white rounded-md hover:bg-teal-700 transition-colors"
106+
>
107+
<HiOutlineArrowLeft className="w-5 h-5" />
108+
Back to Library
109+
</Link>
110+
</div>
111+
</div>
112+
</div>
113+
);
114+
}
115+
24116
return (
25-
<div className="mt-6 search-results" >
26-
<div className="flex justify-between items-center mx-3 flex-wrap gap-3">
27-
<p>
28-
Showing {Number(mediaContent?.length)} results for <b>{searchTerm}</b>
29-
</p>
30-
31-
<div className="mr-3">
32-
<Dropdown label={sortBy ? i18next.t(sortBy) : i18next.t('sort_by')} dismissOnClick={true}>
33-
<Dropdown.Item href={`${baseSearchPath}${createQueryString('sort_by', i18next.t('most_viewed'))}`}>
34-
{i18next.t('most_viewed')}
35-
</Dropdown.Item>
36-
<Dropdown.Item href={`${baseSearchPath}${createQueryString('sort_by', i18next.t('newest'))}`}>
37-
{i18next.t('newest')}
38-
</Dropdown.Item>
39-
</Dropdown>
117+
<div className="mt-6 search-results">
118+
<div className="flex justify-between items-center mx-3 mb-6">
119+
<div className="flex-1 flex justify-center items-center gap-2">
120+
<p className="text-center">
121+
<span className="font-medium">{mediaContent?.length || 0}</span> results found for{' '}
122+
<span className="font-medium">&quot;{searchTerm}&quot;</span>
123+
</p>
124+
</div>
125+
126+
<div className="flex gap-3">
127+
<GradePicker grades={grades || []} gradeId={gradeId} setGradeId={setGradeId} />
40128
</div>
41129
</div>
42130

43-
<LibraryInfiniteScrollList mediaContent={mediaContent} loadMore={loadMore} hasMore={hasMore} error={error} />
131+
<div className="px-3">
132+
<LibraryInfiniteScrollList
133+
mediaContent={mediaContent}
134+
loadMore={loadMore}
135+
hasMore={hasMore as boolean}
136+
error={error}
137+
/>
138+
</div>
44139
</div>
45140
);
46141
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import React from 'react';
2+
3+
export function SearchSkeleton() {
4+
return (
5+
<div className="mt-6 px-3">
6+
<div className="flex justify-between items-center mb-6">
7+
<div className="h-6 bg-gray-200 rounded w-48 animate-pulse"></div>
8+
<div className="h-10 bg-gray-200 rounded w-32 animate-pulse"></div>
9+
</div>
10+
11+
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
12+
{Array(10).fill(0).map((_, index) => (
13+
<div key={index} className="flex flex-col">
14+
<div className="h-40 bg-gray-200 rounded-lg animate-pulse mb-2"></div>
15+
<div className="h-5 bg-gray-200 rounded w-3/4 animate-pulse mb-1"></div>
16+
<div className="h-4 bg-gray-200 rounded w-1/2 animate-pulse"></div>
17+
</div>
18+
))}
19+
</div>
20+
</div>
21+
);
22+
}

0 commit comments

Comments
 (0)