commit 80b6f3b9020fc78b00a179df2ddbc7d2cfa270fa Author: Ryan Chen Date: Thu Sep 25 20:25:18 2025 -0400 Initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87d258c --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +__pycache__/ +*.db + +*node_modules/* + +uploads/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c344697 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +# Multi-stage build for React frontend and Python backend +FROM node:18-alpine as frontend-builder + +WORKDIR /app/frontend +COPY ppq-frontend/package.json ppq-frontend/yarn.lock ./ +RUN yarn install --frozen-lockfile + +COPY ppq-frontend/ ./ +RUN yarn build + +# Python backend stage +FROM python:3.13-slim + +WORKDIR /app + +# Install system dependencies for Pillow and HEIF support +RUN apt-get update && apt-get install -y \ + gcc \ + libjpeg-dev \ + zlib1g-dev \ + libffi-dev \ + libheif-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +# Copy Python dependencies and install +COPY pyproject.toml ./ +RUN pip install --no-cache-dir -e . + +# Install additional dependencies based on imports +RUN pip install --no-cache-dir \ + flask==3.1.2 \ + flask-cors==6.0.1 \ + pillow==11.3.0 \ + pillow-heif==1.1.0 \ + werkzeug==3.1.3 + +# Copy Python application files +COPY *.py ./ +COPY schemas/ ./schemas/ + +# Copy built React frontend from builder stage +COPY --from=frontend-builder /app/frontend/dist ./ppq-frontend/dist + +# Create uploads directory +RUN mkdir -p uploads + +# Expose port +EXPOSE 5000 + +# Set environment variables +ENV FLASK_APP=main.py +ENV FLASK_ENV=production + +# Run the application +CMD ["python", "-m", "flask", "run", "--host=0.0.0.0", "--port=5000"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0e1e883 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# ppq! + +This is a rewrite of [petpicturequeue](https://git.torrtle.co/ryan/petpicturequeue), which I slop-coded a while back. I felt a little guilty and also wanted to wrrite something that I could actually work on later and feel confident using. Thus, ppq! was born. + +The only files generated by AI are the Dockerfile and docker-compose.yml. Claude Code was consulted for general questions but all the code was hand-written. + +In the end, I found that writing this did not take much longer than writing this purely using Claude Code with the added benefit tthat I actually understand what is going on in the codebase! diff --git a/database.py b/database.py new file mode 100644 index 0000000..ce91f89 --- /dev/null +++ b/database.py @@ -0,0 +1,8 @@ +import os +import sqlite3 + +class Database: + @staticmethod + def get_connection(): + database_file = os.getenv("PPQ_DATABASE_PATH", "ppq.db") + return sqlite3.connect(database_file) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f61bb9e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +version: '3.8' + +services: + ppq-app: + build: . + ports: + - "5000:5000" + volumes: + - ./uploads:/app/uploads + - ./ppq.db:/app/ppq.db + environment: + - FLASK_APP=main.py + - FLASK_ENV=production + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5000/api/pictures"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s \ No newline at end of file diff --git a/images.py b/images.py new file mode 100644 index 0000000..14501af --- /dev/null +++ b/images.py @@ -0,0 +1,74 @@ +from datetime import datetime +import typing +from uuid import UUID, uuid4 + +from database import Database + +class PetPicture: + uuid: str + title: str + contributor: str + created_at: int + posted: bool + filepath: str + description: str + + def __init__(self, title, contributor, created_at, filepath, description, uuid=None, posted=False): + self.uuid = str(uuid4()) if uuid is None else uuid + self.title = title + self.contributor = contributor + self.created_at = created_at + self.posted = posted + self.filepath = filepath + self.description = description + + def get_json(self): + return { + "uuid": self.uuid, + "title": self.title, + "contributor": self.contributor, + "description": self.description, + "created_at": self.created_at, + "posted": self.posted, + "filepath": self.filepath, + } + + def upsert(self): + conn = Database.get_connection() + c = conn.cursor() + save_string = """INSERT INTO PetPictures (uuid, title, contributor, description, created_at, posted, filepath) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (uuid) DO UPDATE SET title = excluded.title, contributor = excluded.contributor, created_at = excluded.created_at, posted = excluded.posted, filepath = excluded.filepath, description = excluded.description""" + c.execute(save_string, (self.uuid, self.title, self.contributor, self.description, self.created_at, self.posted, self.filepath,)) + conn.commit() + conn.close() + + @staticmethod + def from_row(db_row): + return PetPicture(uuid=db_row[0], title=db_row[1], contributor=db_row[2], description=db_row[3], created_at=db_row[4], filepath=db_row[6], posted=db_row[5]) + + @staticmethod + def get(uuid: str): + conn = Database.get_connection() + c = conn.cursor() + FETCH_QUERY = "SELECT * FROM PetPictures WHERE UUID=?" + c.execute(FETCH_QUERY, (uuid,)) + row = c.fetchone() + return PetPicture.from_row(row) + + @staticmethod + def get_all(): + conn = Database.get_connection() + c = conn.cursor() + FETCH_QUERY = "SELECT * FROM PetPictures ORDER BY created_at DESC" + c.execute(FETCH_QUERY) + rows = c.fetchall() + conn.close() + return [ + PetPicture.from_row(row) for row in rows + ] + + + + + diff --git a/main.py b/main.py new file mode 100644 index 0000000..722d656 --- /dev/null +++ b/main.py @@ -0,0 +1,146 @@ +from datetime import datetime +import os + +from flask import Flask, jsonify, request, send_from_directory, render_template +from flask_cors import CORS +from PIL import Image +from pillow_heif import register_heif_opener +from werkzeug.utils import secure_filename + +from images import PetPicture + +app = Flask(__name__, static_folder="ppq-frontend/dist/static", template_folder="ppq-frontend/dist") +app.config['UPLOAD_FOLDER'] = 'uploads' +os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + +CORS(app) + +register_heif_opener() + + +def allowed_file(filename): + ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'heic'} + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + +def allowed_file(filename): + ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'heic', 'heif'} + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + +def is_heic_file(filename, mimetype): + """Check if file is HEIC/HEIF""" + extension = filename.rsplit('.', 1)[1].lower() if '.' in filename else '' + return extension in ['heic', 'heif'] or mimetype in ['image/heic', 'image/heif'] + +def convert_heic_image(file_stream, output_path, quality=85): + """Convert HEIC image from file stream""" + try: + # Open image from stream + image = Image.open(file_stream) + + # Convert to RGB if needed (HEIC can have different color modes) + if image.mode not in ['RGB', 'L']: + image = image.convert('RGB') + + # Save as JPEG + image.save(output_path, 'JPEG', quality=quality, optimize=True) + return True, None + except Exception as e: + return False, str(e) + + +# Serve React static files +@app.route('/static/') +def static_files(filename): + return send_from_directory(app.static_folder, filename) + +# Serve the React app for all routes (catch-all) +@app.route('/', defaults={'path': ''}) +@app.route('/') +def serve_react_app(path): + if path and os.path.exists(os.path.join(app.template_folder, path)): + return send_from_directory(app.template_folder, path) + return render_template('index.html') + +# List pictures +# Create a picture entry +# Edit a picture metadata +@app.route("/api/pictures", methods=["GET", "POST", "PATCH"]) +def list_pictures(): + if request.method == "GET": + return jsonify([pp.get_json() for pp in PetPicture.get_all()]) + elif request.method == "POST": + title = request.form.get("title") + contributor = request.form.get("contributor") + description = request.form.get("description") + image = request.files.get("image") + timestamp = int(datetime.now().timestamp()) + + # Validate + if image is None: + return jsonify({"error": "No file provided"}), 400 + + if image and allowed_file(image.filename): + # Check if it's HEIC + if is_heic_file(image.filename, image.mimetype): + # Convert HEIC to JPEG + original_name = image.filename.rsplit('.', 1)[0] + filename = f"{timestamp}_{original_name}.jpg" + output_path = os.path.join(app.config['UPLOAD_FOLDER'], filename) + + # Reset file stream position + image.stream.seek(0) + + success, error_msg = convert_heic_image(image.stream, output_path) + + if not success: + return jsonify({'error': f'HEIC conversion failed: {error_msg}'}), 500 + + else: + # Secure the filename + filename = secure_filename(image.filename) + + # Save the file + image.save(os.path.join('uploads', filename)) + + pic = PetPicture(title=title, + contributor=contributor, + created_at=timestamp, + filepath=filename, + description=description) + pic.upsert() + + return jsonify({'message': 'File uploaded successfully', 'filename': filename}) + + return jsonify({'error': 'Something went wrong'}), 400 + + +@app.route('/uploads/') +def uploaded_file(filename): + return send_from_directory(app.config['UPLOAD_FOLDER'], filename) + +@app.route("/api/pictures/", methods=["GET", "PATCH"]) +def single_picture(uuid): + if request.method == "GET": + pet_picture = PetPicture.get(uuid=uuid) + return jsonify(pet_picture.get_json()) + elif request.method == "PATCH": + data = request.get_json() + print(data) + pic = PetPicture(uuid=uuid, + title=data.get("title"), + contributor=data.get("contributor"), + created_at=data.get("created_at"), + filepath=data.get("filepath"), + description=data.get("description")) + pic.upsert() + return jsonify(pic.get_json()), 200 + + +def main(): + print("Hello from ppq!") + + +if __name__ == "__main__": + main() diff --git a/ppq-frontend/.gitignore b/ppq-frontend/.gitignore new file mode 100644 index 0000000..6f3092c --- /dev/null +++ b/ppq-frontend/.gitignore @@ -0,0 +1,16 @@ +# Local +.DS_Store +*.local +*.log* + +# Dist +node_modules +dist/ + +# Profile +.rspack-profile-*/ + +# IDE +.vscode/* +!.vscode/extensions.json +.idea diff --git a/ppq-frontend/README.md b/ppq-frontend/README.md new file mode 100644 index 0000000..a80dc73 --- /dev/null +++ b/ppq-frontend/README.md @@ -0,0 +1,36 @@ +# Rsbuild project + +## Setup + +Install the dependencies: + +```bash +pnpm install +``` + +## Get started + +Start the dev server, and the app will be available at [http://localhost:3000](http://localhost:3000). + +```bash +pnpm dev +``` + +Build the app for production: + +```bash +pnpm build +``` + +Preview the production build locally: + +```bash +pnpm preview +``` + +## Learn more + +To learn more about Rsbuild, check out the following resources: + +- [Rsbuild documentation](https://rsbuild.rs) - explore Rsbuild features and APIs. +- [Rsbuild GitHub repository](https://github.com/web-infra-dev/rsbuild) - your feedback and contributions are welcome! diff --git a/ppq-frontend/biome.json b/ppq-frontend/biome.json new file mode 100644 index 0000000..53f38c7 --- /dev/null +++ b/ppq-frontend/biome.json @@ -0,0 +1,34 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "formatter": { + "indentStyle": "space" + }, + "javascript": { + "formatter": { + "quoteStyle": "single" + } + }, + "css": { + "parser": { + "cssModules": true + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + } +} diff --git a/ppq-frontend/package.json b/ppq-frontend/package.json new file mode 100644 index 0000000..66060e8 --- /dev/null +++ b/ppq-frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "ppq-frontend", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "rsbuild build", + "check": "biome check --write", + "dev": "rsbuild dev --open", + "format": "biome format --write", + "preview": "rsbuild preview" + }, + "dependencies": { + "axios": "^1.12.2", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-hot-toast": "^2.6.0", + "react-router": "^7.9.2" + }, + "devDependencies": { + "@biomejs/biome": "2.2.3", + "@rsbuild/core": "^1.5.6", + "@rsbuild/plugin-react": "^1.4.0", + "@tailwindcss/postcss": "^4.1.13", + "@types/react": "^19.1.13", + "@types/react-dom": "^19.1.9", + "tailwindcss": "^4.1.13", + "typescript": "^5.9.2" + } +} diff --git a/ppq-frontend/postcss.config.mjs b/ppq-frontend/postcss.config.mjs new file mode 100644 index 0000000..a34a3d5 --- /dev/null +++ b/ppq-frontend/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + '@tailwindcss/postcss': {}, + }, +}; diff --git a/ppq-frontend/rsbuild.config.ts b/ppq-frontend/rsbuild.config.ts new file mode 100644 index 0000000..c536c5d --- /dev/null +++ b/ppq-frontend/rsbuild.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "@rsbuild/core"; +import { pluginReact } from "@rsbuild/plugin-react"; + +export default defineConfig({ + plugins: [pluginReact()], + html: { + title: "ppq!", + }, +}); diff --git a/ppq-frontend/src/App.css b/ppq-frontend/src/App.css new file mode 100644 index 0000000..881fd1e --- /dev/null +++ b/ppq-frontend/src/App.css @@ -0,0 +1,11 @@ +@import 'tailwindcss'; + +body { + margin: 0; + font-family: Inter, Avenir, Helvetica, Arial, sans-serif; +} + +.content { + min-height: 100vh; + line-height: 1.1; +} diff --git a/ppq-frontend/src/App.tsx b/ppq-frontend/src/App.tsx new file mode 100644 index 0000000..14a0d4f --- /dev/null +++ b/ppq-frontend/src/App.tsx @@ -0,0 +1,38 @@ +import "./App.css"; + +import { useState } from "react"; + +import { BrowserRouter, Routes, Route, Link } from "react-router"; +import { Toaster } from "react-hot-toast"; + +import { CreateEditPostPage } from "./CreateEditPostPage.tsx"; +import { ListPage } from "./ListPage"; + +const App = () => { + return ( +
+ +
+
+

