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

5
.env.sample Normal file
View File

@@ -0,0 +1,5 @@
# MongoDB connection URI
MONGODB_URI=mongodb://localhost/beanstalk
# API domain for S3 file downloads
API_DOMAIN=https://app.example.tech

50
.gitignore vendored Normal file
View File

@@ -0,0 +1,50 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
/public/observations
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
# .env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

68
import-to-mongodb.js Normal file
View File

@@ -0,0 +1,68 @@
const { MongoClient } = require('mongodb');
const fs = require('fs');
// MongoDB connection string - update with your actual connection details
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost/beanstalk';
const DATABASE_NAME = 'beanstalk';
async function importJSONToMongoDB() {
const client = new MongoClient(MONGODB_URI);
try {
// Connect to MongoDB
await client.connect();
console.log('Connected to MongoDB');
const db = client.db(DATABASE_NAME);
// Import indicators
console.log('Importing indicators...');
const indicatorsData = JSON.parse(fs.readFileSync('./indicators.json', 'utf8'));
if (indicatorsData.data && Array.isArray(indicatorsData.data)) {
// Clear existing collection
await db.collection('indicators').deleteMany({});
console.log('Cleared existing indicators collection');
// Insert new data
const result = await db.collection('indicators').insertMany(indicatorsData.data);
console.log(`Inserted ${result.insertedCount} indicators`);
} else {
console.log('No valid data found in indicators.json');
}
// Import learning areas
console.log('Importing learning areas...');
const learningAreasData = JSON.parse(fs.readFileSync('./learning_areas.json', 'utf8'));
if (learningAreasData.data && Array.isArray(learningAreasData.data)) {
// Clear existing collection
await db.collection('learning_areas').deleteMany({});
console.log('Cleared existing learning_areas collection');
// Insert new data
const result = await db.collection('learning_areas').insertMany(learningAreasData.data);
console.log(`Inserted ${result.insertedCount} learning areas`);
} else {
console.log('No valid data found in learning_areas.json');
}
console.log('Import completed successfully!');
// Verify the import
const indicatorsCount = await db.collection('indicators').countDocuments();
const learningAreasCount = await db.collection('learning_areas').countDocuments();
console.log(`Verification: ${indicatorsCount} indicators in database`);
console.log(`Verification: ${learningAreasCount} learning areas in database`);
} catch (error) {
console.error('Error during import:', error);
} finally {
await client.close();
console.log('MongoDB connection closed');
}
}
// Run the import
importJSONToMongoDB();

1
indicators.json Normal file

File diff suppressed because one or more lines are too long

1
learning_areas.json Normal file

File diff suppressed because one or more lines are too long

24
next.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true,
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'k1-default1.siliconpin.net',
port: '',
pathname: '/**',
},
{
protocol: 'http',
hostname: '192.168.99.180',
port: '',
pathname: '/**',
},
],
},
};
export default nextConfig;

8177
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "observation-import",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"mongodb": "^7.1.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"sqlite3": "^5.1.7",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1,41 @@
{
"learningAreas": [
"Communication & Language",
"Expressive Arts & Design",
"Literacy",
"Mathematics",
"Personal, Social & Emotional Development",
"Physical Development",
"Physical Development & Aesthetics",
"Thinking Creatively & Critically",
"Understanding The World"
],
"subLearningAreas": [
"Being Imaginative & Expressive",
"Building Theory",
"Creating with materials",
"Fine Motor & Handwriting",
"Health & Self-Care",
"Listening & Attention",
"Making relationships",
"Moving & Handling",
"Numbers",
"People & Communities",
"Reading",
"Sense of self",
"Shapes, Patterns, Spacial Awareness & Measurements",
"Speaking",
"Technology",
"The World",
"Understanding",
"Understanding emotions",
"Writing"
],
"frameworks": [
"COEL",
"EYFS",
"KSPK"
],
"lastUpdated": "2026-02-23T10:21:54.935Z",
"totalObservations": 1232
}

