Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
e611f59
Add poll CRUD, voting, comments, image upload, remix endpoints
daniellauding Feb 24, 2026
6d70884
Add Netlify SPA redirects for React Router
daniellauding Feb 27, 2026
f553e53
feat: full frontend overhaul + teams/projects backend
daniellauding Feb 27, 2026
9f9edfe
fix: auto-convert Figma URLs to embed format
daniellauding Feb 27, 2026
35945dc
feat: poll admin settings — close, deadline, show winner
daniellauding Feb 27, 2026
9445d07
feat: video/audio upload, anonymous voting, media preview
daniellauding Feb 27, 2026
c3c7c1d
feat: add Zustand store, custom hooks, embed fallback, README
daniellauding Feb 27, 2026
7c2363c
test: add 28 unit tests for utils, hooks, and Zustand store
daniellauding Mar 2, 2026
604a481
fix: Lighthouse a11y + presentation slides
daniellauding Mar 3, 2026
b61b31c
cleanup: flat design, remove console.error, remove AI comment
daniellauding Mar 4, 2026
026dda0
Polish and harden for final demo
daniellauding Mar 6, 2026
9f40968
Fix a11y alerts and add selection color
daniellauding Mar 6, 2026
2e77c58
Boost Lighthouse: heading font on buttons, touch targets, robots.txt
daniellauding Mar 6, 2026
4b78d5c
Landing page: animated illustrations, lavender primary, bigger buttons
daniellauding Mar 6, 2026
7a84dab
Skeuomorphic pill buttons, white text, bigger hero CTA
daniellauding Mar 6, 2026
12b3ca8
Fix cursor pointer on buttons, more horizontal padding
daniellauding Mar 6, 2026
ed7ccfe
Better CTA copy — design feedback, not generic polls
daniellauding Mar 6, 2026
9921b26
Match results bar colors to primary with ranked opacity
daniellauding Mar 6, 2026
fc41284
Switch button font to Apercu (body font) for readability
daniellauding Mar 6, 2026
71c1e62
Add subtle text shadow to buttons
daniellauding Mar 6, 2026
32a5960
Fix poll settings: save all fields on create, respect showWinner
daniellauding Mar 6, 2026
c258d8a
Upload progress bars, reorder create flow, favicon + PWA icons
daniellauding Mar 6, 2026
e55ab55
Render .md, .txt, .csv files inline so voters can read content
daniellauding Mar 6, 2026
13a4e15
Skip Cloudinary resize for SVG and animated GIF uploads
daniellauding Mar 6, 2026
d7862cf
Add floating pastel cursors with click animation to hero
daniellauding Mar 6, 2026
0e016a7
Fix dashboard behind header, hide unlisted from Recent, cursor animat…
daniellauding Mar 6, 2026
47cc82f
Rename cursor labels to Designer, Client, Developer
daniellauding Mar 6, 2026
3eb1b47
Soften cursor labels to Mia, Alex, Sam
daniellauding Mar 6, 2026
caaf516
Stack hero buttons on mobile, replace step illustrations with screens…
daniellauding Mar 6, 2026
17f8ba6
Use real screenshots inside CardStack animation, keep VotingBars
daniellauding Mar 6, 2026
fa9d871
Improve non-embeddable URL fallback with favicon and cleaner card UI
daniellauding Mar 6, 2026
a82e49a
Remove Figma from embeddable domains — their embed endpoint is unreli…
daniellauding Mar 6, 2026
1e528f9
Add Figma embed support and more AI/deploy platform domains
daniellauding Mar 6, 2026
bc9061f
Remove Figma from embeddable domains (CSP blocks iframe)
daniellauding Mar 6, 2026
af3ca7e
Re-add Figma embed support via official /embed endpoint
daniellauding Mar 6, 2026
8ec7d61
Fix Figma embed: only *.figma.site allows iframes
daniellauding Mar 6, 2026
d463370
Add sandbox to embed iframes to prevent top-frame navigation
daniellauding Mar 6, 2026
f22c0c1
Show actual error message when anonymous vote fails
daniellauding Mar 6, 2026
38ddc84
Add deep-link to specific poll option via ?option=N
daniellauding Mar 6, 2026
335a98a
Redesign landing: hero poll fade-out, focus carousel, click ripples
daniellauding Mar 6, 2026
1560b62
Add textContent, coverUrl, option URL routing, and better thumbnails
daniellauding Mar 6, 2026
e20b2fc
Add notifications MVP and update landing copy
daniellauding Mar 6, 2026
77084fd
Add Slack + AI Integration docs, update existing Obsidian vault
daniellauding Mar 6, 2026
6135328
Fix notifications, remix hooks crash, cursor, and add roadmap doc
daniellauding Mar 6, 2026
6c2c602
Fix logout crash and notification 401 errors
daniellauding Mar 6, 2026
c989376
Improve vote error messages to be user-friendly
daniellauding Mar 6, 2026
6ca4451
Update dashboard copy and improve error messages
daniellauding Mar 6, 2026
ef541dc
Fix custom cursor: pointer fallback on interactive elements
daniellauding Mar 6, 2026
0ecd18a
Fix dashboard header overlap and silence 401 notification errors
daniellauding Mar 6, 2026
6f3c71e
Fix thumbnails on welcome page, anonymous voting, and update copy
daniellauding Mar 6, 2026
ca22fc3
Update landing copy: broader than design, include code
daniellauding Mar 6, 2026
560f9aa
Add notifications for anonymous votes
daniellauding Mar 6, 2026
e1fe726
Markdown comments, better notifications, copy tweaks
daniellauding Mar 6, 2026
9259b68
Markdown comments, remix UX, parent-child linking
daniellauding Mar 6, 2026
0554e5f
Add Explore gallery page, remix badges on cards
daniellauding Mar 6, 2026
51d0db6
Update landing copy: remove design-specific wording
daniellauding Mar 6, 2026
1cd46b9
Beta deadline, PostHog analytics, poll thumbnails, OG tags
daniellauding Mar 18, 2026
795ef36
Add cover image size guidance and file validation
daniellauding Mar 18, 2026
d53b751
Fix thumbnail not showing on cards and Recent carousel
daniellauding Mar 18, 2026
83a787c
Add 4-card slider to Step 1 illustration
daniellauding Mar 18, 2026
9f096c8
Add new Step 1 illustration images and update existing
daniellauding Mar 18, 2026
80ef194
Fix embed-only polls showing broken text fallback in cards
daniellauding Mar 18, 2026
7beef90
Widen hero poll card for better cover image display
daniellauding Mar 18, 2026
4eb2e6b
Add thumbnailUrl support to Dashboard cards
daniellauding Mar 18, 2026
7628138
Fix OG image dimensions and use large image card for Slack/Twitter
daniellauding Mar 18, 2026
49fdb70
Fix vote counts, intro animation, cookie consent, remix indicator
daniellauding Mar 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,12 @@ npm-debug.log*
yarn-debug.log*
yarn-error.log*