ppq

+ +

upload a new picture

+ +
+ + } /> + } + /> + } + /> + +
+
+ ); +}; + +export default App; diff --git a/ppq-frontend/src/Card.tsx b/ppq-frontend/src/Card.tsx new file mode 100644 index 0000000..1324be7 --- /dev/null +++ b/ppq-frontend/src/Card.tsx @@ -0,0 +1,75 @@ +import { useEffect, useState } from "react"; + +import { NavLink, Link } from "react-router"; + +type CardProps = { + title: string; + description: string; + timestamp: number; + contributor: string; + uuid: string; + filepath: string; +}; + +export const Card = ({ + title, + description, + timestamp, + contributor, + uuid, + filepath, +}: CardProps) => { + const [showMenu, setShowMenu] = useState(false); + console.log(filepath); + return ( +
+
+ +

{title}

+

+ submitted {timestamp} by{" "} + {contributor} +

+

+ Description: + {description} +

+
+
+
+
+

+ like +

+
+ +
setShowMenu(!showMenu)} + > +

+ menu +

+
+
+ {showMenu && ( + <> + +
+

+ edit +

+
+ +
+

+ mark as posted +

+
+ + )} +
+
+
+ ); +}; diff --git a/ppq-frontend/src/CreateEditPostPage.tsx b/ppq-frontend/src/CreateEditPostPage.tsx new file mode 100644 index 0000000..ebbb7c7 --- /dev/null +++ b/ppq-frontend/src/CreateEditPostPage.tsx @@ -0,0 +1,229 @@ +import { useEffect, useState, ChangeEvent } from "react"; + +import axios from "axios"; +import toast from "react-hot-toast"; +import { useNavigate, useParams } from "react-router"; + +type CreateEditProps = { + isCreate?: boolean; +}; + +export const CreateEditPostPage = ({ isCreate = false }: CreateEditProps) => { + let { uuid } = useParams(); + let navigate = useNavigate(); + const [title, setTitle] = useState(""); + const [contributor, setContributor] = useState(""); + const [description, setDescription] = useState(""); + const [filepath, setFilepath] = useState(""); + const [immutableProperties, setImmutableProperties] = useState({}); + const [loading, setLoading] = useState(false); + const [uploadFile, setUploadFile] = useState(null); + const [uploadedImageURI, setUploadedImageURI] = useState(""); + + const [titleError, setTitleError] = useState(false); + const [contributorError, setContributorError] = useState(false); + const [descriptionError, setDescriptionError] = useState(false); + const [fileError, setFileError] = useState(false); + + const headerTitle = isCreate ? "Upload a pet photo" : "Edit a post"; + const headerDescription = isCreate + ? "Add a description" + : "Edit the description"; + + const handleTitleChange = (event: ChangeEvent) => { + const title = event.target.value; + setTitle(event.target.value); + setTitleError(title.length == 0); + }; + + const handleContributorChange = (event: ChangeEvent) => { + const contributor = event.target.value; + setContributor(event.target.value); + setContributorError(contributor.length == 0); + }; + + const handleDescriptionChange = (event: ChangeEvent) => { + const description = event.target.value; + setDescription(description); + setDescriptionError(description.length == 0); + }; + + const validateForm = () => { + if (isCreate) { + let isValid = true; + if (titleError || title.length == 0) { + setTitleError(true); + isValid = false; + } + + if (contributorError || contributor.length == 0) { + setContributorError(true); + isValid = false; + } + + if (descriptionError || description.length == 0) { + setDescriptionError(true); + isValid = false; + } + + if (fileError || uploadedImageURI.length == 0) { + setDescriptionError(true); + isValid = false; + } + + return isValid; + } else { + let isValid = true; + if (titleError || title.length == 0) { + setTitleError(true); + isValid = false; + } + + if (descriptionError || description.length == 0) { + setDescriptionError(true); + isValid = false; + } + return isValid; + } + }; + + const handleSubmit = () => { + if (!validateForm()) { + return; + } + + if (!isCreate) { + const payload = { + title: title, + description: description, + ...immutableProperties, + }; + + axios + .patch(`/api/pictures/${uuid}`, payload) + .then((response) => navigate("/")); + } else { + const formData = new FormData(); + formData.append("title", title); + formData.append("contributor", contributor); + formData.append("description", description); + formData.append("title", title); + + formData.append("image", uploadFile); + + axios + .post(`/api/pictures`, formData) + .then((result) => navigate("/")); + } + }; + + const handleFileChange = (event) => { + const file = event.target.files[0]; // Get the first file + setUploadFile(file); + if (file) { + setUploadedImageURI(URL.createObjectURL(event.target.files[0])); + } else { + setUploadedImageURI(""); + } + }; + + useEffect(() => { + if (!isCreate) { + axios.get(`/api/pictures/${uuid}`).then((result) => { + const picture = result.data; + setTitle(picture.title); + setDescription(picture.description); + setContributor(picture.contributor); + setFilepath(picture.filepath); + setImmutableProperties({ + uuid: picture.uuid, + contributor: picture.contributor, + created_at: picture.created_at, + posted: picture.posted, + filepath: picture.filepath, + }); + }); + } else { + setTitle(""); + setDescription(""); + setContributor(""); + setFilepath(""); + } + }, []); + + return ( +
+
+
+

{headerTitle}

+ {isCreate ? ( +
+ +

Upload a file

+ +
+ ) : ( + + )} +
+

Title:

+ +
+
+

Contributor:

+ {isCreate ? ( + + ) : ( + + )} +
+
+

{headerDescription}

+