This commit is contained in:
Kar
2026-02-24 22:49:47 +05:30
commit e6ea620e1b
47 changed files with 88728 additions and 0 deletions

View File

@@ -0,0 +1,187 @@
import { NextRequest, NextResponse } from 'next/server';
import { createReadStream, createWriteStream, existsSync, mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import sharp from 'sharp';
// Store compression progress in memory
let compressionProgress = {
currentFile: '',
status: 'idle', // idle, processing, completed, error
progress: 0,
totalFiles: 0,
message: '',
results: [] as Array<{
originalPath: string;
compressedPath: string;
originalSize: number;
compressedSize: number;
compressionRatio: number;
}>
};
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { imagePath } = body;
if (!imagePath) {
return NextResponse.json(
{
success: false,
error: 'Image path is required'
},
{ status: 400 }
);
}
// Validate image path
// Handle both full paths and direct filenames
let filename = imagePath;
if (imagePath.includes('/')) {
filename = imagePath.split('/').pop() || imagePath;
}
const fullPath = join(process.cwd(), 'public', 'observations', filename);
if (!existsSync(fullPath)) {
return NextResponse.json(
{
success: false,
error: 'Image file not found',
details: `Looking for: ${filename}`,
skipped: true // Mark as skipped, not failed
},
{ status: 404 }
);
}
// Start compression process
compressionProgress = {
currentFile: imagePath,
status: 'processing',
progress: 0,
totalFiles: 1,
message: 'Starting image compression...',
results: []
};
// Process the image
try {
// Create compressed directory if it doesn't exist
const compressedDir = join(process.cwd(), 'public', 'observations', 'compressed');
mkdirSync(compressedDir, { recursive: true });
// Generate compressed filename
const filename = imagePath.split('/').pop() || imagePath;
const nameWithExt = filename.split('.');
const extension = nameWithExt.pop();
const nameWithoutExt = nameWithExt.join('.');
const compressedFilename = `${nameWithoutExt}.jpg`; // Keep same name, ensure .jpg extension
const compressedPath = join('compressed', compressedFilename);
// Get original file stats
const stats = require('fs').statSync(fullPath);
const originalSize = stats.size;
// Update progress
compressionProgress.progress = 25;
compressionProgress.message = 'Resizing image to 720p...';
// Process image with sharp
const imageBuffer = require('fs').readFileSync(fullPath);
// Resize and compress
const processedImage = await sharp(imageBuffer)
.resize(720, null, {
fit: 'inside',
withoutEnlargement: true
})
.jpeg({
quality: 80,
progressive: true
})
.toBuffer();
// Update progress
compressionProgress.progress = 75;
compressionProgress.message = 'Compressing image...';
// Save compressed image
writeFileSync(join(compressedDir, compressedFilename), processedImage);
// Get compressed file stats
const compressedStats = require('fs').statSync(join(compressedDir, compressedFilename));
const compressedSize = compressedStats.size;
// Calculate compression ratio
const compressionRatio = ((originalSize - compressedSize) / originalSize * 100).toFixed(1);
// Update progress to completed
compressionProgress.progress = 100;
compressionProgress.status = 'completed';
compressionProgress.message = 'Image compression completed successfully';
compressionProgress.results = [{
originalPath: imagePath,
compressedPath: `compressed/${compressedFilename}`,
originalSize,
compressedSize,
compressionRatio: parseFloat(compressionRatio)
}];
return NextResponse.json({
success: true,
message: 'Image compressed successfully',
data: {
originalPath: imagePath,
compressedPath: `compressed/${compressedFilename}`,
originalSize,
compressedSize,
compressionRatio: parseFloat(compressionRatio)
}
});
} catch (error: any) {
compressionProgress.status = 'error';
compressionProgress.message = `Compression failed: ${error.message}`;
console.error('Compression error details:', {
imagePath,
filename,
fullPath,
error: error.message,
stack: error.stack
});
return NextResponse.json(
{
success: false,
error: 'Failed to compress image',
message: error.message,
details: {
imagePath,
filename,
fullPath
}
},
{ status: 500 }
);
}
} catch (error: any) {
return NextResponse.json(
{
success: false,
error: 'Invalid request format'
},
{ status: 400 }
);
}
}
// Progress endpoint
export async function GET() {
return NextResponse.json({
success: true,
data: compressionProgress
});
}

View File

