Subir archivos a "finalexam"
This commit is contained in:
parent
5ac5eec6e7
commit
98cf36606b
13
finalexam/Dockerfile
Normal file
13
finalexam/Dockerfile
Normal file
@ -0,0 +1,13 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
COPY src ./src
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 10000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
234
finalexam/README.md
Normal file
234
finalexam/README.md
Normal file
@ -0,0 +1,234 @@
|
||||
# TaskNotes Cloud — Final Exam Project
|
||||
|
||||
## 1. Description of the application
|
||||
|
||||
TaskNotes Cloud is a small web application for saving tasks and study notes.
|
||||
The user can create, list, mark as completed and delete notes. Each note has a title, description and priority.
|
||||
|
||||
The application is useful for students who want to organize work for seminars, exams or small projects.
|
||||
|
||||
## 2. Cloud and architecture used
|
||||
|
||||
The proposed public cloud is **Render**.
|
||||
|
||||
The application has three main components:
|
||||
|
||||
1. **Frontend container**
|
||||
- React + Vite application.
|
||||
- Served by Nginx.
|
||||
- Public HTTPS URL.
|
||||
|
||||
2. **Backend container**
|
||||
- Node.js + Express REST API.
|
||||
- Provides endpoints under `/api/notes`.
|
||||
- Has a `/health` endpoint for health checks.
|
||||
- Logs HTTP access requests with Morgan.
|
||||
|
||||
3. **PostgreSQL database**
|
||||
- Managed Render Postgres database.
|
||||
- Stores persistent notes data.
|
||||
- Data remains available after backend restart or redeploy.
|
||||
|
||||
Communication:
|
||||
|
||||
- Browser → Frontend over HTTPS.
|
||||
- Frontend → Backend API over HTTPS.
|
||||
- Backend → PostgreSQL using the `DATABASE_URL` environment variable.
|
||||
|
||||
## 3. Docker / cloud objects
|
||||
|
||||
Local objects:
|
||||
|
||||
- `frontend` service
|
||||
- `backend` service
|
||||
- `db` service
|
||||
- `postgres_data` persistent volume
|
||||
|
||||
Cloud objects in `render.yaml`:
|
||||
|
||||
- `tasknotes-frontend`: Docker web service
|
||||
- `tasknotes-backend`: Docker web service
|
||||
- `tasknotes-db`: Render Postgres database
|
||||
|
||||
## 4. Uploaded files and their content
|
||||
|
||||
- `render.yaml` — Infrastructure as Code configuration for Render.
|
||||
- `docker-compose.yml` — local three-container version for testing.
|
||||
- `prepare-app.sh` — prepares and tests the application locally; also gives deployment instructions.
|
||||
- `remove-app.sh` — removes local containers and local persistent volume.
|
||||
- `backend/Dockerfile` — builds the backend container.
|
||||
- `backend/src/server.js` — Express backend API and database initialization.
|
||||
- `backend/src/backup.js` — exports notes to a JSON backup.
|
||||
- `frontend/Dockerfile` — builds React frontend and serves it through Nginx.
|
||||
- `frontend/src/main.jsx` — React user interface.
|
||||
- `frontend/src/styles.css` — visual styling.
|
||||
- `scripts/backup-local.sh` — local PostgreSQL backup script.
|
||||
- `scripts/logs-local.sh` — local backend log viewer.
|
||||
|
||||
## 5. Configuration
|
||||
|
||||
The application is configurable using environment variables:
|
||||
|
||||
Backend:
|
||||
|
||||
- `DATABASE_URL` — PostgreSQL connection string.
|
||||
- `CORS_ORIGIN` — allowed frontend origins.
|
||||
- `PORT` — backend port.
|
||||
- `NODE_ENV` — production/development mode.
|
||||
|
||||
Frontend:
|
||||
|
||||
- `VITE_API_URL` — public URL of the backend API.
|
||||
|
||||
Secrets are not stored in Git. The database connection string is injected by Render from the managed database.
|
||||
|
||||
## 6. How to run locally
|
||||
|
||||
Requirements:
|
||||
|
||||
- Docker
|
||||
- Docker Compose
|
||||
- Git
|
||||
- Bash terminal
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
chmod +x prepare-app.sh remove-app.sh scripts/*.sh
|
||||
./prepare-app.sh
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
- Frontend: `http://localhost:8080`
|
||||
- Backend health: `http://localhost:10000/health`
|
||||
|
||||
## 7. How to deploy to the public cloud
|
||||
|
||||
1. Upload the `sk1` directory to Git.
|
||||
2. Make sure `render.yaml` is in the repository root or configure the correct Blueprint path.
|
||||
3. Create a Render Blueprint from `render.yaml`.
|
||||
4. Render will create:
|
||||
- frontend service
|
||||
- backend service
|
||||
- PostgreSQL database
|
||||
5. After the backend is deployed, copy the backend public URL.
|
||||
6. Set the frontend environment variable:
|
||||
|
||||
```text
|
||||
VITE_API_URL=https://YOUR-BACKEND-URL.onrender.com
|
||||
```
|
||||
|
||||
7. Redeploy the frontend.
|
||||
8. Open the frontend HTTPS URL in the browser.
|
||||
|
||||
## 8. How to use the application
|
||||
|
||||
1. Open the public frontend URL in a browser.
|
||||
2. Write a title and details.
|
||||
3. Select priority.
|
||||
4. Click **Add note**.
|
||||
5. Mark notes as completed or delete them.
|
||||
|
||||
## 9. Backup instructions
|
||||
|
||||
Local backup:
|
||||
|
||||
```bash
|
||||
./scripts/backup-local.sh
|
||||
```
|
||||
|
||||
This creates a SQL dump in the `backups/` directory.
|
||||
|
||||
Backend JSON export:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
DATABASE_URL="your_database_url" npm run backup
|
||||
```
|
||||
|
||||
Cloud backup:
|
||||
|
||||
- Use Render Postgres backup/export options, or connect with `pg_dump` using the external database URL.
|
||||
- Example:
|
||||
|
||||
```bash
|
||||
pg_dump "$DATABASE_URL" > tasknotes-cloud-backup.sql
|
||||
```
|
||||
|
||||
## 10. Access logs from the internet
|
||||
|
||||
Local logs:
|
||||
|
||||
```bash
|
||||
./scripts/logs-local.sh
|
||||
```
|
||||
|
||||
Cloud logs:
|
||||
|
||||
- Open the backend service logs in Render, or use the Render CLI if configured.
|
||||
- The backend uses Morgan with `combined` format, so it logs IP, method, URL, status code, response time and user agent.
|
||||
|
||||
## 11. Conditions for scripts
|
||||
|
||||
`prepare-app.sh` can be run when:
|
||||
|
||||
- Docker is installed.
|
||||
- Docker Compose is available.
|
||||
- Ports `8080`, `10000` and `5432` are free.
|
||||
- The user is in the project root directory.
|
||||
|
||||
`remove-app.sh` can be run when:
|
||||
|
||||
- Docker is installed.
|
||||
- The local containers were created with Docker Compose.
|
||||
|
||||
## 12. Cost analysis for one year
|
||||
|
||||
Assumption:
|
||||
|
||||
- 1000 users per day.
|
||||
- Database/file size: 50 GB.
|
||||
- Small educational application.
|
||||
- Two small web services and one PostgreSQL database.
|
||||
|
||||
Example Render estimate:
|
||||
|
||||
- Frontend web service: Free or Starter. For production estimate, Starter: 7 USD/month.
|
||||
- Backend web service: Starter: 7 USD/month.
|
||||
- PostgreSQL Basic-256mb: 6 USD/month.
|
||||
- PostgreSQL storage: 50 GB × 0.30 USD/GB/month = 15 USD/month.
|
||||
- Total monthly estimate: 7 + 7 + 6 + 15 = 35 USD/month.
|
||||
- Total yearly estimate: 35 × 12 = 420 USD/year.
|
||||
|
||||
This is an approximate educational estimate. Real cost depends on traffic, bandwidth, region, scaling and selected plans.
|
||||
|
||||
## 13. External resources and generative AI use
|
||||
|
||||
External resources used:
|
||||
|
||||
- Render documentation for Blueprints, Docker web services, environment variables, HTTPS and PostgreSQL.
|
||||
- Docker documentation for Dockerfiles and Docker Compose.
|
||||
- PostgreSQL documentation for database concepts and backup using `pg_dump`.
|
||||
|
||||
Generative AI use:
|
||||
|
||||
- ChatGPT was used to help design the structure of the project, generate example configuration files, improve documentation and prepare defense explanations.
|
||||
- The final implementation was reviewed and adapted for this exam project.
|
||||
|
||||
## 14. Defense summary
|
||||
|
||||
Short explanation:
|
||||
|
||||
> My project is TaskNotes Cloud, a web application for students to save tasks and study notes. It has a React frontend, a Node.js backend and a PostgreSQL database. The deployment is defined in configuration files, mainly `render.yaml` and Dockerfiles. The app is publicly accessible using HTTPS, stores data persistently in PostgreSQL, restarts automatically in the cloud, and can be backed up with `pg_dump`.
|
||||
|
||||
Important points to mention:
|
||||
|
||||
- Three components: frontend, backend, database.
|
||||
- HTTPS certificate is provided by Render.
|
||||
- Secrets are environment variables, not committed to Git.
|
||||
- Database is persistent.
|
||||
- Backend health check is `/health`.
|
||||
- Logs can be viewed in Render.
|
||||
- Backup can be done with `pg_dump`.
|
||||
- Local testing is possible with Docker Compose.
|
||||
6
finalexam/backup-local.sh
Normal file
6
finalexam/backup-local.sh
Normal file
@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
mkdir -p backups
|
||||
docker exec tasknotes-db pg_dump -U tasknotes -d tasknotes > "backups/tasknotes-$(date +%Y%m%d-%H%M%S).sql"
|
||||
echo "Backup created in backups/"
|
||||
32
finalexam/backup.js
Normal file
32
finalexam/backup.js
Normal file
@ -0,0 +1,32 @@
|
||||
import pg from "pg";
|
||||
import fs from "fs";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const { Pool } = pg;
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
|
||||
if (!DATABASE_URL) {
|
||||
console.error("Missing DATABASE_URL environment variable");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: DATABASE_URL,
|
||||
ssl: process.env.NODE_ENV === "production" ? { rejectUnauthorized: false } : false
|
||||
});
|
||||
|
||||
const result = await pool.query("SELECT * FROM notes ORDER BY id ASC");
|
||||
const backup = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
table: "notes",
|
||||
rows: result.rows
|
||||
};
|
||||
|
||||
fs.mkdirSync("backups", { recursive: true });
|
||||
const filename = `backups/notes-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
|
||||
fs.writeFileSync(filename, JSON.stringify(backup, null, 2));
|
||||
console.log(`Backup written to ${filename}`);
|
||||
|
||||
await pool.end();
|
||||
41
finalexam/docker-compose.yml
Normal file
41
finalexam/docker-compose.yml
Normal file
@ -0,0 +1,41 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: tasknotes-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: tasknotes
|
||||
POSTGRES_USER: tasknotes
|
||||
POSTGRES_PASSWORD: tasknotes_password
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
container_name: tasknotes-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: postgres://tasknotes:tasknotes_password@db:5432/tasknotes
|
||||
CORS_ORIGIN: http://localhost:5173,http://localhost:8080
|
||||
NODE_ENV: development
|
||||
PORT: 10000
|
||||
ports:
|
||||
- "10000:10000"
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
container_name: tasknotes-frontend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
VITE_API_URL: http://localhost:10000
|
||||
ports:
|
||||
- "8080:80"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
1
finalexam/index.html
Normal file
1
finalexam/index.html
Normal file
@ -0,0 +1 @@
|
||||
<!doctype html><html><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>TaskNotes Cloud</title></head><body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>
|
||||
4
finalexam/logs-local.sh
Normal file
4
finalexam/logs-local.sh
Normal file
@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
docker compose logs -f backend
|
||||
176
finalexam/main.jsx
Normal file
176
finalexam/main.jsx
Normal file
@ -0,0 +1,176 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './styles.css';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:10000';
|
||||
|
||||
function formatDate(date) {
|
||||
if (!date) return 'No deadline';
|
||||
return new Date(date + 'T00:00:00').toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function isOverdue(task) {
|
||||
if (!task.deadline || task.completed) return false;
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
return new Date(task.deadline + 'T00:00:00') < today;
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [tasks, setTasks] = useState([]);
|
||||
const [status, setStatus] = useState('Loading...');
|
||||
const [filter, setFilter] = useState('all');
|
||||
const [form, setForm] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
subject: '',
|
||||
task_type: 'assignment',
|
||||
priority: 'normal',
|
||||
deadline: '',
|
||||
});
|
||||
|
||||
async function loadTasks() {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/tasks`);
|
||||
if (!res.ok) throw new Error('API error');
|
||||
const data = await res.json();
|
||||
setTasks(data);
|
||||
setStatus('Connected to backend and PostgreSQL');
|
||||
} catch (error) {
|
||||
setStatus('Backend is not reachable. Check VITE_API_URL and backend logs.');
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => { loadTasks(); }, []);
|
||||
|
||||
async function addTask(event) {
|
||||
event.preventDefault();
|
||||
if (!form.title.trim()) return;
|
||||
await fetch(`${API_URL}/api/tasks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
setForm({ title: '', description: '', subject: '', task_type: 'assignment', priority: 'normal', deadline: '' });
|
||||
loadTasks();
|
||||
}
|
||||
|
||||
async function toggleTask(id) {
|
||||
await fetch(`${API_URL}/api/tasks/${id}/toggle`, { method: 'PATCH' });
|
||||
loadTasks();
|
||||
}
|
||||
|
||||
async function deleteTask(id) {
|
||||
await fetch(`${API_URL}/api/tasks/${id}`, { method: 'DELETE' });
|
||||
loadTasks();
|
||||
}
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = tasks.length;
|
||||
const completed = tasks.filter(t => t.completed).length;
|
||||
const pending = total - completed;
|
||||
const high = tasks.filter(t => t.priority === 'high' && !t.completed).length;
|
||||
const overdue = tasks.filter(isOverdue).length;
|
||||
return { total, pending, completed, high, overdue };
|
||||
}, [tasks]);
|
||||
|
||||
const filteredTasks = tasks.filter((task) => {
|
||||
if (filter === 'pending') return !task.completed;
|
||||
if (filter === 'completed') return task.completed;
|
||||
if (filter === 'high') return task.priority === 'high' && !task.completed;
|
||||
if (filter === 'overdue') return isOverdue(task);
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="page">
|
||||
<section className="hero">
|
||||
<div>
|
||||
<span className="badge">Final cloud exam project</span>
|
||||
<h1>Student Study Planner</h1>
|
||||
<p>
|
||||
Manage assignments, exams, projects and study deadlines. Data is stored in PostgreSQL,
|
||||
so it survives restarts and redeploys.
|
||||
</p>
|
||||
</div>
|
||||
<div className="heroCard">
|
||||
<strong>Cloud architecture</strong>
|
||||
<span>React frontend</span>
|
||||
<span>Node.js backend API</span>
|
||||
<span>PostgreSQL persistent database</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="stats">
|
||||
<article><span>Total tasks</span><strong>{stats.total}</strong></article>
|
||||
<article><span>Pending</span><strong>{stats.pending}</strong></article>
|
||||
<article><span>Completed</span><strong>{stats.completed}</strong></article>
|
||||
<article><span>High priority</span><strong>{stats.high}</strong></article>
|
||||
<article className={stats.overdue ? 'danger' : ''}><span>Overdue</span><strong>{stats.overdue}</strong></article>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panelHeader">
|
||||
<div>
|
||||
<h2>Add a study task</h2>
|
||||
<p>Create a clear plan for homework, exams and projects.</p>
|
||||
</div>
|
||||
<span className={status.includes('not') ? 'status error' : 'status ok'}>{status}</span>
|
||||
</div>
|
||||
|
||||
<form onSubmit={addTask} className="taskForm">
|
||||
<input value={form.title} onChange={e => setForm({ ...form, title: e.target.value })} placeholder="Task title, e.g. Prepare Kubernetes defense" />
|
||||
<input value={form.subject} onChange={e => setForm({ ...form, subject: e.target.value })} placeholder="Subject, e.g. Cloud Technologies" />
|
||||
<textarea value={form.description} onChange={e => setForm({ ...form, description: e.target.value })} placeholder="Details or study notes" />
|
||||
<select value={form.task_type} onChange={e => setForm({ ...form, task_type: e.target.value })}>
|
||||
<option value="assignment">Assignment</option>
|
||||
<option value="exam">Exam</option>
|
||||
<option value="project">Project</option>
|
||||
<option value="reading">Reading</option>
|
||||
<option value="personal">Personal</option>
|
||||
</select>
|
||||
<select value={form.priority} onChange={e => setForm({ ...form, priority: e.target.value })}>
|
||||
<option value="low">Low priority</option>
|
||||
<option value="normal">Normal priority</option>
|
||||
<option value="high">High priority</option>
|
||||
</select>
|
||||
<input type="date" value={form.deadline} onChange={e => setForm({ ...form, deadline: e.target.value })} />
|
||||
<button type="submit">Add task</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="toolbar">
|
||||
{['all', 'pending', 'completed', 'high', 'overdue'].map(item => (
|
||||
<button key={item} className={filter === item ? 'active' : ''} onClick={() => setFilter(item)}>{item}</button>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="taskList">
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="empty">No tasks in this view. Add your first study task.</div>
|
||||
) : filteredTasks.map(task => (
|
||||
<article key={task.id} className={`task ${task.completed ? 'done' : ''} priority-${task.priority}`}>
|
||||
<div className="taskTop">
|
||||
<div>
|
||||
<h3>{task.title}</h3>
|
||||
<p>{task.description || 'No details added.'}</p>
|
||||
</div>
|
||||
<span className={`pill ${task.priority}`}>{task.priority}</span>
|
||||
</div>
|
||||
<div className="meta">
|
||||
<span>Subject: <b>{task.subject || 'General'}</b></span>
|
||||
<span>Type: <b>{task.task_type}</b></span>
|
||||
<span className={isOverdue(task) ? 'overdue' : ''}>Deadline: <b>{formatDate(task.deadline)}</b></span>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button onClick={() => toggleTask(task.id)}>{task.completed ? 'Reopen' : 'Mark completed'}</button>
|
||||
<button className="delete" onClick={() => deleteTask(task.id)}>Delete</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />);
|
||||
10
finalexam/nginx.conf
Normal file
10
finalexam/nginx.conf
Normal file
@ -0,0 +1,10 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
16
finalexam/package.json
Normal file
16
finalexam/package.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "tasknotes-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.4.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
}
|
||||
}
|
||||
40
finalexam/prepare-app.sh
Normal file
40
finalexam/prepare-app.sh
Normal file
@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "== TaskNotes Cloud: prepare app =="
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "Docker is required for local testing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "Git is required."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "1) Testing the application locally with Docker Compose..."
|
||||
docker compose up -d --build
|
||||
|
||||
echo "2) Waiting for backend health endpoint..."
|
||||
for i in {1..30}; do
|
||||
if curl -fsS http://localhost:10000/health >/dev/null 2>&1; then
|
||||
echo "Backend is healthy."
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "3) Local app is running:"
|
||||
echo " Frontend: http://localhost:8080"
|
||||
echo " Backend health: http://localhost:10000/health"
|
||||
|
||||
echo ""
|
||||
echo "4) Cloud deployment:"
|
||||
echo " Push this repository to GitHub/GitLab/Bitbucket and create a Render Blueprint using render.yaml."
|
||||
echo " After backend is deployed, set VITE_API_URL in the frontend service to:"
|
||||
echo " https://tasknotes-backend.onrender.com"
|
||||
echo " If Render adds a suffix to the URL, use the real backend URL shown by Render."
|
||||
echo ""
|
||||
echo "Optional validation if Render CLI is installed:"
|
||||
echo " render blueprints validate render.yaml"
|
||||
11
finalexam/remove-app.sh
Normal file
11
finalexam/remove-app.sh
Normal file
@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "== TaskNotes Cloud: remove local app =="
|
||||
docker compose down -v
|
||||
echo "Local containers and local PostgreSQL volume removed."
|
||||
|
||||
echo ""
|
||||
echo "Cloud removal:"
|
||||
echo "Delete/suspend the Render Blueprint or services from Render, or use Render CLI/API if available for your account."
|
||||
echo "Do not delete only the frontend; remove backend and database too if you want to stop all resources."
|
||||
40
finalexam/render.yaml
Normal file
40
finalexam/render.yaml
Normal file
@ -0,0 +1,40 @@
|
||||
services:
|
||||
- type: web
|
||||
name: tasknotes-backend
|
||||
runtime: docker
|
||||
plan: free
|
||||
region: frankfurt
|
||||
dockerContext: ./backend
|
||||
dockerfilePath: ./backend/Dockerfile
|
||||
healthCheckPath: /health
|
||||
autoDeploy: true
|
||||
envVars:
|
||||
- key: NODE_ENV
|
||||
value: production
|
||||
- key: PORT
|
||||
value: 10000
|
||||
- key: DATABASE_URL
|
||||
fromDatabase:
|
||||
name: tasknotes-db
|
||||
property: connectionString
|
||||
- key: CORS_ORIGIN
|
||||
value: "*"
|
||||
|
||||
- type: web
|
||||
name: tasknotes-frontend
|
||||
runtime: docker
|
||||
plan: free
|
||||
region: frankfurt
|
||||
dockerContext: ./frontend
|
||||
dockerfilePath: ./frontend/Dockerfile
|
||||
autoDeploy: true
|
||||
envVars:
|
||||
- key: VITE_API_URL
|
||||
sync: false
|
||||
|
||||
databases:
|
||||
- name: tasknotes-db
|
||||
databaseName: tasknotes
|
||||
user: tasknotes
|
||||
plan: free
|
||||
region: frankfurt
|
||||
214
finalexam/server.js
Normal file
214
finalexam/server.js
Normal file
@ -0,0 +1,214 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const morgan = require('morgan');
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const app = express();
|
||||
|
||||
const PORT = process.env.PORT || 10000;
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || 'http://localhost:8080';
|
||||
|
||||
if (!DATABASE_URL) {
|
||||
console.error('DATABASE_URL environment variable is required');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const allowedOrigins = CORS_ORIGIN.split(',').map((origin) => origin.trim());
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin(origin, callback) {
|
||||
if (!origin || allowedOrigins.includes(origin)) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error(`CORS not allowed for origin: ${origin}`));
|
||||
}
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
app.use(express.json());
|
||||
app.use(morgan('combined'));
|
||||
|
||||
const useSsl = process.env.NODE_ENV === 'production';
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: DATABASE_URL,
|
||||
ssl: useSsl ? { rejectUnauthorized: false } : false,
|
||||
});
|
||||
|
||||
async function initDb() {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
subject VARCHAR(120) DEFAULT '',
|
||||
task_type VARCHAR(50) DEFAULT 'assignment',
|
||||
priority VARCHAR(20) DEFAULT 'normal',
|
||||
deadline DATE,
|
||||
completed BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
function normalizeTask(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description || '',
|
||||
subject: row.subject || '',
|
||||
taskType: row.task_type || 'assignment',
|
||||
priority: row.priority || 'normal',
|
||||
deadline: row.deadline,
|
||||
completed: row.completed,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
app.get('/health', async (req, res) => {
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
res.json({
|
||||
status: 'ok',
|
||||
database: 'connected',
|
||||
app: 'Student Study Planner',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Health check failed:', error);
|
||||
res.status(500).json({
|
||||
status: 'error',
|
||||
database: 'disconnected',
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/tasks', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT *
|
||||
FROM tasks
|
||||
ORDER BY completed ASC, deadline ASC NULLS LAST, created_at DESC;
|
||||
`);
|
||||
|
||||
res.json(result.rows.map(normalizeTask));
|
||||
} catch (error) {
|
||||
console.error('Error getting tasks:', error);
|
||||
res.status(500).json({ message: 'Error getting tasks' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/tasks', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
title,
|
||||
description = '',
|
||||
subject = '',
|
||||
taskType = 'assignment',
|
||||
priority = 'normal',
|
||||
deadline = null,
|
||||
} = req.body;
|
||||
|
||||
if (!title || !title.trim()) {
|
||||
return res.status(400).json({ message: 'Title is required' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`
|
||||
INSERT INTO tasks
|
||||
(title, description, subject, task_type, priority, deadline)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *;
|
||||
`,
|
||||
[
|
||||
title.trim(),
|
||||
description,
|
||||
subject,
|
||||
taskType,
|
||||
priority,
|
||||
deadline || null,
|
||||
]
|
||||
);
|
||||
|
||||
res.status(201).json(normalizeTask(result.rows[0]));
|
||||
} catch (error) {
|
||||
console.error('Error creating task:', error);
|
||||
res.status(500).json({ message: 'Error creating task' });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/tasks/:id/toggle', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await pool.query(
|
||||
`
|
||||
UPDATE tasks
|
||||
SET completed = NOT completed,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ message: 'Task not found' });
|
||||
}
|
||||
|
||||
res.json(normalizeTask(result.rows[0]));
|
||||
} catch (error) {
|
||||
console.error('Error updating task:', error);
|
||||
res.status(500).json({ message: 'Error updating task' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/tasks/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await pool.query(
|
||||
`
|
||||
DELETE FROM tasks
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ message: 'Task not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Task deleted',
|
||||
task: normalizeTask(result.rows[0]),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting task:', error);
|
||||
res.status(500).json({ message: 'Error deleting task' });
|
||||
}
|
||||
});
|
||||
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ message: 'Route not found' });
|
||||
});
|
||||
|
||||
initDb()
|
||||
.then(() => {
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`Student Study Planner backend running on port ${PORT}`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
console.log(`SSL for database: ${useSsl ? 'enabled' : 'disabled'}`);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Database initialization failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
111
finalexam/styles.css
Normal file
111
finalexam/styles.css
Normal file
@ -0,0 +1,111 @@
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #172033;
|
||||
background: #eef3ff;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
|
||||
.page { min-height: 100vh; padding: 28px; }
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.7fr .8fr;
|
||||
gap: 28px;
|
||||
align-items: center;
|
||||
padding: 46px;
|
||||
border-radius: 32px;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #182848 0%, #3f51d7 58%, #7b61ff 100%);
|
||||
box-shadow: 0 24px 60px rgba(35, 50, 100, .28);
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,.15);
|
||||
border: 1px solid rgba(255,255,255,.35);
|
||||
font-weight: 700;
|
||||
}
|
||||
h1 { font-size: clamp(42px, 7vw, 82px); line-height: .95; margin: 30px 0 20px; }
|
||||
.hero p { max-width: 850px; font-size: 21px; line-height: 1.6; margin: 0; }
|
||||
.heroCard {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
border-radius: 24px;
|
||||
background: rgba(255,255,255,.14);
|
||||
border: 1px solid rgba(255,255,255,.28);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.heroCard strong { font-size: 22px; }
|
||||
.heroCard span { opacity: .92; }
|
||||
|
||||
.stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin: 28px 0; }
|
||||
.stats article {
|
||||
padding: 22px;
|
||||
border-radius: 24px;
|
||||
background: white;
|
||||
box-shadow: 0 16px 35px rgba(25, 44, 90, .10);
|
||||
}
|
||||
.stats span { display: block; color: #64708a; font-weight: 700; }
|
||||
.stats strong { display: block; margin-top: 8px; font-size: 38px; }
|
||||
.stats .danger strong { color: #c82424; }
|
||||
|
||||
.panel, .task, .empty {
|
||||
background: rgba(255,255,255,.92);
|
||||
border: 1px solid rgba(113, 130, 180, .25);
|
||||
box-shadow: 0 18px 45px rgba(25, 44, 90, .10);
|
||||
border-radius: 28px;
|
||||
}
|
||||
.panel { padding: 28px; }
|
||||
.panelHeader { display: flex; justify-content: space-between; gap: 18px; align-items: start; margin-bottom: 20px; }
|
||||
.panel h2 { margin: 0; font-size: 30px; }
|
||||
.panel p { margin: 6px 0 0; color: #6b7488; }
|
||||
.status { padding: 10px 14px; border-radius: 999px; font-weight: 800; font-size: 14px; }
|
||||
.status.ok { background: #e6f8ee; color: #137340; }
|
||||
.status.error { background: #ffe2e2; color: #a31212; }
|
||||
|
||||
.taskForm { display: grid; grid-template-columns: 1.2fr .8fr .7fr .7fr .7fr auto; gap: 14px; }
|
||||
.taskForm textarea { grid-column: 1 / 4; min-height: 80px; resize: vertical; }
|
||||
.taskForm input, .taskForm textarea, .taskForm select {
|
||||
width: 100%; border: 1px solid #cfd8ee; border-radius: 18px; padding: 16px 18px; background: white; color: #192136;
|
||||
}
|
||||
.taskForm button, .actions button, .toolbar button {
|
||||
border: 0; border-radius: 18px; padding: 15px 20px; font-weight: 800; cursor: pointer;
|
||||
background: #473bd1; color: white;
|
||||
}
|
||||
.taskForm button { min-width: 130px; }
|
||||
|
||||
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; margin: 26px 0 18px; }
|
||||
.toolbar button { background: white; color: #39445e; border: 1px solid #d4dcef; text-transform: capitalize; }
|
||||
.toolbar button.active { background: #182848; color: white; }
|
||||
.taskList { display: grid; gap: 16px; }
|
||||
.task { padding: 24px; border-left: 9px solid #6b78ff; }
|
||||
.task.priority-high { border-left-color: #e43f5a; }
|
||||
.task.priority-normal { border-left-color: #6b78ff; }
|
||||
.task.priority-low { border-left-color: #22a06b; }
|
||||
.task.done { opacity: .72; }
|
||||
.task.done h3 { text-decoration: line-through; }
|
||||
.taskTop { display: flex; justify-content: space-between; gap: 18px; }
|
||||
.task h3 { font-size: 24px; margin: 0 0 8px; }
|
||||
.task p { margin: 0; color: #596579; line-height: 1.5; }
|
||||
.pill { align-self: start; padding: 8px 12px; border-radius: 999px; font-weight: 900; text-transform: uppercase; font-size: 12px; }
|
||||
.pill.high { color: #a8122a; background: #ffe1e7; }
|
||||
.pill.normal { color: #27259a; background: #e7e8ff; }
|
||||
.pill.low { color: #0c6b42; background: #ddf7e9; }
|
||||
.meta { display: flex; flex-wrap: wrap; gap: 14px; margin: 18px 0; color: #5f6b80; }
|
||||
.overdue { color: #c82424 !important; }
|
||||
.actions { display: flex; gap: 12px; }
|
||||
.actions button { background: #23345f; }
|
||||
.actions .delete { background: #f1f3f9; color: #b51f30; }
|
||||
.empty { padding: 34px; text-align: center; color: #64708a; font-weight: 700; }
|
||||
|
||||
@media (max-width: 950px) {
|
||||
.hero { grid-template-columns: 1fr; padding: 30px; }
|
||||
.stats { grid-template-columns: repeat(2, 1fr); }
|
||||
.taskForm { grid-template-columns: 1fr; }
|
||||
.taskForm textarea { grid-column: auto; }
|
||||
.panelHeader { flex-direction: column; }
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user