v1
This commit is contained in:
187
src/app/api/compress-image/route.ts
Normal file
187
src/app/api/compress-image/route.ts
Normal 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
|
||||
});
|
||||
}
|
||||
42
src/app/api/data-structure/route.ts
Normal file
42
src/app/api/data-structure/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
95
src/app/api/delete-observation/[id]/route.ts
Normal file
95
src/app/api/delete-observation/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
54
src/app/api/frameworks/route.ts
Normal file
54
src/app/api/frameworks/route.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
268
src/app/api/get-images-from-s3/route.ts
Normal file
268
src/app/api/get-images-from-s3/route.ts
Normal 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" })'
|
||||
}
|
||||
});
|
||||
}
|
||||
74
src/app/api/get-observation/[id]/route.ts
Normal file
74
src/app/api/get-observation/[id]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
73
src/app/api/get-observations/route.ts
Normal file
73
src/app/api/get-observations/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
188
src/app/api/import-json/route.ts
Normal file
188
src/app/api/import-json/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
85
src/app/api/indicators-by-filter/route.ts
Normal file
85
src/app/api/indicators-by-filter/route.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
50
src/app/api/indicators/route.ts
Normal file
50
src/app/api/indicators/route.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/app/api/learning-areas/route.ts
Normal file
63
src/app/api/learning-areas/route.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
51
src/app/api/proxy-image/route.ts
Normal file
51
src/app/api/proxy-image/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
89
src/app/api/sub-learning-areas/route.ts
Normal file
89
src/app/api/sub-learning-areas/route.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
73
src/app/api/update-data-structure/route.ts
Normal file
73
src/app/api/update-data-structure/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
110
src/app/api/update-observation/[id]/route.ts
Normal file
110
src/app/api/update-observation/[id]/route.ts
Normal 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
196
src/app/data/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
137
src/app/download-images/page.tsx
Normal file
137
src/app/download-images/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
572
src/app/edit-observation/[id]/page.tsx
Normal file
572
src/app/edit-observation/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
397
src/app/edit-observation/[id]/page.tsx.backup
Normal file
397
src/app/edit-observation/[id]/page.tsx.backup
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
26
src/app/globals.css
Normal file
26
src/app/globals.css
Normal 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;
|
||||
}
|
||||
180
src/app/import-json/page.tsx
Normal file
180
src/app/import-json/page.tsx
Normal 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
34
src/app/layout.tsx
Normal 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
128
src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
366
src/app/view-development-areas/page.tsx
Normal file
366
src/app/view-development-areas/page.tsx
Normal 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
83
src/components/Footer.tsx
Normal 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
34
src/components/Header.tsx
Normal 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
18
src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user