@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import { readFile } from 'fs/promises';
import { join } from 'path';
export async function GET() {
try {
const filePath = join(process.cwd(), 'public', 'data_structure.json');
try {
const fileContent = await readFile(filePath, 'utf-8');
const data = JSON.parse(fileContent);
return NextResponse.json({
success: true,
data: data
});
} catch (fileError) {
// If file doesn't exist, return empty arrays
return NextResponse.json({
success: true,
data: {
learningAreas: [],
subLearningAreas: [],
frameworks: [],
lastUpdated: null,
totalObservations: 0
}
});
}
} catch (error: any) {
console.error('Error reading data structure:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to read data structure',
message: error.message
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,95 @@
import { NextRequest, NextResponse } from 'next/server';
export async function DELETE(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const params = await context.params;
const id = params.id;
if (!id) {
return NextResponse.json(
{
success: false,
error: 'No ID provided'
},
{ status: 400 }
);
}
const { MongoClient, ObjectId } = require('mongodb');
const uri = process.env.MONGODB_URI || 'mongodb://localhost/beanstalk';
const client = new MongoClient(uri);
await client.connect();
const db = client.db('beanstalk');
const collection = db.collection('observations');
// Try to find the document first to determine the correct _id format
let document = await collection.findOne({ _id: id });
let filter;
if (document) {
// Found with string ID
filter = { _id: id };
} else {
// Try with ObjectId
try {
const objectId = new ObjectId(id);
document = await collection.findOne({ _id: objectId });
if (document) {
filter = { _id: objectId };
} else {
await client.close();
return NextResponse.json(
{
success: false,
error: 'Observation not found'
},
{ status: 404 }
);
}
} catch (error) {
await client.close();
return NextResponse.json(
{
success: false,
error: 'Invalid ID format'
},
{ status: 400 }
);
}
}
// Delete the document
const result = await collection.deleteOne(filter);
await client.close();
if (result.deletedCount === 0) {
return NextResponse.json(
{
success: false,
error: 'Failed to delete observation'
},
{ status: 500 }
);
}
return NextResponse.json({
success: true,
message: 'Observation deleted successfully'
});
} catch (error: any) {
console.error('Delete observation error:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to delete observation',
message: error.message
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';
import { MongoClient } from 'mongodb';
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost/beanstalk';
const DATABASE_NAME = 'beanstalk';
export async function GET() {
let client: MongoClient | null = null;
try {
client = new MongoClient(MONGODB_URI);
await client.connect();
const db = client.db(DATABASE_NAME);
// Get unique frameworks from indicators collection
const indicators = await db.collection('indicators').find({}).toArray();
// Extract unique frameworks
const frameworks = new Set<string>();
indicators.forEach((indicator: any) => {
if (indicator.developmentArea && Array.isArray(indicator.developmentArea)) {
indicator.developmentArea.forEach((area: any) => {
if (area.framework) {
frameworks.add(area.framework);
}
});
}
});
return NextResponse.json({
success: true,
data: {
frameworks: Array.from(frameworks).sort(),
count: frameworks.size
}
});
} catch (error: any) {
console.error('Error fetching frameworks:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch frameworks',
message: error.message
},
{ status: 500 }
);
} finally {
if (client) {
await client.close();
}
}
}

View File

@@ -0,0 +1,268 @@
import { NextRequest, NextResponse } from 'next/server';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
import { createHash } from 'crypto';
interface S3Response {
url: string;
filename?: string;
}
interface ProgressCallback {
(progress: {
current: number;
total: number;
currentKey: string;
status: 'processing' | 'success' | 'error';
message?: string;
}): void;
}
// Store progress in memory (in production, use Redis or database)
let downloadProgress: any = {
current: 0,
total: 0,
currentKey: '',
status: 'idle',
results: []
};
async function downloadImage(s3Key: string): Promise<{ url: string; filename: string }> {
try {
const apiDomain = process.env.API_DOMAIN || 'https://app.example.tech';
const response = await fetch(`${apiDomain}/api/one/v1/file/url?s3Key=${encodeURIComponent(s3Key)}`);
if (!response.ok) {
throw new Error(`Failed to get download URL for ${s3Key}: ${response.statusText}`);
}
const data: S3Response = await response.json();
if (!data.url) {
throw new Error(`No download URL returned for ${s3Key}`);
}
// Extract filename from S3 key
const filename = s3Key.split('/').pop() || s3Key;
return { url: data.url, filename };
} catch (error) {
console.error(`Error getting download URL for ${s3Key}:`, error);
throw error;
}
}
async function saveImageFromUrl(url: string, filename: string, savePath: string): Promise<void> {
try {
// Download the image
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download image ${filename}: ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// Save to file system
await writeFile(savePath, buffer);
console.log(`Saved image: ${savePath}`);
} catch (error) {
console.error(`Error saving image ${filename}:`, error);
throw error;
}
}
function extractImageKeys(documents: any[]): string[] {
const imageKeys = new Set<string>();
documents.forEach(doc => {
// Extract from assets array
if (doc.assets && Array.isArray(doc.assets)) {
doc.assets.forEach((asset: string) => {
if (asset.startsWith('akademy/')) {
imageKeys.add(asset);
}
});
}
});
return Array.from(imageKeys);
}
async function processImageSequentially(
imageKeys: string[],
observationsDir: string,
onProgress?: ProgressCallback
): Promise<any[]> {
const results = [];
let successCount = 0;
let errorCount = 0;
for (let i = 0; i < imageKeys.length; i++) {
const imageKey = imageKeys[i];
try {
// Update progress - starting
downloadProgress.current = i + 1;
downloadProgress.total = imageKeys.length;
downloadProgress.currentKey = imageKey;
downloadProgress.status = 'processing';
if (onProgress) {
onProgress({
...downloadProgress,
status: 'processing',
message: `Starting download: ${imageKey}`
});
}
console.log(`[${i + 1}/${imageKeys.length}] Processing: ${imageKey}`);
// Get download URL from S3 API
const { url, filename } = await downloadImage(imageKey);
// Create a safe filename (remove path separators but keep original name)
const safeFilename = filename.replace(/[^a-zA-Z0-9.-]/g, '_');
const finalFilename = safeFilename;
const savePath = join(observationsDir, finalFilename);
// Download and save image
await saveImageFromUrl(url, filename, savePath);
const result = {
original_key: imageKey,
filename: finalFilename,
local_path: `/observations/${finalFilename}`,
status: 'success'
};
results.push(result);
successCount++;
// Update progress - success
downloadProgress.status = 'success';
if (onProgress) {
onProgress({
...downloadProgress,
status: 'success',
message: `Successfully saved: ${finalFilename}`
});
}
// Small delay between downloads to avoid overwhelming the API
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error: any) {
console.error(`Failed to process ${imageKey}:`, error.message);
const result = {
original_key: imageKey,
error: error.message,
status: 'error'
};
results.push(result);
errorCount++;
// Update progress - error
downloadProgress.status = 'error';
if (onProgress) {
onProgress({
...downloadProgress,
status: 'error',
message: `Failed: ${error.message}`
});
}
}
}
return results;
}
export async function POST(request: NextRequest) {
try {
// Ensure the public/observations directory exists
const observationsDir = join(process.cwd(), 'public', 'observations');
try {
await mkdir(observationsDir, { recursive: true });
} catch (error: any) {
if (error.code !== 'EEXIST') {
throw error;
}
}
// Get all documents from MongoDB to extract image keys
const { MongoClient } = require('mongodb');
const uri = process.env.MONGODB_URI || 'mongodb://localhost:27017';
const client = new MongoClient(uri);
await client.connect();
const db = client.db('beanstalk');
const collection = db.collection('observations');
const documents = await collection.find({}).toArray();
await client.close();
// Extract unique image keys from documents
const imageKeys = extractImageKeys(documents);
if (imageKeys.length === 0) {
return NextResponse.json({
success: true,
message: 'No images found in documents',
images_processed: 0,
images_saved: 0
});
}
console.log(`Found ${imageKeys.length} unique images to download`);
// Reset progress
downloadProgress = {
current: 0,
total: imageKeys.length,
currentKey: '',
status: 'starting',
results: []
};
// Process images one by one sequentially
const results = await processImageSequentially(imageKeys, observationsDir);
const successCount = results.filter(r => r.status === 'success').length;
const errorCount = results.filter(r => r.status === 'error').length;
return NextResponse.json({
success: true,
message: `Completed processing ${imageKeys.length} images. Successfully saved ${successCount}, failed ${errorCount}`,
images_processed: imageKeys.length,
images_saved: successCount,
images_failed: errorCount,
results: results
});
} catch (error: any) {
console.error('Error in get-images-from-s3:', error);
return NextResponse.json(
{
error: 'Failed to process images from S3',
message: error.message
},
{ status: 500 }
);
}
}
export async function GET(request: NextRequest) {
// Return current progress
return NextResponse.json({
message: 'Current download progress',
progress: downloadProgress,
usage: {
method: 'POST',
endpoint: '/api/get-images-from-s3',
description: 'Downloads all images found in MongoDB observations and saves them to public/observations/',
example: 'fetch("/api/get-images-from-s3", { method: "POST" })'
}
});
}

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const params = await context.params;
const id = params.id;
console.log('=== API CALLED WITH ID:', id);
console.log('Full params object:', JSON.stringify(params));
if (!id) {
return NextResponse.json(
{
success: false,
error: 'No ID provided'
},
{ status: 400 }
);
}
const { MongoClient, ObjectId } = require('mongodb');
const uri = process.env.MONGODB_URI || 'mongodb://localhost/beanstalk';
const client = new MongoClient(uri);
await client.connect();
const db = client.db('beanstalk');
const collection = db.collection('observations');
// Try to find by string ID first
let document = await collection.findOne({ _id: id });
console.log('String ID search result:', document ? 'Found' : 'Not found');
// If not found, try ObjectId
if (!document) {
try {
const objectId = new ObjectId(id);
document = await collection.findOne({ _id: objectId });
console.log('ObjectId search result:', document ? 'Found' : 'Not found');
} catch (error: any) {
console.log('Invalid ObjectId format:', error.message);
}
}
await client.close();
if (!document) {
return NextResponse.json(
{
success: false,
error: 'Observation not found',
searchedId: id
},
{ status: 404 }
);
}
return NextResponse.json({
success: true,
data: document
});
} catch (error: any) {
console.error('Error fetching observation:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch observation',
message: error.message
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '12');
const framework = searchParams.get('framework');
const learningArea = searchParams.get('learningArea');
const subLearningArea = searchParams.get('subLearningArea');
const { MongoClient } = require('mongodb');
const uri = process.env.MONGODB_URI || 'mongodb://localhost/beanstalk';
const client = new MongoClient(uri);
await client.connect();
const db = client.db('beanstalk');
const collection = db.collection('observations');
// Build filter query
let filter: any = {};
if (framework) {
filter['indicators.developmentAreas.framework'] = framework.trim();
}
if (learningArea) {
filter['indicators.developmentAreas.learningArea'] = learningArea.trim();
}
if (subLearningArea) {
filter['indicators.developmentAreas.subLearningArea'] = subLearningArea.trim();
}
// Get total count for pagination
const totalCount = await collection.countDocuments(filter);
// Calculate skip value for pagination
const skip = (page - 1) * limit;
// Fetch observations with pagination and sort by createdAt (newest first)
const documents = await collection
.find(filter)
.sort({ createdAt: -1 })
.skip(skip)
.limit(limit)
.toArray();
await client.close();
return NextResponse.json({
success: true,
data: documents,
pagination: {
currentPage: page,
totalPages: Math.ceil(totalCount / limit),
totalCount: totalCount,
limit: limit,
hasNextPage: page < Math.ceil(totalCount / limit),
hasPrevPage: page > 1
}
});
} catch (error: any) {
console.error('Error fetching observations:', error);
return NextResponse.json(
{
error: 'Failed to fetch observations',
message: error.message
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,188 @@
import { NextRequest, NextResponse } from 'next/server';
import { MongoClient, Db, Collection, ObjectId } from 'mongodb';
// MongoDB connection
let client: MongoClient | null = null;
let db: Db | null = null;
async function getDatabase(): Promise<Db> {
if (!client) {
const uri = process.env.MONGODB_URI || 'mongodb://localhost:27017';
client = new MongoClient(uri);
await client.connect();
db = client.db('beanstalk');
}
return db!;
}
function convertMongoExtendedJSON(obj: any): any {
if (obj === null || obj === undefined) {
return obj;
}
if (Array.isArray(obj)) {
return obj.map(item => convertMongoExtendedJSON(item));
}
if (typeof obj === 'object') {
const converted: any = {};
for (const [key, value] of Object.entries(obj)) {
if (key === '$oid' && typeof value === 'string') {
return new ObjectId(value);
} else if (key === '$date' && typeof value === 'string') {
return new Date(value);
} else {
converted[key] = convertMongoExtendedJSON(value);
}
}
return converted;
}
return obj;
}
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const filename = searchParams.get('file');
if (!filename) {
return NextResponse.json(
{
error: 'file parameter is required',
message: 'Usage: /api/import-json?file=db_prod.akademyObservations.json'
},
{ status: 400 }
);
}
// Read JSON file
const fs = require('fs');
const path = require('path');
const filePath = path.join(process.cwd(), '..', filename);
const fileContent = fs.readFileSync(filePath, 'utf-8');
const rawDocuments = JSON.parse(fileContent);
// Convert MongoDB extended JSON to proper MongoDB documents
const documents = rawDocuments.map((doc: any) => convertMongoExtendedJSON(doc));
// Get MongoDB collection
const db = await getDatabase();
const collection = db.collection('observations');
// Insert documents
const result = await collection.insertMany(documents);
return NextResponse.json({
success: true,
message: `Successfully imported ${result.insertedCount} observations`,
inserted_count: result.insertedCount,
inserted_ids: Object.values(result.insertedIds)
});
} catch (error: any) {
console.error('Import error:', error);
if (error.code === 'ENOENT') {
return NextResponse.json(
{
error: 'File not found',
message: `The file was not found`
},
{ status: 400 }
);
}
if (error.name === 'SyntaxError') {
return NextResponse.json(
{
error: 'Failed to parse JSON',
message: error.message
},
{ status: 400 }
);
}
return NextResponse.json(
{
error: 'Failed to import data',
message: error.message
},
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file) {
return NextResponse.json(
{
error: 'No file provided',
message: 'Please select a JSON file to upload'
},
{ status: 400 }
);
}
// Validate file type
if (!file.type.includes('json')) {
return NextResponse.json(
{
error: 'Invalid file type',
message: 'Please upload a JSON file'
},
{ status: 400 }
);
}
// Read file content
const fileContent = await file.text();
const rawDocuments = JSON.parse(fileContent);
// Convert MongoDB extended JSON to proper MongoDB documents
const documents = rawDocuments.map((doc: any) => convertMongoExtendedJSON(doc));
// Get MongoDB collection
const db = await getDatabase();
const collection = db.collection('observations');
// Insert documents
const result = await collection.insertMany(documents);
return NextResponse.json({
success: true,
message: `Successfully imported ${result.insertedCount} observations from ${file.name}`,
inserted_count: result.insertedCount,
inserted_ids: Object.values(result.insertedIds),
filename: file.name,
size: `${(file.size / 1024).toFixed(2)} KB`
});
} catch (error: any) {
console.error('Import error:', error);
if (error.name === 'SyntaxError') {
return NextResponse.json(
{
error: 'Failed to parse JSON',
message: error.message
},
{ status: 400 }
);
}
return NextResponse.json(
{
error: 'Failed to import data',
message: error.message
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,85 @@
import { NextRequest, NextResponse } from 'next/server';
import { MongoClient } from 'mongodb';
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost/beanstalk';
const DATABASE_NAME = 'beanstalk';
export async function GET(request: NextRequest) {
let client: MongoClient | null = null;
try {
client = new MongoClient(MONGODB_URI);
await client.connect();
const db = client.db(DATABASE_NAME);
const { searchParams } = new URL(request.url);
const framework = searchParams.get('framework');
const learningArea = searchParams.get('learningArea');
const subLearningArea = searchParams.get('subLearningArea');
// Build filter query
let filter: any = {};
if (framework) {
filter['developmentArea.framework'] = framework.trim();
}
if (learningArea) {
filter['developmentArea.learningArea'] = learningArea.trim();
}
if (subLearningArea) {
filter['developmentArea.subLearningArea'] = subLearningArea.trim();
}
// Get indicators matching the filter
const indicators = await db.collection('indicators')
.find({
developmentArea: {
$elemMatch: {
...(framework && { framework: framework.trim() }),
...(learningArea && { learningArea: learningArea.trim() }),
...(subLearningArea && { subLearningArea: subLearningArea.trim() })
}
}
})
.toArray();
// Extract unique indicator descriptions
const indicatorDescriptions = new Set<string>();
indicators.forEach((indicator: any) => {
if (indicator.indicator) {
indicatorDescriptions.add(indicator.indicator);
}
});
return NextResponse.json({
success: true,
data: {
indicators: Array.from(indicatorDescriptions).sort(),
count: indicatorDescriptions.size,
filter: {
framework,
learningArea,
subLearningArea
}
}
});
} catch (error: any) {
console.error('Error fetching indicators:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch indicators',
message: error.message
},
{ status: 500 }
);
} finally {
if (client) {
await client.close();
}
}
}

View File

@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from 'next/server';
import { MongoClient } from 'mongodb';
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost/beanstalk';
const DATABASE_NAME = 'beanstalk';
export async function GET() {
let client: MongoClient | null = null;
try {
client = new MongoClient(MONGODB_URI);
await client.connect();
const db = client.db(DATABASE_NAME);
// Get all indicators from indicators collection
const indicators = await db.collection('indicators').find({}).toArray();
// Extract unique indicator descriptions
const indicatorDescriptions = new Set<string>();
indicators.forEach((indicator: any) => {
if (indicator.indicator) {
indicatorDescriptions.add(indicator.indicator);
}
});
return NextResponse.json({
success: true,
data: {
indicators: Array.from(indicatorDescriptions).sort(),
count: indicatorDescriptions.size
}
});
} catch (error: any) {
console.error('Error fetching indicators:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch indicators',
message: error.message
},
{ status: 500 }
);
} finally {
if (client) {
await client.close();
}
}
}

View File

@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
import { MongoClient } from 'mongodb';
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost/beanstalk';
const DATABASE_NAME = 'beanstalk';
export async function GET(request: NextRequest) {
let client: MongoClient | null = null;
try {
const { searchParams } = new URL(request.url);
const framework = searchParams.get('framework');
client = new MongoClient(MONGODB_URI);
await client.connect();
const db = client.db(DATABASE_NAME);
let query: any = {};
if (framework) {
query['developmentArea.framework'] = framework;
}
// Get unique learning areas from indicators collection
const indicators = await db.collection('indicators').find(query).toArray();
// Extract unique learning areas
const learningAreas = new Set<string>();
indicators.forEach((indicator: any) => {
if (indicator.developmentArea && Array.isArray(indicator.developmentArea)) {
indicator.developmentArea.forEach((area: any) => {
if (area.learningArea) {
learningAreas.add(area.learningArea);
}
});
}
});
return NextResponse.json({
success: true,
data: {
learningAreas: Array.from(learningAreas).sort(),
count: learningAreas.size,
framework: framework || 'all'
}
});
} catch (error: any) {
console.error('Error fetching learning areas:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch learning areas',
message: error.message
},
{ status: 500 }
);
} finally {
if (client) {
await client.close();
}
}
}

View File

@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from 'next/server';
import { readFile } from 'fs/promises';
import { join } from 'path';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const path = searchParams.get('path');
if (!path) {
return NextResponse.json({ error: 'No path provided' }, { status: 400 });
}
// Security check - ensure the path doesn't contain directory traversal
if (path.includes('..') || path.includes('/') || path.includes('\\')) {
return NextResponse.json({ error: 'Invalid path' }, { status: 400 });
}
const imagePath = join(process.cwd(), 'public', 'observations', path);
try {
const imageBuffer = await readFile(imagePath);
// Determine content type based on file extension
const ext = path.split('.').pop()?.toLowerCase();
let contentType = 'image/jpeg';
if (ext === 'png') contentType = 'image/png';
else if (ext === 'gif') contentType = 'image/gif';
else if (ext === 'webp') contentType = 'image/webp';
else if (ext === 'jpg' || ext === 'jpeg') contentType = 'image/jpeg';
return new NextResponse(imageBuffer, {
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=31536000', // Cache for 1 year
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
} catch (fileError) {
console.error('File not found:', imagePath);
return NextResponse.json({ error: 'Image not found' }, { status: 404 });
}
} catch (error) {
console.error('Proxy image error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,89 @@
import { NextRequest, NextResponse } from 'next/server';
import { MongoClient } from 'mongodb';
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost/beanstalk';
const DATABASE_NAME = 'beanstalk';
export async function GET(request: NextRequest) {
let client: MongoClient | null = null;
try {
const { searchParams } = new URL(request.url);
const framework = searchParams.get('framework');
const learningArea = searchParams.get('learningArea');
client = new MongoClient(MONGODB_URI);
await client.connect();
const db = client.db(DATABASE_NAME);
let query: any = {};
if (framework && learningArea) {
query['developmentArea'] = {
$elemMatch: {
framework: framework,
learningArea: learningArea
}
};
} else if (framework) {
query['developmentArea.framework'] = framework;
} else if (learningArea) {
query['developmentArea.learningArea'] = learningArea;
}
// Get indicators matching the criteria
const indicators = await db.collection('indicators').find(query).toArray();
// Extract unique sub-learning areas
const subLearningAreas = new Set<string>();
indicators.forEach((indicator: any) => {
if (indicator.developmentArea && Array.isArray(indicator.developmentArea)) {
indicator.developmentArea.forEach((area: any) => {
if (area.subLearningArea) {
// Only add if it matches both framework and learning area if both are provided
if (framework && learningArea) {
if (area.framework === framework && area.learningArea === learningArea) {
subLearningAreas.add(area.subLearningArea);
}
} else if (framework && !learningArea) {
if (area.framework === framework) {
subLearningAreas.add(area.subLearningArea);
}
} else if (!framework && learningArea) {
if (area.learningArea === learningArea) {
subLearningAreas.add(area.subLearningArea);
}
} else {
subLearningAreas.add(area.subLearningArea);
}
}
});
}
});
return NextResponse.json({
success: true,
data: {
subLearningAreas: Array.from(subLearningAreas).sort(),
count: subLearningAreas.size,
framework: framework || 'all',
learningArea: learningArea || 'all'
}
});
} catch (error: any) {
console.error('Error fetching sub-learning areas:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to fetch sub-learning areas',
message: error.message
},
{ status: 500 }
);
} finally {
if (client) {
await client.close();
}
}
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server';
import { writeFile, mkdir } from 'fs/promises';
import { join } from 'path';
export async function POST() {
try {
const { MongoClient } = require('mongodb');
const uri = process.env.MONGODB_URI || 'mongodb://localhost/beanstalk';
const client = new MongoClient(uri);
await client.connect();
const db = client.db('beanstalk');
const collection = db.collection('observations');
// Get all observations
const observations = await collection.find({}).toArray();
// Extract unique values
const learningAreas = new Set<string>();
const subLearningAreas = new Set<string>();
const frameworks = new Set<string>();
observations.forEach((obs: any) => {
if (obs.indicators && Array.isArray(obs.indicators)) {
obs.indicators.forEach((indicator: any) => {
if (indicator.developmentAreas && Array.isArray(indicator.developmentAreas)) {
indicator.developmentAreas.forEach((area: any) => {
if (area.learningArea) learningAreas.add(area.learningArea);
if (area.subLearningArea) subLearningAreas.add(area.subLearningArea);
if (area.framework) frameworks.add(area.framework);
});
}
});
}
});
const dataStructure = {
learningAreas: Array.from(learningAreas).sort(),
subLearningAreas: Array.from(subLearningAreas).sort(),
frameworks: Array.from(frameworks).sort(),
lastUpdated: new Date().toISOString(),
totalObservations: observations.length
};
// Save to public directory so it can be accessed by the client
const publicDir = join(process.cwd(), 'public');
const filePath = join(publicDir, 'data_structure.json');
// Ensure public directory exists
await mkdir(publicDir, { recursive: true });
// Write the file
await writeFile(filePath, JSON.stringify(dataStructure, null, 2), 'utf-8');
await client.close();
return NextResponse.json({
success: true,
message: 'Data structure updated successfully',
data: dataStructure
});
} catch (error: any) {
console.error('Error updating data structure:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to update data structure',
message: error.message
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from 'next/server';
export async function PUT(
request: NextRequest,
context: { params: Promise<{ id: string }> }
) {
try {
const params = await context.params;
const id = params.id;
if (!id) {
return NextResponse.json(
{
success: false,
error: 'No ID provided'
},
{ status: 400 }
);
}
const { MongoClient, ObjectId } = require('mongodb');
const uri = process.env.MONGODB_URI || 'mongodb://localhost/beanstalk';
const client = new MongoClient(uri);
await client.connect();
const db = client.db('beanstalk');
const collection = db.collection('observations');
const body = await request.json();
// Try to find the document first to determine the correct _id format
let document = await collection.findOne({ _id: id });
let filter;
if (document) {
// Found with string ID
filter = { _id: id };
} else {
// Try with ObjectId
try {
const objectId = new ObjectId(id);
document = await collection.findOne({ _id: objectId });
if (document) {
filter = { _id: objectId };
} else {
await client.close();
return NextResponse.json(
{
success: false,
error: 'Observation not found'
},
{ status: 404 }
);
}
} catch (error) {
await client.close();
return NextResponse.json(
{
success: false,
error: 'Invalid ID format'
},
{ status: 400 }
);
}
}
// Update observation
const result = await collection.updateOne(
filter,
{
$set: {
...body,
updatedAt: new Date()
}
}
);
if (result.matchedCount === 0) {
await client.close();
return NextResponse.json(
{
success: false,
error: 'Observation not found'
},
{ status: 404 }
);
}
// Fetch updated document
const updatedDocument = await collection.findOne(filter);
await client.close();
return NextResponse.json({
success: true,
data: updatedDocument,
message: 'Observation updated successfully'
});
} catch (error: any) {
console.error('Error updating observation:', error);
return NextResponse.json(
{
success: false,
error: 'Failed to update observation',
message: error.message
},
{ status: 500 }
);
}
}

196
src/app/data/page.tsx Normal file
View File

@@ -0,0 +1,196 @@
'use client';
import { useState, useEffect } from 'react';
import Layout from '@/components/Layout';
export default function DataPage() {
// Compression states
const [compressing, setCompressing] = useState(false);
const [compressionProgress, setCompressionProgress] = useState<any>(null);
const compressAllImages = async () => {
try {
setCompressing(true);
setCompressionProgress({ status: 'starting', message: 'Starting image compression...' });
// Get all unique image paths from observations
const response = await fetch('/api/get-observations?limit=1000');
if (response.ok) {
const data = await response.json();
if (data.success) {
const imagePaths = new Set<string>();
data.data.forEach((obs: any) => {
if (obs.assets && Array.isArray(obs.assets)) {
obs.assets.forEach((asset: string) => {
imagePaths.add(asset);
});
}
});
const totalImages = imagePaths.size;
let processedImages = 0;
let failedImages = 0;
let skippedImages = 0;
console.log(`Found ${totalImages} unique images to compress`);
// Process each image
for (const imagePath of imagePaths) {
try {
const compressResponse = await fetch('/api/compress-image', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ imagePath }),
});
if (compressResponse.ok) {
const result = await compressResponse.json();
processedImages++;
setCompressionProgress({
status: 'processing',
message: `Compressed ${processedImages} of ${totalImages} images... (${Math.round((processedImages / totalImages) * 100)}%)`,
progress: (processedImages / totalImages) * 100
});
console.log(`Compressed: ${imagePath} - Ratio: ${result.data.compressionRatio}%`);
} else {
const errorResult = await compressResponse.json().catch(() => ({}));
if (errorResult.skipped) {
skippedImages++;
console.log(`Skipped (file not found): ${imagePath}`);
} else {
failedImages++;
console.error(`Failed to compress: ${imagePath}`, errorResult);
}
setCompressionProgress((prev: any) => ({
...prev,
message: `Compressed ${processedImages} of ${totalImages} images... (${skippedImages} skipped, ${failedImages} failed)`
}));
}
} catch (error) {
failedImages++;
console.error('Error compressing image:', imagePath, error);
}
}
let finalMessage = `Successfully compressed ${processedImages} images`;
if (skippedImages > 0) {
finalMessage += `, ${skippedImages} skipped (files not found)`;
}
if (failedImages > 0) {
finalMessage += `, ${failedImages} failed`;
}
setCompressionProgress({
status: 'completed',
message: finalMessage,
progress: 100,
totalImages,
processedImages,
skippedImages,
failedImages
});
}
}
} catch (error) {
console.error('Error in compression process:', error);
setCompressionProgress({
status: 'error',
message: 'Compression failed: ' + (error instanceof Error ? error.message : 'Unknown error'),
progress: 0
});
} finally {
setCompressing(false);
}
};
return (
<Layout>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white rounded-lg shadow-sm border border-slate-200 p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Data Management</h1>
{/* Import Section */}
<div className="mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Import Data</h2>
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-gray-700 mb-4">Import observation data from JSON files into the database.</p>
<a
href="/import-json"
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium"
>
Import JSON
</a>
</div>
</div>
{/* Image Compression Section */}
<div className="mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Image Compression</h2>
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-gray-700 mb-4">
Compress all observation images to reduce file size. Images will be resized to maximum 720p
and compressed with optimized settings.
</p>
<button
onClick={compressAllImages}
disabled={compressing}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium disabled:bg-gray-400"
>
{compressing ? 'Compressing...' : 'Start Image Compression'}
</button>
{/* Compression Progress */}
{compressionProgress && (
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h3 className="text-lg font-semibold text-blue-900 mb-2">Image Compression Progress</h3>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-gray-700">Status:</span>
<span className="text-sm font-medium text-blue-900">{compressionProgress.status}</span>
</div>
{compressionProgress.message && (
<div className="text-sm text-gray-600">{compressionProgress.message}</div>
)}
{compressionProgress.progress > 0 && (
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${compressionProgress.progress}%` }}
></div>
</div>
)}
{compressionProgress.totalImages && (
<div className="text-sm text-gray-600">
Total Images: {compressionProgress.totalImages} |
Processed: {compressionProgress.processedImages} |
Skipped: {compressionProgress.skippedImages || 0} |
Failed: {compressionProgress.failedImages || 0}
</div>
)}
</div>
</div>
)}
</div>
</div>
{/* Download Images Section */}
<div>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Download Images</h2>
<div className="bg-gray-50 p-4 rounded-lg">
<p className="text-gray-700 mb-4">Download observation images from S3 storage to local server.</p>
<a
href="/download-images"
className="inline-flex items-center px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 text-sm font-medium"
>
Download Images
</a>
</div>
</div>
</div>
</div>
</Layout>
);
}

View File

@@ -0,0 +1,137 @@
'use client';
import { useState } from 'react';
export default function DownloadImagesPage() {
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
const handleDownload = async () => {
setLoading(true);
setError(null);
setResult(null);
try {
const response = await fetch('/api/get-images-from-s3', {
method: 'POST',
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Download failed');
}
setResult(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-2xl mx-auto bg-white rounded-lg shadow-md p-6">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 text-center">
Download Images from S3
</h1>
<p className="mt-2 text-gray-600 text-center">
Extract image URLs from MongoDB observations and download them to public/observations/
</p>
</div>
<div className="space-y-6">
<div>
<button
onClick={handleDownload}
disabled={loading}
className={`w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white ${
loading
? 'bg-gray-400 cursor-not-allowed'
: 'bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500'
}`}
>
{loading ? 'Downloading Images...' : 'Download All Images'}
</button>
</div>
</div>
{error && (
<div className="mt-6 p-4 bg-red-50 border border-red-200 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Download Failed</h3>
<div className="mt-2 text-sm text-red-700">{error}</div>
</div>
</div>
</div>
)}
{result && (
<div className="mt-6 p-4 bg-green-50 border border-green-200 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-green-800">Download Complete</h3>
<div className="mt-2 text-sm text-green-700">
<p>{result.message}</p>
<div className="mt-2 space-y-1">
<p><strong>Images Processed:</strong> {result.images_processed}</p>
<p><strong>Images Saved:</strong> {result.images_saved}</p>
<p><strong>Images Failed:</strong> {result.images_failed}</p>
</div>
{result.results && result.results.length > 0 && (
<details className="mt-3">
<summary className="cursor-pointer text-sm font-medium text-green-800 hover:text-green-900">
View Detailed Results ({result.results.length})
</summary>
<div className="mt-2 max-h-40 overflow-y-auto text-xs">
{result.results.map((item: any, index: number) => (
<div key={index} className="mb-2 p-2 bg-white rounded border">
<div className="font-medium">Key: {item.original_key}</div>
{item.status === 'success' ? (
<div className="text-green-600">
Saved as: {item.filename}
<br />
Path: {item.local_path}
</div>
) : (
<div className="text-red-600">
Error: {item.error}
</div>
)}
</div>
))}
</div>
</details>
)}
</div>
</div>
</div>
</div>
)}
<div className="mt-8">
<a
href="/"
className="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Back to Home
</a>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,572 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Image from 'next/image';
import Link from 'next/link';
import Layout from '@/components/Layout';
interface DevelopmentArea {
learningArea: string;
subLearningArea: string;
framework: string;
}
interface Indicator {
indicator: string;
developmentAreas: DevelopmentArea[];
}
interface Observation {
_id: string;
title: string;
remarks: string;
assets: string[];
indicators: Indicator[];
grade: { grade: string }[];
createdAt: string;
updatedAt: string;
}
export default function EditObservationPage() {
const params = useParams();
const router = useRouter();
const id = params.id as string;
const [observation, setObservation] = useState<Observation | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [success, setSuccess] = useState<string | null>(null);
const [title, setTitle] = useState('');
const [remarks, setRemarks] = useState('');
const [indicators, setIndicators] = useState<Indicator[]>([]);
// Dropdown options state
const [allFrameworks, setAllFrameworks] = useState<string[]>([]);
const [allLearningAreas, setAllLearningAreas] = useState<{ [key: number]: string[] }>({});
const [allSubLearningAreas, setAllSubLearningAreas] = useState<{ [key: number]: string[] }>({});
const [allIndicators, setAllIndicators] = useState<{ [key: number]: string[] }>({});
useEffect(() => {
fetchObservation();
}, [id]);
// Load dropdown options
useEffect(() => {
fetch('/api/frameworks')
.then(r => r.json())
.then(d => d.success && setAllFrameworks(d.data.frameworks))
.catch(console.error);
}, []);
// Load filtered indicators when framework/learning area/sub-learning area changes
useEffect(() => {
indicators.forEach((indicator, index) => {
const framework = indicator.developmentAreas[0]?.framework;
const learningArea = indicator.developmentAreas[0]?.learningArea;
const subLearningArea = indicator.developmentAreas[0]?.subLearningArea;
if (framework || learningArea || subLearningArea) {
const params = new URLSearchParams();
if (framework) params.set('framework', framework);
if (learningArea) params.set('learningArea', learningArea);
if (subLearningArea) params.set('subLearningArea', subLearningArea);
fetch(`/api/indicators-by-filter?${params.toString()}`)
.then(r => r.json())
.then(d => {
if (d.success) {
setAllIndicators(prev => ({ ...prev, [index]: d.data.indicators }));
}
})
.catch(console.error);
}
});
}, [indicators.map(ind => `${ind.developmentAreas[0]?.framework}-${ind.developmentAreas[0]?.learningArea}-${ind.developmentAreas[0]?.subLearningArea}`).join(',')]);
// Load learning areas when framework changes for each indicator
useEffect(() => {
indicators.forEach((indicator, index) => {
const framework = indicator.developmentAreas[0]?.framework;
if (framework) {
fetch(`/api/learning-areas?framework=${encodeURIComponent(framework)}`)
.then(r => r.json())
.then(d => {
if (d.success) {
setAllLearningAreas(prev => ({ ...prev, [index]: d.data.learningAreas }));
}
})
.catch(console.error);
}
});
}, [indicators.map(ind => ind.developmentAreas[0]?.framework).join(',')]);
// Load sub-learning areas when framework or learning area changes
useEffect(() => {
indicators.forEach((indicator, index) => {
const framework = indicator.developmentAreas[0]?.framework;
const learningArea = indicator.developmentAreas[0]?.learningArea;
if (framework && learningArea) {
fetch(`/api/sub-learning-areas?framework=${encodeURIComponent(framework)}&learningArea=${encodeURIComponent(learningArea)}`)
.then(r => r.json())
.then(d => {
if (d.success) {
setAllSubLearningAreas(prev => ({ ...prev, [index]: d.data.subLearningAreas }));
}
})
.catch(console.error);
}
});
}, [indicators.map(ind => `${ind.developmentAreas[0]?.framework}-${ind.developmentAreas[0]?.learningArea}`).join(',')]);
const fetchObservation = async () => {
try {
const response = await fetch(`/api/get-observation/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch observation');
}
const result = await response.json();
if (result.success) {
const data = result.data;
setObservation(data);
setTitle(data.title || '');
setRemarks(data.remarks || '');
setIndicators(data.indicators || []);
} else {
throw new Error(result.error || 'Failed to fetch observation');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
};
const handleSave = async () => {
try {
setSaving(true);
setError(null);
setSuccess(null);
const response = await fetch(`/api/update-observation/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title,
remarks,
indicators,
}),
});
const result = await response.json();
if (result.success) {
setSuccess('Observation updated successfully!');
} else {
setError(result.error || 'Failed to update observation');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
try {
setIsDeleting(true);
setError(null);
const response = await fetch(`/api/delete-observation/${id}`, {
method: 'DELETE',
});
const result = await response.json();
if (result.success) {
// Redirect to view-development-areas after successful deletion
router.push('/view-development-areas');
} else {
setError(result.error || 'Failed to delete observation');
setShowDeleteConfirm(false);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
setShowDeleteConfirm(false);
} finally {
setIsDeleting(false);
}
};
const addIndicator = () => {
setIndicators([
...indicators,
{
indicator: '',
developmentAreas: [
{
learningArea: '',
subLearningArea: '',
framework: '',
},
],
},
]);
};
const updateIndicator = (index: number, field: string, value: string) => {
const updatedIndicators = [...indicators];
if (field === 'indicator') {
updatedIndicators[index].indicator = value;
} else {
(updatedIndicators[index].developmentAreas[0] as any)[field] = value;
// Clear dependent fields when framework changes
if (field === 'framework') {
updatedIndicators[index].developmentAreas[0].learningArea = '';
updatedIndicators[index].developmentAreas[0].subLearningArea = '';
// Clear indicator selection since it's no longer valid
updatedIndicators[index].indicator = '';
}
// Clear sub-learning area and indicator when learning area changes
if (field === 'learningArea') {
updatedIndicators[index].developmentAreas[0].subLearningArea = '';
// Clear indicator selection since it's no longer valid
updatedIndicators[index].indicator = '';
}
// Clear indicator when sub-learning area changes
if (field === 'subLearningArea') {
// Clear indicator selection since it's no longer valid
updatedIndicators[index].indicator = '';
}
}
setIndicators(updatedIndicators);
};
const removeIndicator = (index: number) => {
setIndicators(indicators.filter((_, i) => i !== index));
};
const getImageUrl = (asset: string) => {
const filename = asset.split('/').pop();
return `/api/proxy-image?path=${filename}`;
};
if (loading) {
return (
<Layout>
<div className="flex items-center justify-center py-16">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading observation...</p>
</div>
</div>
</Layout>
);
}
if (error && !observation) {
return (
<Layout>
<div className="flex items-center justify-center py-16">
<div className="text-center">
<p className="text-red-600 text-lg mb-4">Error: {error}</p>
<Link href="/view-development-areas" className="text-blue-600 hover:text-blue-800">
Back to Development Areas
</Link>
</div>
</div>
</Layout>
);
}
return (
<Layout>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="bg-white rounded-lg shadow-sm border border-slate-200 p-4 mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center">
<Link href="/view-development-areas" className="text-blue-600 hover:text-blue-800 mr-4">
Back to Development Areas
</Link>
<h1 className="text-xl font-semibold text-gray-900">
Edit Observation
</h1>
</div>
<div className="flex space-x-3">
<button
onClick={() => setShowDeleteConfirm(true)}
disabled={isDeleting}
className={`px-4 py-2 rounded-md text-sm font-medium ${
isDeleting
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-red-600 text-white hover:bg-red-700'
}`}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
<button
onClick={handleSave}
disabled={saving}
className={`px-4 py-2 rounded-md text-sm font-medium ${
saving
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</div>
{/* Success/Error Messages */}
{success && (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-4">
<div className="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded">
{success}
</div>
</div>
)}
{error && (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-4">
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded">
{error}
</div>
</div>
)}
{/* Main Content */}
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{observation && (
<div className="space-y-8">
{/* Image Section */}
{observation.assets && observation.assets.length > 0 && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Images</h2>
<div className="space-y-4">
{observation.assets.map((asset, index) => (
<div key={index} className="relative bg-gray-100 rounded-lg overflow-auto max-h-96">
<img
src={getImageUrl(asset)}
alt={`Observation image ${index + 1}`}
className="w-full h-auto object-contain"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = `https://via.placeholder.com/400x300?text=Image+Not+Found`;
}}
/>
</div>
))}
</div>
</div>
)}
{/* Basic Information */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Basic Information</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Title
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Remarks
</label>
<textarea
value={remarks}
onChange={(e) => setRemarks(e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900"
/>
</div>
</div>
</div>
{/* Indicators Section */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Development Indicators</h2>
<button
onClick={addIndicator}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-medium"
>
Add Indicator
</button>
</div>
{indicators.length === 0 ? (
<p className="text-gray-500 text-center py-8">
No indicators added yet. Click "Add Indicator" to add one.
</p>
) : (
<div className="space-y-6">
{indicators.map((indicator, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-md font-medium text-gray-900">Indicator {index + 1}</h3>
<button
onClick={() => removeIndicator(index)}
className="text-red-600 hover:text-red-800 text-sm"
>
Remove
</button>
</div>
<div className="space-y-3">
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Framework
</label>
<select
value={indicator.developmentAreas[0]?.framework || ''}
onChange={(e) => updateIndicator(index, 'framework', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900"
>
<option value="">Select Framework</option>
{allFrameworks.map(framework => (
<option key={framework} value={framework}>{framework}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Learning Area
</label>
<select
value={indicator.developmentAreas[0]?.learningArea || ''}
onChange={(e) => updateIndicator(index, 'learningArea', e.target.value)}
disabled={!indicator.developmentAreas[0]?.framework}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed"
>
<option value="">Select Learning Area</option>
{(allLearningAreas[index] || []).map((area: string, areaIdx: number) => (
<option key={`${area}-${areaIdx}`} value={area}>{area}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Sub Learning Area
</label>
<select
value={indicator.developmentAreas[0]?.subLearningArea || ''}
onChange={(e) => updateIndicator(index, 'subLearningArea', e.target.value)}
disabled={!indicator.developmentAreas[0]?.learningArea}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed"
>
<option value="">Select Sub Learning Area</option>
{(allSubLearningAreas[index] || []).map((area: string, subAreaIdx: number) => (
<option key={`${area}-${subAreaIdx}`} value={area}>{area}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Indicator Description
</label>
<select
value={indicator.indicator}
onChange={(e) => updateIndicator(index, 'indicator', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900"
>
<option value="">Select Indicator</option>
{(allIndicators[index] || []).map((indicatorDesc: string, idx: number) => (
<option key={`${index}-${idx}`} value={indicatorDesc}>{indicatorDesc}</option>
))}
</select>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Metadata */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Metadata</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium text-gray-700">ID:</span>
<span className="ml-2 text-gray-600">{observation._id}</span>
</div>
<div>
<span className="font-medium text-gray-700">Created:</span>
<span className="ml-2 text-gray-600">
{new Date(observation.createdAt).toLocaleString()}
</span>
</div>
<div>
<span className="font-medium text-gray-700">Last Updated:</span>
<span className="ml-2 text-gray-600">
{new Date(observation.updatedAt).toLocaleString()}
</span>
</div>
{observation.grade && observation.grade.length > 0 && (
<div>
<span className="font-medium text-gray-700">Grade:</span>
<span className="ml-2 text-gray-600">{observation.grade[0].grade}</span>
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 max-w-md mx-4">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Confirm Delete</h2>
<p className="text-gray-600 mb-6">
Are you sure you want to delete this observation? This action cannot be undone.
</p>
<div className="flex space-x-3">
<button
onClick={handleDelete}
disabled={isDeleting}
className={`px-4 py-2 rounded-md text-sm font-medium ${
isDeleting
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-red-600 text-white hover:bg-red-700'
}`}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</button>
<button
onClick={() => setShowDeleteConfirm(false)}
disabled={isDeleting}
className="px-4 py-2 bg-gray-200 text-gray-800 rounded-md hover:bg-gray-300 text-sm font-medium"
>
Cancel
</button>
</div>
</div>
</div>
)}
</Layout>
);
}

View File

@@ -0,0 +1,397 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import Image from 'next/image';
import Link from 'next/link';
import Layout from '@/components/Layout';
interface DevelopmentArea {
learningArea: string;
subLearningArea: string;
framework: string;
}
interface Indicator {
indicator: string;
developmentAreas: DevelopmentArea[];
}
interface Observation {
_id: string;
title: string;
remarks: string;
assets: string[];
indicators: Indicator[];
createdAt: string;
updatedAt: string;
grade?: Array<{
grade: string;
}>;
}
export default function EditObservation() {
const params = useParams();
const router = useRouter();
const id = params.id as string;
const [observation, setObservation] = useState<Observation | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
// Form state
const [title, setTitle] = useState('');
const [remarks, setRemarks] = useState('');
const [indicators, setIndicators] = useState<Indicator[]>([]);
useEffect(() => {
if (id) {
fetchObservation();
}
}, [id]);
const fetchObservation = async () => {
try {
setLoading(true);
const response = await fetch(`/api/get-observation/${id}`);
const data = await response.json();
if (data.success) {
setObservation(data.data);
setTitle(data.data.title || '');
setRemarks(data.data.remarks || '');
setIndicators(data.data.indicators || []);
} else {
setError('Failed to fetch observation');
}
} catch (err) {
setError('Error fetching observation');
console.error('Error:', err);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
try {
setSaving(true);
setSuccess(null);
setError(null);
const response = await fetch(`/api/update-observation/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title,
remarks,
indicators,
}),
});
const data = await response.json();
if (data.success) {
setSuccess('Observation updated successfully!');
// Update local state
setObservation(data.data);
} else {
setError(data.message || 'Failed to update observation');
}
} catch (err) {
setError('Error updating observation');
console.error('Error:', err);
} finally {
setSaving(false);
}
};
const addIndicator = () => {
setIndicators([...indicators, {
indicator: '',
developmentAreas: [{
learningArea: '',
subLearningArea: '',
framework: 'EYFS'
}]
}]);
};
const updateIndicator = (index: number, field: string, value: string) => {
const updatedIndicators = [...indicators];
if (field === 'indicator') {
updatedIndicators[index].indicator = value;
} else {
// Type assertion to handle dynamic field access
(updatedIndicators[index].developmentAreas[0] as any)[field] = value;
}
setIndicators(updatedIndicators);
};
const removeIndicator = (index: number) => {
setIndicators(indicators.filter((_, i) => i !== index));
};
const getImageUrl = (asset: string) => {
const filename = asset.split('/').pop();
return `/api/proxy-image?path=${filename}`;
};
if (loading) {
return (
<Layout>
<div className="flex items-center justify-center py-16">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Loading observation...</p>
</div>
</div>
</Layout>
);
}
if (error && !observation) {
return (
<Layout>
<div className="flex items-center justify-center py-16">
<div className="text-center">
<p className="text-red-600 text-lg mb-4">Error: {error}</p>
<Link href="/view-development-areas" className="text-blue-600 hover:text-blue-800">
← Back to Development Areas
</Link>
</div>
</div>
</Layout>
);
}
return (
<Layout>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="bg-white rounded-lg shadow-sm border border-slate-200 p-4 mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center">
<Link href="/view-development-areas" className="text-blue-600 hover:text-blue-800 mr-4">
← Back to Development Areas
</Link>
<h1 className="text-xl font-semibold text-gray-900">
Edit Observation
</h1>
</div>
<div className="flex space-x-3">
<button
onClick={handleSave}
disabled={saving}
className={`px-4 py-2 rounded-md text-sm font-medium ${
saving
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</div>
</div>
{/* Success/Error Messages */}
{success && (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-4">
<div className="bg-green-50 border border-green-200 text-green-800 px-4 py-3 rounded">
{success}
</div>
</div>
)}
{error && (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-4">
<div className="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded">
{error}
</div>
</div>
)}
{/* Main Content */}
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{observation && (
<div className="space-y-8">
{/* Image Section */}
{observation.assets && observation.assets.length > 0 && (
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Images</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{observation.assets.map((asset, index) => (
<div key={index} className="relative h-48 bg-gray-200 rounded-lg overflow-hidden">
<Image
src={getImageUrl(asset)}
alt={`Observation image ${index + 1}`}
fill
className="object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = `https://via.placeholder.com/400x300?text=Image+Not+Found`;
}}
/>
</div>
))}
</div>
</div>
)}
{/* Basic Information */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Basic Information</h2>
<div className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
Title
</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label htmlFor="remarks" className="block text-sm font-medium text-gray-700 mb-1">
Remarks
</label>
<textarea
id="remarks"
value={remarks}
onChange={(e) => setRemarks(e.target.value)}
rows={4}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
</div>
{/* Development Indicators */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900">Development Indicators</h2>
<button
onClick={addIndicator}
className="px-3 py-1 bg-green-600 text-white text-sm rounded hover:bg-green-700"
>
+ Add Indicator
</button>
</div>
<div className="space-y-4">
{indicators.map((indicator, index) => (
<div key={index} className="border border-gray-200 rounded-lg p-4">
<div className="flex justify-between items-start mb-3">
<h3 className="text-sm font-medium text-gray-700">Indicator {index + 1}</h3>
<button
onClick={() => removeIndicator(index)}
className="text-red-600 hover:text-red-800 text-sm"
>
Remove
</button>
</div>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Indicator Description
</label>
<textarea
value={indicator.indicator}
onChange={(e) => updateIndicator(index, 'indicator', e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{indicator.developmentAreas && indicator.developmentAreas.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Learning Area
</label>
<input
type="text"
value={indicator.developmentAreas[0].learningArea}
onChange={(e) => updateIndicator(index, 'learningArea', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Sub Learning Area
</label>
<input
type="text"
value={indicator.developmentAreas[0].subLearningArea}
onChange={(e) => updateIndicator(index, 'subLearningArea', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Framework
</label>
<input
type="text"
value={indicator.developmentAreas[0].framework}
onChange={(e) => updateIndicator(index, 'framework', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
)}
</div>
</div>
))}
{indicators.length === 0 && (
<p className="text-gray-500 text-center py-4">
No indicators added yet. Click "Add Indicator" to add one.
</p>
)}
</div>
</div>
{/* Metadata */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Metadata</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium text-gray-700">ID:</span>
<span className="ml-2 text-gray-600">{observation._id}</span>
</div>
<div>
<span className="font-medium text-gray-700">Created:</span>
<span className="ml-2 text-gray-600">
{new Date(observation.createdAt).toLocaleString()}
</span>
</div>
<div>
<span className="font-medium text-gray-700">Last Updated:</span>
<span className="ml-2 text-gray-600">
{new Date(observation.updatedAt).toLocaleString()}
</span>
</div>
{observation.grade && observation.grade.length > 0 && (
<div>
<span className="font-medium text-gray-700">Grade:</span>
<span className="ml-2 text-gray-600">{observation.grade[0].grade}</span>
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
</Layout>
);
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

26
src/app/globals.css Normal file
View File

@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -0,0 +1,180 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function ImportJsonPage() {
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [result, setResult] = useState<any>(null);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile && selectedFile.type === 'application/json') {
setFile(selectedFile);
setError(null);
} else if (selectedFile) {
setError('Please select a valid JSON file');
setFile(null);
}
};
const handleImport = async (e: React.FormEvent) => {
e.preventDefault();
if (!file) {
setError('Please select a JSON file');
return;
}
setLoading(true);
setError(null);
setResult(null);
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/import-json', {
method: 'POST',
body: formData,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'Import failed');
}
setResult(data);
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md mx-auto bg-white rounded-lg shadow-md p-6">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 text-center">
Upload JSON to MongoDB
</h1>
<p className="mt-2 text-gray-600 text-center">
Upload observation data from JSON file into MongoDB database
</p>
</div>
<form onSubmit={handleImport} className="space-y-6">
<div>
<label htmlFor="file" className="block text-sm font-medium text-gray-700 mb-2">
JSON File
</label>
<div className="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md hover:border-gray-400 transition-colors">
<div className="space-y-1 text-center">
<svg
className="mx-auto h-12 w-12 text-gray-400"
stroke="currentColor"
fill="none"
viewBox="0 0 48 48"
aria-hidden="true"
>
<path
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div className="flex text-sm text-gray-600">
<label
htmlFor="file"
className="relative cursor-pointer bg-white rounded-md font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-blue-500"
>
<span>Upload a file</span>
<input
id="file"
name="file"
type="file"
className="sr-only"
accept=".json,application/json"
onChange={handleFileChange}
/>
</label>
<p className="pl-1">or drag and drop</p>
</div>
<p className="text-xs text-gray-500">JSON files only</p>
</div>
</div>
{file && (
<div className="mt-2 text-sm text-gray-600">
Selected: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(2)} KB)
</div>
)}
</div>
<div>
<button
type="submit"
disabled={loading || !file}
className={`w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white ${
loading || !file
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500'
}`}
>
{loading ? 'Importing...' : 'Import JSON'}
</button>
</div>
</form>
{error && (
<div className="mt-6 p-4 bg-red-50 border border-red-200 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Import Failed</h3>
<div className="mt-2 text-sm text-red-700">{error}</div>
</div>
</div>
</div>
)}
{result && (
<div className="mt-6 p-4 bg-green-50 border border-green-200 rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-green-800">Import Successful</h3>
<div className="mt-2 text-sm text-green-700">
<p>{result.message}</p>
<p className="mt-1">Documents inserted: {result.inserted_count}</p>
</div>
</div>
</div>
</div>
)}
<div className="mt-8">
<button
onClick={() => router.push('/')}
className="w-full flex justify-center py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Back to Home
</button>
</div>
</div>
</div>
);
}

34
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

128
src/app/page.tsx Normal file
View File

@@ -0,0 +1,128 @@
import Image from "next/image";
import Link from "next/link";
import Layout from "@/components/Layout";
export default function Home() {
return (
<Layout>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
<div className="text-center">
<div className="max-w-4xl mx-auto">
{/* Hero Section */}
<div className="mb-16">
<h1 className="text-5xl font-bold text-slate-900 mb-6 leading-tight">
SiliconPin Data Classifier
</h1>
<p className="text-xl text-slate-600 mb-8 leading-relaxed max-w-3xl mx-auto">
Annotate and modify data before sending to AI for retraining.
Ensure your AI receives clean, correctly structured data for optimal performance.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
<Link
className="inline-flex items-center justify-center px-8 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-colors shadow-lg"
href="/import-json"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
Import JSON Data
</Link>
<Link
className="inline-flex items-center justify-center px-8 py-3 bg-white text-slate-700 font-semibold rounded-lg border border-slate-300 hover:bg-slate-50 transition-colors shadow-lg"
href="/view-development-areas"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
View & Edit Data
</Link>
</div>
</div>
{/* Features Grid */}
<div className="grid md:grid-cols-3 gap-8 mb-16">
<div className="bg-white p-6 rounded-xl shadow-sm border border-slate-200">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mb-4 mx-auto">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">Data Import</h3>
<p className="text-slate-600">
Seamlessly import JSON observation data into MongoDB for processing and classification.
</p>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm border border-slate-200">
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mb-4 mx-auto">
<svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">Data Annotation</h3>
<p className="text-slate-600">
Modify and annotate observation data to ensure clean, structured input for AI training.
</p>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm border border-slate-200">
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center mb-4 mx-auto">
<svg className="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-slate-900 mb-2">AI Training Ready</h3>
<p className="text-slate-600">
Export clean, structured data optimized for AI model retraining and improvement.
</p>
</div>
</div>
{/* Workflow Section */}
<div className="bg-white p-8 rounded-xl shadow-sm border border-slate-200">
<h2 className="text-2xl font-bold text-slate-900 mb-6">How It Works</h2>
<div className="grid md:grid-cols-4 gap-6">
<div className="text-center">
<div className="w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold mb-3 mx-auto">
1
</div>
<h4 className="font-semibold text-slate-900 mb-2">Import Data</h4>
<p className="text-sm text-slate-600">
Upload JSON files containing observation data
</p>
</div>
<div className="text-center">
<div className="w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold mb-3 mx-auto">
2
</div>
<h4 className="font-semibold text-slate-900 mb-2">Review & Edit</h4>
<p className="text-sm text-slate-600">
View and modify data in the card-based interface
</p>
</div>
<div className="text-center">
<div className="w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold mb-3 mx-auto">
3
</div>
<h4 className="font-semibold text-slate-900 mb-2">Annotate</h4>
<p className="text-sm text-slate-600">
Add development areas and indicators
</p>
</div>
<div className="text-center">
<div className="w-10 h-10 bg-blue-600 text-white rounded-full flex items-center justify-center font-bold mb-3 mx-auto">
4
</div>
<h4 className="font-semibold text-slate-900 mb-2">Export for AI</h4>
<p className="text-sm text-slate-600">
Clean data ready for AI model training
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</Layout>
);
}

View File

@@ -0,0 +1,366 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Link from 'next/link';
import Layout from '@/components/Layout';
// ─── Types ────────────────────────────────────────────────────────────────────
interface DevelopmentArea {
learningArea: string;
subLearningArea: string;
framework: string;
}
interface Indicator {
indicator: string;
developmentAreas: DevelopmentArea[];
}
interface Observation {
_id: string;
title: string;
remarks: string;
assets: string[];
indicators: Indicator[];
createdAt: string;
grade?: Array<{ grade: string }>;
}
interface PaginationInfo {
currentPage: number;
totalPages: number;
totalCount: number;
limit: number;
hasNextPage: boolean;
hasPrevPage: boolean;
}
interface ApiResponse {
success: boolean;
data: Observation[];
pagination: PaginationInfo;
error?: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getPageNumbers(currentPage: number, totalPages: number): (number | string)[] {
const delta = 2;
const range: (number | string)[] = [1];
const start = Math.max(2, currentPage - delta);
const end = Math.min(totalPages - 1, currentPage + delta);
if (start > 2) range.push('...');
for (let i = start; i <= end; i++) range.push(i);
if (end < totalPages - 1) range.push('...');
if (totalPages > 1) range.push(totalPages);
return range.filter((item, idx, arr) => item !== arr[idx - 1]);
}
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
}
function getImageUrl(asset: string) {
const filename = asset?.split('/').pop();
return `/api/proxy-image?path=${filename}`;
}
const FALLBACK_IMG =
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgZmlsbD0iI2UwZTBlMCIvPjx0ZXh0IHg9IjUwIiB5PSI1MCIgZm9udC1mYW1pbHk9IkFyaWFsIiBmb250LXNpemU9IjE0IiBmaWxsPSIjOTk5IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkeT0iLjNlbSI+SW1hZ2U8L3RleHQ+PC9zdmc+';
// ─── Component ────────────────────────────────────────────────────────────────
export default function ViewDevelopmentAreas() {
const [observations, setObservations] = useState<Observation[]>([]);
const [pagination, setPagination] = useState<PaginationInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(12);
const [frameworkFilter, setFrameworkFilter] = useState('');
const [learningAreaFilter, setLearningAreaFilter] = useState('');
const [subLearningAreaFilter, setSubLearningAreaFilter] = useState('');
const [allFrameworks, setAllFrameworks] = useState<string[]>([]);
const [allLearningAreas, setAllLearningAreas] = useState<string[]>([]);
const [allSubLearningAreas, setAllSubLearningAreas] = useState<string[]>([]);
// ── ONE fetch function — passes filters as server-side query params ──────────
const fetchObservations = useCallback(async (page = currentPage) => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ page: String(page), limit: String(pageSize) });
if (frameworkFilter) params.set('framework', frameworkFilter);
if (learningAreaFilter) params.set('learningArea', learningAreaFilter);
if (subLearningAreaFilter) params.set('subLearningArea', subLearningAreaFilter);
const res = await fetch(`/api/get-observations?${params}`);
const data: ApiResponse = await res.json();
if (data.success) {
setObservations(data.data);
setPagination(data.pagination);
} else {
setError(data.error || 'Failed to fetch observations');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'An error occurred');
} finally {
setLoading(false);
}
}, [currentPage, pageSize, frameworkFilter, learningAreaFilter, subLearningAreaFilter]);
useEffect(() => { fetchObservations(currentPage); }, [currentPage, pageSize, frameworkFilter, learningAreaFilter, subLearningAreaFilter]);
// ── Load dropdown options ────────────────────────────────────────────────────
useEffect(() => {
fetch('/api/frameworks')
.then(r => r.json())
.then(d => d.success && setAllFrameworks(d.data.frameworks))
.catch(console.error);
}, []);
useEffect(() => {
if (!frameworkFilter) { setAllLearningAreas([]); setLearningAreaFilter(''); return; }
fetch(`/api/learning-areas?framework=${encodeURIComponent(frameworkFilter)}`)
.then(r => r.json())
.then(d => d.success && setAllLearningAreas(d.data.learningAreas))
.catch(console.error);
}, [frameworkFilter]);
useEffect(() => {
if (!frameworkFilter || !learningAreaFilter) { setAllSubLearningAreas([]); setSubLearningAreaFilter(''); return; }
fetch(`/api/sub-learning-areas?framework=${encodeURIComponent(frameworkFilter)}&learningArea=${encodeURIComponent(learningAreaFilter)}`)
.then(r => r.json())
.then(d => d.success && setAllSubLearningAreas(d.data.subLearningAreas))
.catch(console.error);
}, [frameworkFilter, learningAreaFilter]);
// ── Handlers ────────────────────────────────────────────────────────────────
const handlePageChange = (page: number) => {
if (!pagination || page < 1 || page > pagination.totalPages) return;
setCurrentPage(page);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const clearFilters = () => {
setFrameworkFilter('');
setLearningAreaFilter('');
setSubLearningAreaFilter('');
setCurrentPage(1);
};
// ── Loading / Error ──────────────────────────────────────────────────────────
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto" />
<p className="mt-4 text-gray-600">Loading observations...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<p className="text-red-600 text-lg mb-4">Error: {error}</p>
<button onClick={() => fetchObservations()} className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Try Again
</button>
</div>
</div>
);
}
// ── Render ───────────────────────────────────────────────────────────────────
return (
<Layout>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="bg-white rounded-lg shadow-sm border border-slate-200 p-4 mb-6 flex items-center justify-between">
<h1 className="text-xl font-semibold text-gray-900">Development Areas View</h1>
<div className="flex items-center space-x-2">
<label htmlFor="page-size" className="text-sm text-gray-600">Show:</label>
<select
id="page-size"
value={pageSize}
onChange={e => { setPageSize(Number(e.target.value)); setCurrentPage(1); }}
className="text-sm border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{[6, 12, 24, 48].map(n => <option key={n} value={n}>{n}</option>)}
</select>
<span className="text-sm text-gray-600">per page</span>
</div>
</div>
{/* Filters */}
<div className="bg-white rounded-lg shadow-sm border border-slate-200 p-4 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Filters</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label htmlFor="framework" className="block text-sm font-medium text-gray-700 mb-1">Framework</label>
<select
id="framework"
value={frameworkFilter}
onChange={e => { setFrameworkFilter(e.target.value); setLearningAreaFilter(''); setSubLearningAreaFilter(''); setCurrentPage(1); }}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900"
>
<option value="">All Frameworks</option>
{allFrameworks.map(f => <option key={f} value={f}>{f}</option>)}
</select>
</div>
<div>
<label htmlFor="learning-area" className="block text-sm font-medium text-gray-700 mb-1">Learning Area</label>
<select
id="learning-area"
value={learningAreaFilter}
onChange={e => { setLearningAreaFilter(e.target.value); setSubLearningAreaFilter(''); setCurrentPage(1); }}
disabled={!frameworkFilter}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed"
>
<option value="">All Learning Areas</option>
{allLearningAreas.map(a => <option key={a} value={a}>{a}</option>)}
</select>
</div>
<div>
<label htmlFor="sub-learning-area" className="block text-sm font-medium text-gray-700 mb-1">Sub Learning Area</label>
<select
id="sub-learning-area"
value={subLearningAreaFilter}
onChange={e => { setSubLearningAreaFilter(e.target.value); setCurrentPage(1); }}
disabled={!learningAreaFilter}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 text-gray-900 disabled:bg-gray-100 disabled:cursor-not-allowed"
>
<option value="">All Sub Learning Areas</option>
{allSubLearningAreas.map(a => <option key={a} value={a}>{a}</option>)}
</select>
</div>
</div>
<div className="mt-4">
<button onClick={clearFilters} className="px-4 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700 text-sm font-medium">
Clear Filters
</button>
</div>
</div>
{/* Results */}
{observations.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 text-lg">No observations found</p>
</div>
) : (
<>
{pagination && (
<div className="bg-white rounded-lg shadow-sm border border-slate-200 p-4 mb-6 text-sm text-gray-500">
Showing {((pagination.currentPage - 1) * pagination.limit) + 1}
{Math.min(pagination.currentPage * pagination.limit, pagination.totalCount)} of {pagination.totalCount} observations
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{observations.map((obs, index) => (
<Link key={`${obs._id}-${index}`} href={`/edit-observation/${obs._id}`} className="block">
<div className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg hover:scale-105 transform transition-all duration-200 cursor-pointer">
<div className="relative h-48 bg-gray-200">
<img
src={getImageUrl(obs.assets?.[0])}
alt={obs.title}
className="w-full h-full object-cover"
onError={e => { e.currentTarget.src = FALLBACK_IMG; }}
/>
<div className="absolute inset-0 bg-black bg-opacity-40 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity">
<span className="text-white font-medium">Click to Edit</span>
</div>
{obs.grade?.[0] && (
<div className="absolute top-2 right-2">
<span className="bg-green-600 text-white text-xs px-2 py-1 rounded-full">{obs.grade[0].grade}</span>
</div>
)}
</div>
<div className="p-4">
<h3 className="font-semibold text-lg text-gray-900 mb-1">{obs.title || 'Untitled Observation'}</h3>
<p className="text-sm text-gray-500 mb-2">{formatDate(obs.createdAt)}</p>
{obs.remarks && <p className="text-gray-600 text-sm mb-3 line-clamp-2">{obs.remarks}</p>}
{obs.indicators?.length > 0 && (
<div className="space-y-2">
<h4 className="font-medium text-sm text-gray-700">Development Areas:</h4>
{obs.indicators.slice(0, 2).map((ind, idx) => (
<div key={idx} className="text-xs">
<p className="text-gray-600 mb-1 line-clamp-2">{ind.indicator}</p>
{ind.developmentAreas?.length > 0 && (
<div className="flex flex-wrap gap-1">
{ind.developmentAreas.map((area, aIdx) => (
<span key={aIdx} className="bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded">{area.learningArea}</span>
))}
</div>
)}
</div>
))}
{obs.indicators.length > 2 && (
<p className="text-xs text-gray-500">+{obs.indicators.length - 2} more indicators</p>
)}
</div>
)}
</div>
</div>
</Link>
))}
</div>
{pagination && pagination.totalPages > 1 && (
<div className="mt-8 flex justify-center">
<div className="flex items-center space-x-2">
<button
onClick={() => handlePageChange(pagination.currentPage - 1)}
disabled={!pagination.hasPrevPage}
className={`px-3 py-2 rounded-md text-sm font-medium ${pagination.hasPrevPage ? 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50' : 'bg-gray-100 text-gray-400 cursor-not-allowed'}`}
>
Previous
</button>
<div className="flex space-x-1">
{getPageNumbers(pagination.currentPage, pagination.totalPages).map((pageNum, idx) =>
pageNum === '...' ? (
<span key={`ellipsis-${idx}`} className="px-3 py-2 text-gray-500">...</span>
) : (
<button
key={pageNum}
onClick={() => handlePageChange(pageNum as number)}
className={`px-3 py-2 rounded-md text-sm font-medium ${pageNum === pagination.currentPage ? 'bg-blue-600 text-white' : 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50'}`}
>
{pageNum}
</button>
)
)}
</div>
<button
onClick={() => handlePageChange(pagination.currentPage + 1)}
disabled={!pagination.hasNextPage}
className={`px-3 py-2 rounded-md text-sm font-medium ${pagination.hasNextPage ? 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50' : 'bg-gray-100 text-gray-400 cursor-not-allowed'}`}
>
Next
</button>
</div>
</div>
)}
</>
)}
</div>
</Layout>
);
}

83
src/components/Footer.tsx Normal file
View File

@@ -0,0 +1,83 @@
import Link from "next/link";
export default function Footer() {
return (
<footer className="bg-slate-900 text-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div className="grid md:grid-cols-4 gap-8">
<div className="col-span-2">
<div className="flex items-center space-x-2 mb-4">
<div className="w-8 h-8 bg-gradient-to-br from-blue-600 to-purple-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">SP</span>
</div>
<span className="text-xl font-bold">SiliconPin</span>
</div>
<p className="text-slate-400 mb-4 max-w-md">
Advanced data classification platform for AI training. Transform raw observation data into clean, structured datasets for optimal machine learning performance.
</p>
<div className="flex space-x-4">
<a href="https://siliconpin.com" target="_blank" rel="noopener noreferrer" className="text-slate-400 hover:text-white transition-colors">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.17 6.839 9.49.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369 1.343-3.369 1.343-.452 1.043-1.119 1.343-1.343.18.31.28.672.28 1.065 0 .019 0 .12-.008.179-.015-.41.295-.762.75-1.091 1.022A4.92 4.92 0 0112 19.5c-3.308 0-6-2.692-6-6s2.692-6 6-6 6 2.692 6 6c.99 0 1.916-.255 2.75-.7a4.92 4.92 0 001.5-1.022c.32-.272.58-.727.76-1.022.007.059.015.119.015.179 0 .266-.182.393-.483.683-.483.835 0 1.835.005 1.703.013 1.703-.18.31-.503.617-.683 1.02C19.135 17.825 22 14.42 22 12c0-5.523-4.477-10-10-10z"/>
</svg>
</a>
<a href="#" className="text-slate-400 hover:text-white transition-colors">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 006.675 2.493c.086 0 .173-.01.255-.015a8.348 8.348 0 006.432-7.744c0-.18-.005-.362-.013-.54A8.372 8.372 0 0022 10.292a8.236 8.236 0 01-2.416.662z"/>
</svg>
</a>
</div>
</div>
<div>
<h3 className="font-semibold text-white mb-4">Product</h3>
<ul className="space-y-2">
<li>
<Link href="/import-json" className="text-slate-400 hover:text-white transition-colors">
Data Import
</Link>
</li>
<li>
<Link href="/view-development-areas" className="text-slate-400 hover:text-white transition-colors">
Data Viewer
</Link>
</li>
<li>
<Link href="/download-images" className="text-slate-400 hover:text-white transition-colors">
Image Manager
</Link>
</li>
</ul>
</div>
<div>
<h3 className="font-semibold text-white mb-4">Resources</h3>
<ul className="space-y-2">
<li>
<a href="https://siliconpin.com/docs" target="_blank" rel="noopener noreferrer" className="text-slate-400 hover:text-white transition-colors">
Documentation
</a>
</li>
<li>
<a href="https://siliconpin.com/api" target="_blank" rel="noopener noreferrer" className="text-slate-400 hover:text-white transition-colors">
API Reference
</a>
</li>
<li>
<a href="https://siliconpin.com/support" target="_blank" rel="noopener noreferrer" className="text-slate-400 hover:text-white transition-colors">
Support
</a>
</li>
</ul>
</div>
</div>
<div className="border-t border-slate-800 mt-8 pt-8 text-center">
<p className="text-slate-400">
© 2026 SiliconPin Data Classifier. All rights reserved.
</p>
</div>
</div>
</footer>
);
}

34
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,34 @@
import Link from "next/link";
export default function Header() {
return (
<header className="bg-white shadow-sm border-b border-slate-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center space-x-3">
{/* Logo */}
<Link href="/" className="flex items-center space-x-2">
<div className="w-8 h-8 bg-gradient-to-br from-blue-600 to-purple-600 rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">SP</span>
</div>
<span className="text-xl font-bold text-slate-900">SiliconPin</span>
</Link>
<span className="text-slate-500">|</span>
<span className="text-slate-600 font-medium">Data Classifier</span>
</div>
<nav className="hidden md:flex space-x-8">
<Link href="/" className="text-slate-600 hover:text-slate-900 font-medium">
Home
</Link>
<Link href="/view-development-areas" className="text-slate-600 hover:text-slate-900 font-medium">
View Data
</Link>
<Link href="/data" className="text-slate-600 hover:text-slate-900 font-medium">
Data
</Link>
</nav>
</div>
</div>
</header>
);
}

18
src/components/Layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
import Header from './Header';
import Footer from './Footer';
interface LayoutProps {
children: React.ReactNode;
}
export default function Layout({ children }: LayoutProps) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col">
<Header />
<main className="flex-1">
{children}
</main>
<Footer />
</div>
);
}