package-lock.json
package-lock.json

# Build output
frontend/dist

# Presentation files
*.pptx
make_pptx.py
presentation.html
1 change: 0 additions & 1 deletion Procfile

This file was deleted.

162 changes: 156 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,163 @@
# Final Project
# Pejla

Replace this readme with your own information about your project.
A full-stack design feedback platform where users create polls with design alternatives, vote, comment, remix, and collaborate in teams.

Start by briefly describing the assignment in a sentence or two. Keep it short and to the point.
**Live:** [Frontend](https://pejla.io) | [Backend API](https://pejla-api.onrender.com)

## The problem
## The Problem

Describe how you approached to problem, and what tools and techniques you used to solve it. How did you plan? What technologies did you use? If you had more time, what would be next?
Designers need quick, structured feedback on their work. Existing tools are either too heavyweight (Figma comments) or too generic (Google Forms). Pejla makes it simple: upload designs, share a link, get votes.

I built this as my final project for the Technigo Fullstack JavaScript programme. It started with basic poll CRUD and grew into a platform with embeds, media upload, remix trees, teams, and anonymous voting.

## Features

- **Create polls** with images, video, audio, or embedded content (Figma, YouTube, CodePen, etc.)
- **Vote** on design alternatives with fullscreen swipe UI
- **Comment** on polls with inline discussion
- **Remix** existing polls to create variations (shown as a tree, not duplicates)
- **Teams & Projects** backend infrastructure (Figma-inspired collaboration)
- **Anonymous voting** — poll creators can allow votes without login
- **Password protection** — restrict access to specific polls
- **Poll lifecycle** — open/close polls, set deadlines, show/hide winner
- **Report system** — flag inappropriate content for admin review
- **Responsive** — works on mobile (320px) to desktop (1600px+)

## Tech Stack

### Frontend
- **React 18** with TypeScript
- **React Router 7** — client-side navigation
- **Zustand** — global state management for polls (store)
- **Context API** — authentication state (AuthContext)
- **Tailwind CSS 4** + **shadcn/ui** (Radix UI primitives)
- **Framer Motion** — animations
- **Sonner** — toast notifications
- **Lucide React** — icon library
- **React Dropzone** — drag-and-drop file upload
- **Storybook** — component development and testing

### Backend
- **Node.js** with **Express**
- **MongoDB** via **Mongoose**
- **bcrypt** — password hashing
- **Cloudinary** + **Multer** — image/video/audio upload
- **RESTful API** with token-based authentication

## React Hooks Used

### Standard hooks
- `useState` — local component state
- `useEffect` — side effects (data fetching, event listeners)
- `useContext` — consuming AuthContext

### React Router hooks
- `useParams` — reading URL parameters (shareId, pollId)
- `useNavigate` — programmatic navigation
- `useLocation` — reading current path (deep link login)
- `useSearchParams` — reading query params (remix flow)

### Custom hooks (outside curriculum)
- **`useDebounce(value, delay)`** — delays updates until user stops typing. Used for embed URL preview so the iframe doesn't reload on every keystroke.
- **`useMediaQuery(query)`** — tracks CSS media query state. Enables responsive logic in JavaScript (e.g. mobile vs desktop layout decisions).
- **`useAuth()`** — wraps `useContext(AuthContext)` with error boundary.

### Zustand store
- **`usePollStore()`** — global poll state with `fetchPolls`, `deletePoll`, `reset`. Used in Home and Dashboard to share poll data without prop drilling.

## External Libraries (beyond React/Express/MongoDB)

| Library | Purpose |
|---------|---------|
| **zustand** | Global state management (poll store) |
| **framer-motion** | Animations and transitions |
| **sonner** | Toast notifications |
| **lucide-react** | SVG icon components |
| **react-dropzone** | Drag-and-drop file uploads |
| **cloudinary** + **multer-storage-cloudinary** | Cloud media storage |
| **bcrypt** | Secure password hashing |
| **shadcn/ui** (radix-ui, class-variance-authority, tailwind-merge) | Accessible UI components |

## API Endpoints

### Auth
| Method | Path | Description |
|--------|------|-------------|
| POST | `/users` | Register |
| POST | `/sessions` | Login |
| GET | `/users/me` | Get profile (auth) |
| PATCH | `/users/me` | Update profile (auth) |
| DELETE | `/users/me` | Delete account (auth) |

### Polls
| Method | Path | Description |
|--------|------|-------------|
| GET | `/polls` | List public polls |
| GET | `/polls/:shareId` | Get specific poll (password check) |
| POST | `/polls` | Create poll (auth) |
| PATCH | `/polls/:id` | Update poll (auth, owner) |
| DELETE | `/polls/:id` | Delete poll (auth, owner) |
| POST | `/polls/:id/vote` | Vote (auth) |
| POST | `/polls/:id/vote-anonymous` | Anonymous vote |
| POST | `/polls/:id/unvote` | Remove vote (auth) |
| POST | `/polls/:id/remix` | Remix poll (auth) |

### Comments
| Method | Path | Description |
|--------|------|-------------|
| GET | `/polls/:id/comments` | Get comments |
| POST | `/polls/:id/comments` | Add comment (auth) |
| DELETE | `/comments/:id` | Delete comment (auth, owner) |

### Teams & Projects
| Method | Path | Description |
|--------|------|-------------|
| POST | `/teams` | Create team (auth) |
| GET | `/teams` | Get my teams (auth) |
| POST | `/teams/join` | Join via invite code (auth) |
| POST | `/teams/:id/invite` | Invite by username (admin) |
| POST | `/teams/:teamId/projects` | Create project (auth) |
| POST | `/projects/:id/polls` | Add poll to project (auth) |

### Admin
| Method | Path | Description |
|--------|------|-------------|
| GET | `/admin/reports` | View reports (admin) |
| PATCH | `/admin/reports/:id` | Update report status (admin) |

## Getting Started

### Backend
```bash
cd backend
npm install
# Create .env with MONGO_URL, CLOUDINARY_CLOUD_NAME, CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET
npm run dev
```

### Frontend
```bash
cd frontend
npm install
# Create .env with VITE_API_URL=http://localhost:8080
npm run dev
```

### Storybook
```bash
cd frontend
npm run storybook
```

## Architecture Decisions

- **Zustand + Context API**: Zustand handles poll data (global, shared between pages), Context handles auth (needs to wrap the entire app with Provider pattern).
- **Embed URL utility**: Many sites block iframes (X-Frame-Options). The `toEmbedUrl()` utility converts known services to embed format, and shows a "open in new tab" fallback for unsupported sites.
- **Remix tree**: Remixes are hidden from the main feed (`remixedFrom: null` filter) and shown as a tree on the original poll. This prevents feed pollution.
- **Anonymous voting**: Uses a browser fingerprint stored in localStorage for duplicate prevention.
- **Media upload**: Cloudinary with dynamic `resource_type` (image/video/audio). File type detected from mimetype.

## View it live

Every project should be deployed somewhere. Be sure to include the link to the deployed project so that the viewer can click around and see what it's all about.
- **Frontend:** https://pejla.io
- **Backend API:** https://pejla-api.onrender.com
50 changes: 45 additions & 5 deletions backend/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,48 @@
# Backend part of Final Project
# Pejla Backend

This project includes the packages and babel setup for an express server, and is just meant to make things a little simpler to get up and running with.
Express + MongoDB REST API for the Pejla platform.

## Getting Started
## Setup

1. Install the required dependencies using `npm install`.
2. Start the development server using `npm run dev`.
```bash
npm install
cp .env.example .env # fill in your values
npm run dev
```

### Environment variables

| Variable | Description |
|----------|-------------|
| `MONGO_URL` | MongoDB Atlas connection string |
| `CLOUDINARY_CLOUD_NAME` | Cloudinary cloud name |
| `CLOUDINARY_API_KEY` | Cloudinary API key |
| `CLOUDINARY_API_SECRET` | Cloudinary API secret |

## Models

- **User** — username, email, hashed password, role
- **Poll** — title, description, options (image/video/audio/embed), votes, visibility, password, deadline, remix tree
- **Comment** — text, user, poll reference
- **Team** — name, members, invite codes
- **Project** — name, team, polls collection
- **Report** — flagged content for admin review

## API routes

| Area | Example endpoints |
|------|-------------------|
| Auth | `POST /users`, `POST /sessions`, `GET /users/me` |
| Polls | `GET /polls`, `POST /polls`, `PATCH /polls/:id`, `DELETE /polls/:id` |
| Voting | `POST /polls/:id/vote`, `POST /polls/:id/vote-anonymous` |
| Comments | `GET /polls/:id/comments`, `POST /polls/:id/comments` |
| Teams | `POST /teams`, `POST /teams/join`, `POST /teams/:id/invite` |
| Upload | `POST /upload` (image, video, audio via Cloudinary) |

## Tech

- **Express** — routing and middleware
- **Mongoose** — MongoDB ODM
- **bcrypt** — password hashing
- **Cloudinary + Multer** — media upload and storage
- **Babel** — ES module support
44 changes: 44 additions & 0 deletions backend/middleware/upload.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { v2 as cloudinary } from "cloudinary";
import { CloudinaryStorage } from "multer-storage-cloudinary";
import multer from "multer";

cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
});