26575
public/db/indicators.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,913 @@
[
{
"_id": "Personal, Social & Emotional Development",
"subLearningAreas": [
{
"_id": "664c3a3720040f27ff22d6b0",
"subLearningArea": "Making relationships"
},
{
"_id": "664c3a5120040f27ff22d6b4",
"subLearningArea": "Sense of self"
},
{
"_id": "664c3a6720040f27ff22d6b8",
"subLearningArea": "Understanding emotions"
}
]
},
{
"_id": "Communication & Language",
"subLearningAreas": [
{
"_id": "664ca2b68b8e6aea398e3071",
"subLearningArea": "Listening & Attention"
},
{
"_id": "664ca2c48b8e6aea398e3072",
"subLearningArea": "Understanding"
},
{
"_id": "664ca2cd8b8e6aea398e3073",
"subLearningArea": "Speaking"
}
]
},
{
"_id": "Physical Development",
"subLearningAreas": [
{
"_id": "664ca2e38b8e6aea398e3074",
"subLearningArea": "Moving & Handling"
},
{
"_id": "664ca2f18b8e6aea398e3075",
"subLearningArea": "Health & Self-Care"
}
]
},
{
"_id": "Literacy",
"subLearningAreas": [
{
"_id": "664ca3048b8e6aea398e3076",
"subLearningArea": "Reading"
},
{
"_id": "664ca30f8b8e6aea398e3077",
"subLearningArea": "Writing"
}
]
},
{
"_id": "Mathematics",
"subLearningAreas": [
{
"_id": "664ca31f8b8e6aea398e3078",
"subLearningArea": "Numbers"
},
{
"_id": "664ca3298b8e6aea398e3079",
"subLearningArea": "Shapes, Patterns, Spacial Awareness & Measurements"
},
{
"_id": "698d94aa4399c42eabf156e6",
"subLearningArea": "Language of mathematics"
},
{
"_id": "698d94d94399c42eabf156f7",
"subLearningArea": "Mathematical concepts: indirect preparation"
},
{
"_id": "698d95244399c42eabf15708",
"subLearningArea": "Quantities and symbols 1 to 10"
},
{
"_id": "698d95684399c42eabf1571b",
"subLearningArea": "Decimal system"
},
{
"_id": "698d95974399c42eabf1572a",
"subLearningArea": "Language of numbers larger than 10"
},
{
"_id": "698d95c44399c42eabf15739",
"subLearningArea": "Counting: continuation"
},
{
"_id": "698d95f74399c42eabf15746",
"subLearningArea": "Operations"
},
{
"_id": "698d96254399c42eabf15757",
"subLearningArea": "Expanding the decimal system: beyond 1000"
},
{
"_id": "698d965d4399c42eabf15768",
"subLearningArea": "Memorisation"
},
{
"_id": "698d96914399c42eabf15777",
"subLearningArea": "Geometry"
},
{
"_id": "698d96c74399c42eabf1578a",
"subLearningArea": "Algebra"
},
{
"_id": "698d96fe4399c42eabf1579b",
"subLearningArea": "Time and sequence"
},
{
"_id": "698d9d8a4399c42eabf162a0",
"subLearningArea": "Fractions"
}
]
},
{
"_id": "Understanding The World",
"subLearningAreas": [
{
"_id": "664ca3458b8e6aea398e307a",
"subLearningArea": "People & Communities"
},
{
"_id": "664ca3518b8e6aea398e307b",
"subLearningArea": "The World"
},
{
"_id": "664ca35d8b8e6aea398e307c",
"subLearningArea": "Technology"
}
]
},
{
"_id": "Expressive Arts & Design",
"subLearningAreas": [
{
"_id": "664ca3758b8e6aea398e307d",
"subLearningArea": "Creating with materials"
},
{
"_id": "664ca3858b8e6aea398e307e",
"subLearningArea": "Being Imaginative & Expressive"
}
]
},
{
"_id": "Playing & Exploring",
"subLearningAreas": [
{
"_id": "664ca3de8b8e6aea398e307f",
"subLearningArea": "Finding out & exploring"
},
{
"_id": "664ca3f08b8e6aea398e3080",
"subLearningArea": "Playing with what they know"
},
{
"_id": "664ca4038b8e6aea398e3081",
"subLearningArea": "Being willing to have a go"
},
{
"_id": "6655946632635c95f10b01b0"
}
]
},
{
"_id": "Active Learning",
"subLearningAreas": [
{
"_id": "664ca4258b8e6aea398e3082",
"subLearningArea": "Being involved & concentrating"
},
{
"_id": "664ca4358b8e6aea398e3083",
"subLearningArea": "Keep on trying"
},
{
"_id": "664ca4478b8e6aea398e3084",
"subLearningArea": "Enjoying achieving what they set out to do"
},
{
"_id": "6655949132635c95f10b01b8"
}
]
},
{
"_id": "Thinking Creatively & Critically",
"subLearningAreas": [
{
"_id": "664ca4618b8e6aea398e3085",
"subLearningArea": "Creative Thinking"
},
{
"_id": "664ca46d8b8e6aea398e3086",
"subLearningArea": "Building Theory"
},
{
"_id": "664ca4828b8e6aea398e3087",
"subLearningArea": "Critical Thinking"
},
{
"_id": "665594ba32635c95f10b01c0"
}
]
},
{
"_id": "",
"subLearningAreas": [
{
"_id": "665087b5c601c727f7706122",
"subLearningArea": ""
},
{
"_id": "698444ad4360b85ae983c298",
"subLearningArea": ""
}
]
},
{
"_id": "Physical & Motor Development",
"subLearningAreas": [
{
"_id": "68e79762c2f6370ebe59bd5b",
"subLearningArea": "Gross Motor"
},
{
"_id": "68e79786c2f6370ebe59bd64",
"subLearningArea": "Fine Motor"
},
{
"_id": "68e797b2c2f6370ebe59bd6d",
"subLearningArea": "Health Safety & Nutrition"
}
]
},
{
"_id": "Cognitive Development",
"subLearningAreas": [
{
"_id": "68e797e0c2f6370ebe59bda3",
"subLearningArea": "Exploration & Discovery"
},
{
"_id": "68e79803c2f6370ebe59bdd8",
"subLearningArea": "Problem Solving"
},
{
"_id": "68e79822c2f6370ebe59bde5",
"subLearningArea": "Early Numeracy"
},
{
"_id": "68e7984bc2f6370ebe59bdf0",
"subLearningArea": "Memory & Concept Formation"
}
]
},
{
"_id": "Language Communication & Early Literacy",
"subLearningAreas": [
{
"_id": "68e79893c2f6370ebe59bdfd",
"subLearningArea": "Listening & Understanding"
},
{
"_id": "68e798c4c2f6370ebe59be06",
"subLearningArea": "Speaking"
},
{
"_id": "68e798e9c2f6370ebe59be0f",
"subLearningArea": "Early Reading"
},
{
"_id": "68e79907c2f6370ebe59be16",
"subLearningArea": "Early Writing"
}
]
},
{
"_id": "Socio-Emotional Development",
"subLearningAreas": [
{
"_id": "68e79938c2f6370ebe59be21",
"subLearningArea": "Attachment & Trust"
},
{
"_id": "68e7995bc2f6370ebe59be28",
"subLearningArea": "Self-Awareness"
},
{
"_id": "68e79976c2f6370ebe59be31",
"subLearningArea": "Emotional Regulation"
},
{
"_id": "68e79996c2f6370ebe59be38",
"subLearningArea": "Social Interaction"
}
]
},
{
"_id": "Spiritual Moral & Values",
"subLearningAreas": [
{
"_id": "68e799b5c2f6370ebe59be41",
"subLearningArea": "Awareness of the Creator"
},
{
"_id": "68e799dac2f6370ebe59be4a",
"subLearningArea": "Gratitude & Courtesy"
},
{
"_id": "68e799ffc2f6370ebe59be53",
"subLearningArea": "Values & Conduct"
}
]
},
{
"_id": "Creativity & Aesthetics",
"subLearningAreas": [
{
"_id": "68e79a1dc2f6370ebe59be5a",
"subLearningArea": "Artistic Expression"
},
{
"_id": "68e79a41c2f6370ebe59be63",
"subLearningArea": "Imagination & Play"
},
{
"_id": "68e79a60c2f6370ebe59be6d",
"subLearningArea": "Appreciation of Beauty"
}
]
},
{
"_id": "Communication",
"subLearningAreas": [
{
"_id": "68e8d703f8335116beaf2c6c",
"subLearningArea": "Listening & Attention"
},
{
"_id": "68e8d72af8335116beaf2c71",
"subLearningArea": "Speaking & Vocabulary"
},
{
"_id": "68e8d752f8335116beaf2c76",
"subLearningArea": "Reading (Early Literacy)"
},
{
"_id": "68e8d775f8335116beaf2c7b",
"subLearningArea": "Writing (Emergent Writing)"
}
]
},
{
"_id": "Spirituality & Values",
"subLearningAreas": [
{
"_id": "68e8d806f8335116beaf2c80",
"subLearningArea": "Gratitude & Courtesy"
},
{
"_id": "68e8d829f8335116beaf2c85",
"subLearningArea": "Respect, Tolerance, Compassion"
},
{
"_id": "68e8d850f8335116beaf2c8a",
"subLearningArea": "Moral Understanding & Choices"
}
]
},
{
"_id": "Humanities",
"subLearningAreas": [
{
"_id": "68e8d8edf8335116beaf2c8f",
"subLearningArea": "Self, Family & Community"
},
{
"_id": "68e8d910f8335116beaf2c94",
"subLearningArea": "Malaysia & National Identity"
},
{
"_id": "68e8d944f8335116beaf2e37",
"subLearningArea": "Environment & Sustainability"
}
]
},
{
"_id": "Personal Competence",
"subLearningAreas": [
{
"_id": "68e8da5ef8335116beaf2e69",
"subLearningArea": "Self-Awareness & Confidence"
},
{
"_id": "68e8da7bf8335116beaf2e6e",
"subLearningArea": "Social Skills & Collaboration"
},
{
"_id": "68e8da94f8335116beaf2e79",
"subLearningArea": "Emotional Regulation"
},
{
"_id": "68e8dac2f8335116beaf2e80",
"subLearningArea": "Responsibility & Safety"
}
]
},
{
"_id": "Physical Development & Aesthetics",
"subLearningAreas": [
{
"_id": "68e8dafdf8335116beaf2e87",
"subLearningArea": "Gross Motor (Locomotor)"
},
{
"_id": "68e8db2bf8335116beaf2e8e",
"subLearningArea": "Object Control (Manipulative)"
},
{
"_id": "68e8db4bf8335116beaf2e93",
"subLearningArea": "Fine Motor & Handwriting"
},
{
"_id": "68e8db6df8335116beaf2e9a",
"subLearningArea": "Rhythm, Movement & Expression"
},
{
"_id": "68e8dbb3f8335116beaf2ea1",
"subLearningArea": "Health, Hygiene & Nutrition"
}
]
},
{
"_id": "Science & Technology",
"subLearningAreas": [
{
"_id": "68e8dbd1f8335116beaf2ea6",
"subLearningArea": "Scientific Inquiry Skills"
},
{
"_id": "68e8dbf5f8335116beaf2eab",
"subLearningArea": "Materials & Physical World"
},
{
"_id": "68e8dc1ff8335116beaf2eb0",
"subLearningArea": "Technology in Daily Life"
}
]
},
{
"_id": "Children have a strong sense of identity",
"subLearningAreas": [
{
"_id": "68ece8b74327021071764158",
"subLearningArea": "Children feel safe, secure, and supported"
},
{
"_id": "68ece927432702107176415d",
"subLearningArea": "Children develop their emerging autonomy, inter-dependence, resilience, and agency"
},
{
"_id": "69842a93478005ee23edc361",
"subLearningArea": "Children develop knowledgeable and confident self-identities, and a positive sense of self-worth"
},
{
"_id": "6984410e4360b85ae983bf65",
"subLearningArea": "Children learn to interact in relation to others with care, empathy, and respect"
}
]
},
{
"_id": "Children are connected with and contribute to their world",
"subLearningAreas": [
{
"_id": "698441f04360b85ae983bfb3",
"subLearningArea": "Children develop a sense of connectedness to groups and communities and an understanding of their reciprocal rights and responsibilities as active and informed citizens"
},
{
"_id": "698442f94360b85ae983c16e",
"subLearningArea": "Children respond to diversity with respect"
},
{
"_id": "698445c24360b85ae983c2f1",
"subLearningArea": "Children become aware of fairness"
},
{
"_id": "698445fd4360b85ae983c304",
"subLearningArea": "Children become socially responsible and show respect for the environment"
}
]
},
{
"_id": "Children have a strong sense of wellbeing",
"subLearningAreas": [
{
"_id": "698447a94360b85ae983c48b",
"subLearningArea": "Children become strong in their social, emotional, and mental wellbeing"
},
{
"_id": "698449824360b85ae983c960",
"subLearningArea": "Children become strong in their physical learning and mental wellbeing"
},
{
"_id": "698449ef4360b85ae983cc5f",
"subLearningArea": "Children become strong in their physical learning and wellbeing"
},
{
"_id": "69844a664360b85ae983cc74",
"subLearningArea": "Children are aware of and develop strategies to support their own mental and physical health and personal safety"
}
]
},
{
"_id": "Children are confident and involved learners",
"subLearningAreas": [
{
"_id": "69844b3c4360b85ae983cc9f",
"subLearningArea": "Children develop dispositions for learning such as curiosity, cooperation, confidence, creativity, commitment, enthusiasm, persistence, imagination, and reflexivity"
},
{
"_id": "698456f64360b85ae983dc08",
"subLearningArea": "Children develop a range of skills and processes such as problem-solving, inquiry, experimentation, hypothesising, researching, and investigating"
},
{
"_id": "698457844360b85ae983dc1b",
"subLearningArea": "Children transfer and adapt what they have learned from one context to another"
},
{
"_id": "698457ea4360b85ae983dc2c",
"subLearningArea": "Children resource their own learning through connecting with people, place, technologies, and natural and processed materials"
}
]
},
{
"_id": "Children are effective communicators",
"subLearningAreas": [
{
"_id": "6984598e4360b85ae983dc5b",
"subLearningArea": "Children interact verbally and non-verbally with others for a range of purposes"
},
{
"_id": "698461df4399c42eabeb68d0",
"subLearningArea": "Children engage with a range of texts and gain meaning from these texts"
},
{
"_id": "698462754399c42eabeb6a4f",
"subLearningArea": "Children express ideas and make meaning using a range of media"
},
{
"_id": "698462c74399c42eabeb6b59",
"subLearningArea": "Children begin to understand how symbols and pattern systems work"
},
{
"_id": "6984636a4399c42eabeb6d2f",
"subLearningArea": "Children use information and communication technologies to access information, investigate ideas, and represent their thinking"
}
]
},
{
"_id": "Development of Movement",
"subLearningAreas": [
{
"_id": "698c1e3f4399c42eabefb541",
"subLearningArea": "Development of equilibrium: in supine position"
},
{
"_id": "698c1e964399c42eabefb612",
"subLearningArea": "Development of equilibrium: in prone position"
},
{
"_id": "698c1f1c4399c42eabefb900",
"subLearningArea": "Control and coordination of body movement"
},
{
"_id": "698c1f6c4399c42eabefba15",
"subLearningArea": "Development of the hand grasp"
},
{
"_id": "698c1fa14399c42eabefbbdc",
"subLearningArea": "Tactile stimulation"
},
{
"_id": "698c1fe14399c42eabefbc30",
"subLearningArea": "Eye-hand coordination"
}
]
},
{
"_id": "Language",
"subLearningAreas": [
{
"_id": "698c20624399c42eabefbc61",
"subLearningArea": "Oral language acquisition and development"
},
{
"_id": "698c20ce4399c42eabefbd8b",
"subLearningArea": "Preparation of the hand for writing"
},
{
"_id": "698d6c894399c42eabf0b2c3",
"subLearningArea": "Spoken language Vocabulary enrichment"
},
{
"_id": "698d6cef4399c42eabf0b8ba",
"subLearningArea": "The mechanics of writing and reading: sound-letter correspondence, letter formation, decoding, handwriting"
},
{
"_id": "698d6d364399c42eabf0bb5f",
"subLearningArea": "Written expression: preparation"
},
{
"_id": "698d6d754399c42eabf0bd2c",
"subLearningArea": "The functions of words: parts of speech and their work"
},
{
"_id": "698d6dba4399c42eabf0c14d",
"subLearningArea": "Reading: analysis and synthesis"
},
{
"_id": "698d6e0a4399c42eabf0c3e4",
"subLearningArea": "Sentence analysis: words, groups and phrases, clauses and sentences"
},
{
"_id": "698d7a154399c42eabf10899",
"subLearningArea": "Word study"
},
{
"_id": "698d7a4a4399c42eabf108aa",
"subLearningArea": "Spelling"
},
{
"_id": "698d7b734399c42eabf10935",
"subLearningArea": "Reading commands"
},
{
"_id": "698d7bbb4399c42eabf109fa",
"subLearningArea": "Interpretive reading"
},
{
"_id": "698d7c084399c42eabf10b05",
"subLearningArea": "Reading across the subject areas"
},
{
"_id": "698d7c9b4399c42eabf10dca",
"subLearningArea": "Punctuation "
},
{
"_id": "698d7cc34399c42eabf11018",
"subLearningArea": "Musical notation "
},
{
"_id": "698d94384399c42eabf1567b",
"subLearningArea": "Cultural extensions: language across the subject areas: history, geography, science, art and music, appreciation"
}
]
},
{
"_id": "Development and Education of the Senses",
"subLearningAreas": [
{
"_id": "698c210f4399c42eabefbdf4",
"subLearningArea": "Sensorial exploration"
},
{
"_id": "698c216f4399c42eabefbe11",
"subLearningArea": "Visual discrimination"
},
{
"_id": "698c21a94399c42eabefbe59",
"subLearningArea": "Tactile discrimination"
},
{
"_id": "698c21dc4399c42eabefbe87",
"subLearningArea": "Auditory discrimination"
},
{
"_id": "698c221e4399c42eabefbea2",
"subLearningArea": "Olfactory and gustatory"
},
{
"_id": "698c22444399c42eabefbeb1",
"subLearningArea": "Stereognostic sense"
},
{
"_id": "698d64964399c42eabf09970",
"subLearningArea": "Visual discrimination: dimension"
},
{
"_id": "698d64d24399c42eabf0998c",
"subLearningArea": "Visual discrimination: colour"
},
{
"_id": "698d65444399c42eabf099ab",
"subLearningArea": "Visual discrimination: shape (form)"
},
{
"_id": "698d65824399c42eabf099e0",
"subLearningArea": "Visual discrimination: mixed"
},
{
"_id": "698d65a54399c42eabf099ed",
"subLearningArea": "Tactile discrimination: texture"
},
{
"_id": "698d65ef4399c42eabf09a04",
"subLearningArea": "Tactile discrimination: mass (baric sense)"
},
{
"_id": "698d662f4399c42eabf09a3a",
"subLearningArea": "Tactile discrimination: temperature (thermic sense)"
},
{
"_id": "698d66854399c42eabf09a57",
"subLearningArea": "Tactile discrimination: stereognostic"
},
{
"_id": "698d67844399c42eabf09ad7",
"subLearningArea": "Auditory discrimination: dynamics/intensity of sound"
},
{
"_id": "698d67eb4399c42eabf09d81",
"subLearningArea": "Auditory discrimination: pitch"
},
{
"_id": "698d682c4399c42eabf09f9e",
"subLearningArea": "Auditory discrimination: timbre"
},
{
"_id": "698d68564399c42eabf0a008",
"subLearningArea": "Auditory discrimination: rhythm"
},
{
"_id": "698d687a4399c42eabf0a06c",
"subLearningArea": "Auditory discrimination: style"
},
{
"_id": "698d68d34399c42eabf0a0e0",
"subLearningArea": "Olfactory discrimination: smell"
},
{
"_id": "698d69064399c42eabf0a195",
"subLearningArea": "Gustatory discrimination: taste"
}
]
},
{
"_id": "Fundamental Life Skills in the Infant Community",
"subLearningAreas": [
{
"_id": "698c22784399c42eabefbec6",
"subLearningArea": "Transition (from home to Montessori early childhood settings)"
},
{
"_id": "698c23364399c42eabefbef8",
"subLearningArea": "Care of the environment (indoor and outdoor)"
},
{
"_id": "698c23784399c42eabefbf0b",
"subLearningArea": "Social relations"
}
]
},
{
"_id": "Exercises Of Practical Life",
"subLearningAreas": [
{
"_id": "698d627f4399c42eabf09752",
"subLearningArea": "Control of movement :Transition or preliminary movements"
},
{
"_id": "698d62fd4399c42eabf0990f",
"subLearningArea": "Control of movement : Preliminary activities "
},
{
"_id": "698d638d4399c42eabf09930",
"subLearningArea": "Care of person"
},
{
"_id": "698d63c44399c42eabf0993e",
"subLearningArea": "Care of environment: indoor and outdoor"
},
{
"_id": "698d641c4399c42eabf09954",
"subLearningArea": "Movement: analysis and control"
},
{
"_id": "698d64494399c42eabf09961",
"subLearningArea": "Social relations"
}
]
},
{
"_id": "Science",
"subLearningAreas": [
{
"_id": "698d9f364399c42eabf1634e",
"subLearningArea": "Practical life"
},
{
"_id": "698da14c4399c42eabf16898",
"subLearningArea": "Sense exercises"
},
{
"_id": "698da1b24399c42eabf168bb",
"subLearningArea": "Physical Science: simple physics and chemistry, time, weather, astronomy"
},
{
"_id": "698da1e94399c42eabf168c8",
"subLearningArea": "Botany"
},
{
"_id": "698da2234399c42eabf168d7",
"subLearningArea": "Botany: language"
},
{
"_id": "698da2864399c42eabf168f0",
"subLearningArea": "Zoology"
},
{
"_id": "698da2b44399c42eabf168fd",
"subLearningArea": "Zoology: language"
}
]
},
{
"_id": "Geography",
"subLearningAreas": [
{
"_id": "698da3024399c42eabf16910",
"subLearningArea": "Geography "
},
{
"_id": "698da3364399c42eabf1691d",
"subLearningArea": "Geography: language"
}
]
},
{
"_id": "History",
"subLearningAreas": [
{
"_id": "698da3654399c42eabf1692c",
"subLearningArea": "History"
}
]
},
{
"_id": "Creative Arts",
"subLearningAreas": [
{
"_id": "698da3f84399c42eabf1694b",
"subLearningArea": "Music: auditory discrimination"
},
{
"_id": "698da4224399c42eabf16954",
"subLearningArea": "Music: singing"
},
{
"_id": "698da4524399c42eabf16961",
"subLearningArea": "Music: appreciation"
},
{
"_id": "698da47a4399c42eabf1696c",
"subLearningArea": "Music: timbre"
},
{
"_id": "698da4a44399c42eabf16d42",
"subLearningArea": "Music: pitch and notation"
},
{
"_id": "698da4d94399c42eabf16d4b",
"subLearningArea": "Music: rhythm"
},
{
"_id": "698da5054399c42eabf16d58",
"subLearningArea": "Visual Arts"
},
{
"_id": "698da53c4399c42eabf16d65",
"subLearningArea": "Movement and Dance"
}
]
},
{
"_id": "Personal Development, Health and Physical Education",
"subLearningAreas": [
{
"_id": "698da5794399c42eabf16d72",
"subLearningArea": "Personal Development"
},
{
"_id": "698da5a14399c42eabf16d7b",
"subLearningArea": "Health"
},
{
"_id": "698da5d44399c42eabf16d88",
"subLearningArea": "Physical Education"
}
]
}
]

48872
public/db/observations.json Normal file

File diff suppressed because it is too large Load Diff

61
sample.json Normal file
View File

@@ -0,0 +1,61 @@
[
{
"_id": {
"$oid": "68c29a3f8b8a101b6f11d2ef"
},
"title": "Grasping",
"remarks": "Her grasping is secured",
"students": [
{
"$oid": "679749a148464093da300e1d"
}
],
"assets": [
"akademy/file_0zriR1MsJhRIaWiJE4mU0GoEUTLbD7sQ-IMG_8680.jpeg"
],
"classroomId": {
"$oid": "67ee488fd1fc1f772c7f2dca"
},
"schoolId": {
"$oid": "66a0daa36969d352535ee676"
},
"recordedBy": {
"$oid": "67efb634d1fc1f772c9a2a9e"
},
"indicators": [
{
"indicator": "Holds pen or crayon using a whole hand (palmar) grasp and makes random marks with different strokes",
"developmentAreas": [
{
"learningArea": "Physical Development",
"subLearningArea": "Moving & Handling",
"framework": "EYFS"
}
]
}
],
"gradeId": {
"$oid": "664b3ce474ce94848a3ababc"
},
"origin": "OBSERVATION",
"createdAt": {
"$date": "2025-09-11T09:45:35.744Z"
},
"updatedAt": {
"$date": "2025-09-11T09:45:35.744Z"
},
"__v": 0,
"grade": [
{
"_id": {
"$oid": "664b3ce474ce94848a3ababc"
},
"frameworkId": {
"$oid": "664b3b5b004a6913d156c559"
},
"grade": "Secure",
"__v": 0
}
]
}
]