const storage = new CloudinaryStorage({
cloudinary,
params: async (req, file) => {
const isVideo = file.mimetype.startsWith("video/");
const isAudio = file.mimetype.startsWith("audio/");
const isImage = file.mimetype.startsWith("image/");
const isSvg = file.mimetype === "image/svg+xml";
const isGif = file.mimetype === "image/gif";

const resourceType = isVideo || isAudio ? "video" : isImage ? "image" : "raw";
const transform = isImage && !isSvg && !isGif ? [{ width: 2400, crop: "limit" }] : [];

return {
folder: "pejla",
resource_type: resourceType,
allowed_formats: [
"jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "tiff", "ico", "heic", "heif", "avif",
"mp4", "mov", "webm", "avi", "mkv", "m4v",
"mp3", "wav", "ogg", "m4a", "flac", "aac",
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx",
"md", "txt", "csv", "json", "html", "css", "js", "ts", "jsx", "tsx",
"zip", "sketch", "fig",
],
transformation: transform,
};
},
});

const upload = multer({
storage,
limits: { fileSize: 100 * 1024 * 1024 },
});

export default upload;
39 changes: 39 additions & 0 deletions backend/models/Comment.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import mongoose from "mongoose";

const commentSchema = new mongoose.Schema({
text: {
type: String,
required: true,
minlength: 1,
maxlength: 500
},
user: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
required: true
},
username: {
type: String,
required: true
},
poll: {
type: mongoose.Schema.Types.ObjectId,
ref: "Poll",
required: true
},
optionIndex: {
type: Number,
default: null
},
imageUrl: {
type: String,
default: ""
},
createdAt: {
type: Date,
default: Date.now
}
});

const Comment = mongoose.model("Comment", commentSchema);
export default Comment;
15 changes: 15 additions & 0 deletions backend/models/Notification.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import mongoose from "mongoose";

const notificationSchema = new mongoose.Schema({
user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true, index: true },
type: { type: String, enum: ["vote", "comment", "remix", "mention"], required: true },
poll: { type: mongoose.Schema.Types.ObjectId, ref: "Poll" },
fromUser: { type: mongoose.Schema.Types.ObjectId, ref: "User" },
fromUsername: { type: String, default: "" },
message: { type: String, required: true },
read: { type: Boolean, default: false },
createdAt: { type: Date, default: Date.now }
});

const Notification = mongoose.model("Notification", notificationSchema);
export default Notification;
Loading