49
scripts/dump-db.js Normal file
View File

@@ -0,0 +1,49 @@
const { MongoClient } = require('mongodb');
const fs = require('fs');
const path = require('path');
const MONGODB_URI = 'mongodb://localhost/beanstalk';
const OUTPUT_DIR = path.join(__dirname, '../public/db');
async function dumpDatabase() {
const client = new MongoClient(MONGODB_URI);
try {
console.log('Connecting to MongoDB...');
await client.connect();
const db = client.db();
// Create output directory if it doesn't exist
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
console.log('Getting collections...');
const collections = await db.listCollections().toArray();
for (const collection of collections) {
const collectionName = collection.name;
console.log(`Dumping collection: ${collectionName}`);
const data = await db.collection(collectionName).find({}).toArray();
// Write to JSON file
const filePath = path.join(OUTPUT_DIR, `${collectionName}.json`);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
console.log(`${data.length} documents saved to ${filePath}`);
}
console.log('\nDatabase dump completed successfully!');
console.log(`Output directory: ${OUTPUT_DIR}`);
} catch (error) {
console.error('Error dumping database:', error);
process.exit(1);
} finally {
await client.close();
}
}
dumpDatabase();

93
scripts/import-db.js Normal file
View File

@@ -0,0 +1,93 @@
const { MongoClient } = require('mongodb');
const fs = require('fs');
const path = require('path');
const MONGODB_URI = 'mongodb://localhost/beanstalk';
const DB_DIR = path.join(__dirname, '../public/db');
async function importDatabase() {
const client = new MongoClient(MONGODB_URI);
try {
console.log('Connecting to MongoDB...');
await client.connect();
const db = client.db();
// Check if DB directory exists
if (!fs.existsSync(DB_DIR)) {
console.error(`Error: Database directory not found: ${DB_DIR}`);
process.exit(1);
}
console.log('Reading JSON files...');
const files = fs.readdirSync(DB_DIR).filter(file => file.endsWith('.json'));
if (files.length === 0) {
console.log('No JSON files found in the database directory.');
return;
}
for (const file of files) {
const collectionName = path.basename(file, '.json');
const filePath = path.join(DB_DIR, file);
console.log(`\nImporting collection: ${collectionName}`);
try {
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
if (!Array.isArray(data)) {
console.log(` ⚠️ Warning: ${file} does not contain an array. Skipping.`);
continue;
}
// Drop existing collection
await db.collection(collectionName).drop().catch(() => {});
console.log(` ✓ Dropped existing collection`);
// Insert data
if (data.length > 0) {
const result = await db.collection(collectionName).insertMany(data);
console.log(` ✓ Inserted ${result.insertedCount} documents`);
} else {
console.log(` ✓ Collection is empty`);
}
} catch (error) {
console.error(` ✗ Error importing ${file}:`, error.message);
}
}
console.log('\nDatabase import completed successfully!');
} catch (error) {
console.error('Error importing database:', error);
process.exit(1);
} finally {
await client.close();
}
}
// Handle command line arguments
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`
Usage: node import-db.js [options]
Options:
--help, -h Show this help message
Description:
Imports JSON files from public/db directory into MongoDB.
Each JSON file corresponds to a collection with the same name (without .json extension).
The script will:
1. Drop existing collections
2. Create new collections with the imported data
3. Preserve all document structure and data
`);
process.exit(0);
}
importDatabase();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

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

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

View File

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

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

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

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

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

View File

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

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

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

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

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

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

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

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}