Compare commits
2 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
12bf65f865 | ||
![]() |
5eb085067d |
.env.gitignoreDockerfileREADME.md
backend
docker-compose.ymlfrontend
.gitignore.npmrc.prettierignore.prettierrcREADME.mdeslint.config.jspackage-lock.jsonpackage.json
package.jsonserver.jssrc
static
svelte.config.jstsconfig.jsonvite.config.tssrc
config
middleware
routes
services
views
4
.env
4
.env
@ -1,4 +0,0 @@
|
|||||||
INITIAL_ADMIN_USERNAME=admin
|
|
||||||
INITIAL_ADMIN_PASSWORD=admin
|
|
||||||
ALLOWED_ORIGIN=http://127.0.0.1:5500,http://localhost:5500
|
|
||||||
DATABASE_URL=form_data.db
|
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,4 +0,0 @@
|
|||||||
*.env
|
|
||||||
package-lock.json
|
|
||||||
node_modules
|
|
||||||
database.sqlite
|
|
56
Dockerfile
56
Dockerfile
@ -1,36 +1,24 @@
|
|||||||
FROM node:24-alpine AS builder
|
# Stage 1: Build the Svelte frontend
|
||||||
|
FROM node:18 as frontend-builder
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/ .
|
||||||
|
RUN npm install
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
# Stage 2: Build the Rust backend (statically linked)
|
||||||
|
FROM rust:1.83 as backend-builder
|
||||||
|
WORKDIR /app/backend
|
||||||
|
COPY backend/ .
|
||||||
|
# Add the musl target for static linking
|
||||||
|
RUN rustup target add x86_64-unknown-linux-musl
|
||||||
|
# Build the binary with musl
|
||||||
|
RUN cargo build --release --target x86_64-unknown-linux-musl
|
||||||
|
|
||||||
# Install build dependencies for sqlite3
|
# Final Stage
|
||||||
RUN apk add --no-cache python3 make g++ sqlite-dev
|
FROM debian:bullseye-slim
|
||||||
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY --from=frontend-builder /app/frontend/build ./frontend/build
|
||||||
RUN npm ci
|
# Copy the statically linked binary
|
||||||
|
COPY --from=backend-builder /app/backend/target/x86_64-unknown-linux-musl/release/formies_be ./formies_be
|
||||||
COPY . .
|
EXPOSE 8080
|
||||||
|
CMD ["./formies_be"]
|
||||||
FROM node:24-alpine
|
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
|
||||||
|
|
||||||
# Install runtime dependencies for sqlite3
|
|
||||||
RUN apk add --no-cache sqlite-libs python3 make g++ sqlite-dev
|
|
||||||
|
|
||||||
# Create a non-root user
|
|
||||||
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
|
|
||||||
|
|
||||||
COPY --from=builder /usr/src/app/package*.json ./
|
|
||||||
COPY --from=builder /usr/src/app/ ./
|
|
||||||
|
|
||||||
# Rebuild sqlite3 for the target architecture
|
|
||||||
RUN npm rebuild sqlite3
|
|
||||||
|
|
||||||
# Set ownership to non-root user
|
|
||||||
RUN chown -R appuser:appgroup /usr/src/app
|
|
||||||
|
|
||||||
USER appuser
|
|
||||||
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
|
221
README.md
Normal file
221
README.md
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
# Formies
|
||||||
|
|
||||||
|
Formies is a form management tool that allows you to create customizable forms, collect submissions, and view collected data. This project combines a Rust backend and a Svelte frontend, packaged as a single Docker container for easy deployment.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 📝 Form Management
|
||||||
|
|
||||||
|
- Create forms with customizable fields (text, number, date, etc.).
|
||||||
|
- View all created forms in a centralized dashboard.
|
||||||
|
|
||||||
|
### 🗂️ Submissions
|
||||||
|
|
||||||
|
- Submit responses to forms via a user-friendly interface.
|
||||||
|
- View and manage all form submissions.
|
||||||
|
|
||||||
|
### ⚙️ Backend
|
||||||
|
|
||||||
|
- Built with Rust using Actix-Web for high performance and scalability.
|
||||||
|
- Uses SQLite for local data storage with easy migration to PostgreSQL if needed.
|
||||||
|
|
||||||
|
### 🎨 Frontend
|
||||||
|
|
||||||
|
- Built with SvelteKit for a modern and lightweight user experience.
|
||||||
|
- Responsive design for use across devices.
|
||||||
|
|
||||||
|
### 🚀 Deployment
|
||||||
|
|
||||||
|
- Packaged as a single Docker image for seamless deployment.
|
||||||
|
- Supports CI/CD workflows with Gitea Actions, Drone CI, or GitHub Actions.
|
||||||
|
|
||||||
|
## Folder Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
project-root/
|
||||||
|
├── backend/ # Backend codebase
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── handlers.rs # Route handlers for Actix-Web
|
||||||
|
│ │ ├── models.rs # Data models (Form, Submission)
|
||||||
|
│ │ ├── db.rs # Database initialization
|
||||||
|
│ │ ├── main.rs # Main entry point for the backend
|
||||||
|
│ │ └── ... # Additional modules
|
||||||
|
│ ├── Cargo.toml # Backend dependencies
|
||||||
|
│ └── Cargo.lock # Locked dependencies
|
||||||
|
│
|
||||||
|
├── frontend/ # Frontend codebase
|
||||||
|
│ ├── public/ # Built static files (after `npm run build`)
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── lib/ # Shared utilities (e.g., API integration)
|
||||||
|
│ │ ├── routes/ # Svelte pages
|
||||||
|
│ │ │ ├── +page.svelte # Dashboard
|
||||||
|
│ │ │ └── form/ # Form-related pages
|
||||||
|
│ │ └── main.ts # Frontend entry point
|
||||||
|
│ ├── package.json # Frontend dependencies
|
||||||
|
│ ├── svelte.config.js # Svelte configuration
|
||||||
|
│ └── ... # Additional files
|
||||||
|
│
|
||||||
|
├── Dockerfile # Combined Dockerfile for both frontend and backend
|
||||||
|
├── docker-compose.yml # Docker Compose file for deployment
|
||||||
|
├── .gitea/ # Gitea Actions workflows
|
||||||
|
│ └── workflows/
|
||||||
|
│ └── build_and_deploy.yml
|
||||||
|
├── .drone.yml # Drone CI configuration
|
||||||
|
├── README.md # Documentation (this file)
|
||||||
|
└── ... # Other configuration files
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
- Install Docker: [Docker Documentation](https://docs.docker.com/)
|
||||||
|
|
||||||
|
### Rust (for development)
|
||||||
|
|
||||||
|
- Install Rust: [Rustup Installation](https://rustup.rs/)
|
||||||
|
|
||||||
|
### Node.js (for frontend development)
|
||||||
|
|
||||||
|
- Install Node.js: [Node.js Downloads](https://nodejs.org/)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
1. Navigate to the backend/ directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd backend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run the backend server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
The backend will be available at [http://localhost:8080](http://localhost:8080).
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
1. Navigate to the frontend/ directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install dependencies:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the development server:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The frontend will be available at [http://localhost:5173](http://localhost:5173).
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
### Build the Docker Image
|
||||||
|
|
||||||
|
1. Build the combined Docker image:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker build -t your-dockerhub-username/formies-combined .
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Run the Docker container:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker run -p 8080:8080 your-dockerhub-username/formies-combined
|
||||||
|
```
|
||||||
|
|
||||||
|
Access the application at [http://localhost:8080](http://localhost:8080).
|
||||||
|
|
||||||
|
### Using Docker Compose
|
||||||
|
|
||||||
|
1. Deploy using `docker-compose.yml`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Stop the containers:
|
||||||
|
```sh
|
||||||
|
docker-compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD Workflow
|
||||||
|
|
||||||
|
### Gitea Actions
|
||||||
|
|
||||||
|
1. Create a file at `.gitea/workflows/build_and_deploy.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Build and Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Clone the repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Docker
|
||||||
|
uses: docker/setup-buildx-action@v2
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
|
||||||
|
|
||||||
|
- name: Build and Push Docker Image
|
||||||
|
run: |
|
||||||
|
docker build -t your-dockerhub-username/formies-combined .
|
||||||
|
docker push your-dockerhub-username/formies-combined:latest
|
||||||
|
|
||||||
|
- name: Deploy to Server (optional)
|
||||||
|
run: |
|
||||||
|
ssh -o StrictHostKeyChecking=no ${{ secrets.SERVER_USER }}@${{ secrets.SERVER_IP }} << 'EOF'
|
||||||
|
docker pull your-dockerhub-username/formies-combined:latest
|
||||||
|
docker stop formies || true
|
||||||
|
docker rm formies || true
|
||||||
|
docker run -d --name formies -p 8080:8080 your-dockerhub-username/formies-combined:latest
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add secrets in Gitea:
|
||||||
|
- `DOCKER_USERNAME`: Your Docker Hub username.
|
||||||
|
- `DOCKER_PASSWORD`: Your Docker Hub password.
|
||||||
|
- `SERVER_USER`: SSH username for deployment.
|
||||||
|
- `SERVER_IP`: IP address of the server.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
**Base URL:** `http://localhost:8080`
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
| ------ | ----------------------------- | ----------------------------------- |
|
||||||
|
| POST | `/api/forms` | Create a new form |
|
||||||
|
| GET | `/api/forms` | Get all forms |
|
||||||
|
| POST | `/api/forms/{id}/submissions` | Submit data to a form |
|
||||||
|
| GET | `/api/forms/{id}/submissions` | Get submissions for a specific form |
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- **Authentication:** Add user-based authentication for managing forms and submissions.
|
||||||
|
- **Export:** Allow exporting submissions to CSV or Excel.
|
||||||
|
- **Scaling:** Migrate to PostgreSQL for distributed data handling.
|
||||||
|
- **Monitoring:** Integrate tools like Prometheus and Grafana for performance monitoring.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for details.
|
1
backend/.gitignore
vendored
Normal file
1
backend/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target
|
1835
backend/Cargo.lock
generated
Normal file
1835
backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
backend/Cargo.toml
Normal file
18
backend/Cargo.toml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "formies_be"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
actix-web = "4.0"
|
||||||
|
rusqlite = { version = "0.29", features = ["bundled"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
uuid = { version = "1.0", features = ["v4"] }
|
||||||
|
actix-files = "0.6"
|
||||||
|
actix-cors = "0.6"
|
||||||
|
env_logger = "0.10"
|
||||||
|
log = "0.4"
|
||||||
|
futures = "0.3"
|
||||||
|
bcrypt = "0.13"
|
||||||
|
anyhow = "1.0"
|
BIN
backend/form_data.db
Normal file
BIN
backend/form_data.db
Normal file
Binary file not shown.
36
backend/src/auth.rs
Normal file
36
backend/src/auth.rs
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
use actix_web::{dev::Payload, http::header::AUTHORIZATION, web, Error, FromRequest, HttpRequest};
|
||||||
|
use futures::future::{ready, Ready};
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
pub struct Auth {
|
||||||
|
pub user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRequest for Auth {
|
||||||
|
type Error = Error;
|
||||||
|
type Future = Ready<Result<Self, Self::Error>>;
|
||||||
|
|
||||||
|
fn from_request(req: &HttpRequest, _: &mut Payload) -> Self::Future {
|
||||||
|
let db = req
|
||||||
|
.app_data::<web::Data<Arc<Mutex<Connection>>>>()
|
||||||
|
.expect("Database connection missing");
|
||||||
|
|
||||||
|
if let Some(auth_header) = req.headers().get(AUTHORIZATION) {
|
||||||
|
if let Ok(auth_str) = auth_header.to_str() {
|
||||||
|
if auth_str.starts_with("Bearer ") {
|
||||||
|
let token = &auth_str[7..];
|
||||||
|
let conn = db.lock().unwrap();
|
||||||
|
|
||||||
|
match super::db::validate_token(&conn, token) {
|
||||||
|
Ok(Some(user_id)) => return ready(Ok(Auth { user_id })),
|
||||||
|
Ok(None) | Err(_) => {
|
||||||
|
return ready(Err(actix_web::error::ErrorUnauthorized("Invalid token")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ready(Err(actix_web::error::ErrorUnauthorized("Missing token")))
|
||||||
|
}
|
||||||
|
}
|
122
backend/src/db.rs
Normal file
122
backend/src/db.rs
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
use anyhow::{Context, Result as AnyhowResult};
|
||||||
|
use bcrypt::{hash, verify, DEFAULT_COST}; // Add bcrypt dependency for password hashing
|
||||||
|
use rusqlite::{params, Connection, OptionalExtension};
|
||||||
|
use uuid::Uuid; // UUID for generating unique IDs // Import anyhow
|
||||||
|
|
||||||
|
pub fn init_db() -> AnyhowResult<Connection> {
|
||||||
|
let conn = Connection::open("form_data.db").context("Failed to open the database")?;
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS forms (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
fields TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS submissions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
form_id TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (form_id) REFERENCES forms (id) ON DELETE CASCADE
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Add a table for users
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE,
|
||||||
|
password TEXT NOT NULL, -- Store a hashed password
|
||||||
|
token TEXT UNIQUE -- Optional: For token-based auth
|
||||||
|
)",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Setup initial admin after creating the tables
|
||||||
|
setup_initial_admin(&conn)?;
|
||||||
|
|
||||||
|
Ok(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn setup_initial_admin(conn: &Connection) -> AnyhowResult<()> {
|
||||||
|
add_admin_user(conn)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_admin_user(conn: &Connection) -> AnyhowResult<()> {
|
||||||
|
// Check if admin user already exists
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT id FROM users WHERE username = ?1")
|
||||||
|
.context("Failed to prepare query for checking admin user")?;
|
||||||
|
if stmt.exists(params!["admin"])? {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a UUID for the admin user
|
||||||
|
let admin_id = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
// Hash the password before storing it
|
||||||
|
let hashed_password = hash("admin", DEFAULT_COST).context("Failed to hash password")?;
|
||||||
|
|
||||||
|
// Add admin user with hashed password
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users (id, username, password) VALUES (?1, ?2, ?3)",
|
||||||
|
params![admin_id, "admin", hashed_password],
|
||||||
|
)
|
||||||
|
.context("Failed to insert admin user into the database")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a function to validate a token
|
||||||
|
pub fn validate_token(conn: &Connection, token: &str) -> AnyhowResult<Option<String>> {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT id FROM users WHERE token = ?1")
|
||||||
|
.context("Failed to prepare query for validating token")?;
|
||||||
|
let user_id: Option<String> = stmt
|
||||||
|
.query_row(params![token], |row| row.get(0))
|
||||||
|
.optional()
|
||||||
|
.context("Failed to retrieve user ID for the given token")?;
|
||||||
|
Ok(user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a function to authenticate users (for login)
|
||||||
|
pub fn authenticate_user(
|
||||||
|
conn: &Connection,
|
||||||
|
username: &str,
|
||||||
|
password: &str,
|
||||||
|
) -> AnyhowResult<Option<String>> {
|
||||||
|
let mut stmt = conn
|
||||||
|
.prepare("SELECT id, password FROM users WHERE username = ?1")
|
||||||
|
.context("Failed to prepare query for authenticating user")?;
|
||||||
|
let mut rows = stmt
|
||||||
|
.query(params![username])
|
||||||
|
.context("Failed to execute query for authenticating user")?;
|
||||||
|
|
||||||
|
if let Some(row) = rows.next()? {
|
||||||
|
let user_id: String = row.get(0)?;
|
||||||
|
let stored_password: String = row.get(1)?;
|
||||||
|
|
||||||
|
// Use bcrypt to verify the hashed password
|
||||||
|
if verify(password, &stored_password).context("Failed to verify password")? {
|
||||||
|
return Ok(Some(user_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a function to generate and save a token for a user
|
||||||
|
pub fn generate_token_for_user(conn: &Connection, user_id: &str, token: &str) -> AnyhowResult<()> {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE users SET token = ?1 WHERE id = ?2",
|
||||||
|
params![token, user_id],
|
||||||
|
)
|
||||||
|
.context("Failed to update token for user")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
136
backend/src/handlers.rs
Normal file
136
backend/src/handlers.rs
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
use actix_web::{web, HttpResponse, Responder};
|
||||||
|
use rusqlite::{params, Connection};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::auth::Auth;
|
||||||
|
|
||||||
|
// Structs for requests and responses
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LoginRequest {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct LoginResponse {
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public: Login handler
|
||||||
|
pub async fn login(
|
||||||
|
db: web::Data<Arc<Mutex<Connection>>>,
|
||||||
|
login_request: web::Json<LoginRequest>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let conn = db.lock().unwrap();
|
||||||
|
let user_id =
|
||||||
|
match crate::db::authenticate_user(&conn, &login_request.username, &login_request.password)
|
||||||
|
{
|
||||||
|
Ok(Some(user_id)) => user_id,
|
||||||
|
Ok(None) => return HttpResponse::Unauthorized().body("Invalid username or password"),
|
||||||
|
Err(_) => return HttpResponse::InternalServerError().body("Database error"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = Uuid::new_v4().to_string();
|
||||||
|
if let Err(_) = crate::db::generate_token_for_user(&conn, &user_id, &token) {
|
||||||
|
return HttpResponse::InternalServerError().body("Failed to generate token");
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponse::Ok().json(LoginResponse { token })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public: Submit a form
|
||||||
|
pub async fn submit_form(
|
||||||
|
db: web::Data<Arc<Mutex<Connection>>>,
|
||||||
|
path: web::Path<String>,
|
||||||
|
submission: web::Form<serde_json::Value>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let conn = db.lock().unwrap();
|
||||||
|
let submission_id = Uuid::new_v4().to_string();
|
||||||
|
let form_id = path.into_inner();
|
||||||
|
let submission_json = serde_json::to_string(&submission.into_inner()).unwrap();
|
||||||
|
|
||||||
|
match conn.execute(
|
||||||
|
"INSERT INTO submissions (id, form_id, data) VALUES (?1, ?2, ?3)",
|
||||||
|
params![submission_id, form_id, submission_json],
|
||||||
|
) {
|
||||||
|
Ok(_) => HttpResponse::Ok().json(submission_id),
|
||||||
|
Err(e) => HttpResponse::InternalServerError().body(format!("Error: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected: Create a new form
|
||||||
|
pub async fn create_form(
|
||||||
|
db: web::Data<Arc<Mutex<Connection>>>,
|
||||||
|
auth: Auth,
|
||||||
|
form: web::Json<crate::models::Form>,
|
||||||
|
) -> impl Responder {
|
||||||
|
println!("Authenticated user: {}", auth.user_id);
|
||||||
|
let conn = db.lock().unwrap();
|
||||||
|
let form_id = Uuid::new_v4().to_string();
|
||||||
|
let form_json = serde_json::to_string(&form.fields).unwrap();
|
||||||
|
|
||||||
|
match conn.execute(
|
||||||
|
"INSERT INTO forms (id, name, fields) VALUES (?1, ?2, ?3)",
|
||||||
|
params![form_id, form.name, form_json],
|
||||||
|
) {
|
||||||
|
Ok(_) => HttpResponse::Ok().json(form_id),
|
||||||
|
Err(e) => HttpResponse::InternalServerError().body(format!("Error: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected: Get all forms
|
||||||
|
pub async fn get_forms(db: web::Data<Arc<Mutex<Connection>>>, auth: Auth) -> impl Responder {
|
||||||
|
println!("Authenticated user: {}", auth.user_id);
|
||||||
|
let conn = db.lock().unwrap();
|
||||||
|
|
||||||
|
let mut stmt = match conn.prepare("SELECT id, name, fields FROM forms") {
|
||||||
|
Ok(stmt) => stmt,
|
||||||
|
Err(e) => return HttpResponse::InternalServerError().body(format!("Error: {}", e)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let forms_iter = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
let id: Option<String> = row.get(0)?;
|
||||||
|
let name: String = row.get(1)?;
|
||||||
|
let fields: String = row.get(2)?;
|
||||||
|
let fields = serde_json::from_str(&fields).unwrap();
|
||||||
|
Ok(crate::models::Form { id, name, fields })
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let forms: Vec<crate::models::Form> = forms_iter.filter_map(|f| f.ok()).collect();
|
||||||
|
HttpResponse::Ok().json(forms)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected: Get submissions for a form
|
||||||
|
pub async fn get_submissions(
|
||||||
|
db: web::Data<Arc<Mutex<Connection>>>,
|
||||||
|
auth: Auth,
|
||||||
|
path: web::Path<String>,
|
||||||
|
) -> impl Responder {
|
||||||
|
println!("Authenticated user: {}", auth.user_id);
|
||||||
|
let conn = db.lock().unwrap();
|
||||||
|
let form_id = path.into_inner();
|
||||||
|
|
||||||
|
let mut stmt =
|
||||||
|
match conn.prepare("SELECT id, form_id, data FROM submissions WHERE form_id = ?1") {
|
||||||
|
Ok(stmt) => stmt,
|
||||||
|
Err(e) => return HttpResponse::InternalServerError().body(format!("Error: {}", e)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let submissions_iter = stmt
|
||||||
|
.query_map([form_id], |row| {
|
||||||
|
let id: String = row.get(0)?;
|
||||||
|
let form_id: String = row.get(1)?;
|
||||||
|
let data: String = row.get(2)?;
|
||||||
|
let data = serde_json::from_str(&data).unwrap();
|
||||||
|
Ok(crate::models::Submission { id, form_id, data })
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let submissions: Vec<crate::models::Submission> =
|
||||||
|
submissions_iter.filter_map(|s| s.ok()).collect();
|
||||||
|
HttpResponse::Ok().json(submissions)
|
||||||
|
}
|
43
backend/src/main.rs
Normal file
43
backend/src/main.rs
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
use actix_cors::Cors;
|
||||||
|
use actix_files as fs;
|
||||||
|
use actix_web::{web, App, HttpServer};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
mod auth;
|
||||||
|
mod db;
|
||||||
|
mod handlers;
|
||||||
|
mod models;
|
||||||
|
|
||||||
|
#[actix_web::main]
|
||||||
|
async fn main() -> std::io::Result<()> {
|
||||||
|
env_logger::init();
|
||||||
|
let db = Arc::new(Mutex::new(
|
||||||
|
db::init_db().expect("Failed to initialize the database"),
|
||||||
|
));
|
||||||
|
|
||||||
|
HttpServer::new(move || {
|
||||||
|
App::new()
|
||||||
|
.wrap(
|
||||||
|
Cors::default()
|
||||||
|
.allow_any_origin()
|
||||||
|
.allow_any_header()
|
||||||
|
.allow_any_method(),
|
||||||
|
)
|
||||||
|
.app_data(web::Data::new(db.clone()))
|
||||||
|
.service(fs::Files::new("/", "frontend/build").index_file("index.html"))
|
||||||
|
.route("/login", web::post().to(handlers::login)) // Public: Login
|
||||||
|
.route(
|
||||||
|
"/forms/{id}/submissions",
|
||||||
|
web::post().to(handlers::submit_form), // Public: Submit form
|
||||||
|
)
|
||||||
|
.route("/forms", web::post().to(handlers::create_form)) // Protected
|
||||||
|
.route("/forms", web::get().to(handlers::get_forms)) // Protected
|
||||||
|
.route(
|
||||||
|
"/forms/{id}/submissions",
|
||||||
|
web::get().to(handlers::get_submissions), // Protected
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.bind("0.0.0.0:8080")?
|
||||||
|
.run()
|
||||||
|
.await
|
||||||
|
}
|
15
backend/src/models.rs
Normal file
15
backend/src/models.rs
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct Form {
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
pub fields: serde_json::Value, // JSON array of form fields
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
pub struct Submission {
|
||||||
|
pub id: String,
|
||||||
|
pub form_id: String,
|
||||||
|
pub data: serde_json::Value, // JSON of submission data
|
||||||
|
}
|
@ -1,13 +1,9 @@
|
|||||||
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
formies:
|
||||||
image: whtvrboo/formies:1.02
|
image: your-dockerhub-username/formies-combined:latest
|
||||||
container_name: formies
|
container_name: formies-app
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "8080:8080" # Expose the application on port 8080
|
||||||
environment:
|
restart: always
|
||||||
- NODE_ENV=development
|
|
||||||
- NTFY_TOPIC_URL=https://ntfy.vinylnostalgia.com/form-alerts
|
|
||||||
- NTFY_ENABLED=true
|
|
||||||
- PORT=3000
|
|
||||||
volumes:
|
|
||||||
- ./database.sqlite:/usr/src/app/database.sqlite
|
|
||||||
|
23
frontend/.gitignore
vendored
Normal file
23
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
4
frontend/.prettierignore
Normal file
4
frontend/.prettierignore
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# Package Managers
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
15
frontend/.prettierrc
Normal file
15
frontend/.prettierrc
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"useTabs": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "none",
|
||||||
|
"printWidth": 100,
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.svelte",
|
||||||
|
"options": {
|
||||||
|
"parser": "svelte"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
38
frontend/README.md
Normal file
38
frontend/README.md
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
# sv
|
||||||
|
|
||||||
|
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||||
|
|
||||||
|
## Creating a project
|
||||||
|
|
||||||
|
If you're seeing this, you've probably already done this step. Congrats!
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# create a new project in the current directory
|
||||||
|
npx sv create
|
||||||
|
|
||||||
|
# create a new project in my-app
|
||||||
|
npx sv create my-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Developing
|
||||||
|
|
||||||
|
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# or start the server and open the app in a new browser tab
|
||||||
|
npm run dev -- --open
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To create a production version of your app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
You can preview the production build with `npm run preview`.
|
||||||
|
|
||||||
|
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
34
frontend/eslint.config.js
Normal file
34
frontend/eslint.config.js
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import { includeIgnoreFile } from '@eslint/compat';
|
||||||
|
import svelte from 'eslint-plugin-svelte';
|
||||||
|
import globals from 'globals';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import ts from 'typescript-eslint';
|
||||||
|
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||||
|
|
||||||
|
export default ts.config(
|
||||||
|
includeIgnoreFile(gitignorePath),
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.recommended,
|
||||||
|
...svelte.configs['flat/recommended'],
|
||||||
|
prettier,
|
||||||
|
...svelte.configs['flat/prettier'],
|
||||||
|
{
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
...globals.node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.svelte'],
|
||||||
|
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
parser: ts.parser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
3652
frontend/package-lock.json
generated
Normal file
3652
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
frontend/package.json
Normal file
37
frontend/package.json
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "formies-fe",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"lint": "prettier --check . && eslint ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/compat": "^1.2.3",
|
||||||
|
"@sveltejs/adapter-auto": "^3.0.0",
|
||||||
|
"@sveltejs/adapter-node": "^5.2.11",
|
||||||
|
"@sveltejs/kit": "^2.0.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
|
"eslint": "^9.7.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-svelte": "^2.36.0",
|
||||||
|
"globals": "^15.0.0",
|
||||||
|
"prettier": "^3.3.2",
|
||||||
|
"prettier-plugin-svelte": "^3.2.6",
|
||||||
|
"svelte": "^5.0.0",
|
||||||
|
"svelte-check": "^4.0.0",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"typescript-eslint": "^8.0.0",
|
||||||
|
"vite": "^5.4.11"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/uuid": "^10.0.0",
|
||||||
|
"axios": "^1.7.9"
|
||||||
|
}
|
||||||
|
}
|
188
frontend/src/app.css
Normal file
188
frontend/src/app.css
Normal file
@ -0,0 +1,188 @@
|
|||||||
|
/* Reset and base styles */
|
||||||
|
:root {
|
||||||
|
--primary-color: #4a90e2;
|
||||||
|
--secondary-color: #f5f5f5;
|
||||||
|
--border-color: #ddd;
|
||||||
|
--text-color: #333;
|
||||||
|
--error-color: #e74c3c;
|
||||||
|
--success-color: #2ecc71;
|
||||||
|
--shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
|
||||||
|
sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin: 1.5rem 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Links */
|
||||||
|
a {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: #357abd;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lists */
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
form {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type='text'],
|
||||||
|
input[type='number'],
|
||||||
|
input[type='date'],
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 100px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
button {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) {
|
||||||
|
background-color: #357abd;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background-color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary:hover:not(:disabled) {
|
||||||
|
background-color: #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
button + button {
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Field management */
|
||||||
|
.field-container {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submissions */
|
||||||
|
.submissions-list {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submission-item {
|
||||||
|
background-color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility classes */
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: var(--error-color);
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: var(--success-color);
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
13
frontend/src/app.d.ts
vendored
Normal file
13
frontend/src/app.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
12
frontend/src/app.html
Normal file
12
frontend/src/app.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
138
frontend/src/lib/api.ts
Normal file
138
frontend/src/lib/api.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
const API_BASE_URL = 'http://127.0.0.1:8080';
|
||||||
|
|
||||||
|
// A simple function to retrieve the token from local storage or wherever it is stored
|
||||||
|
function getAuthToken(): string | null {
|
||||||
|
return localStorage.getItem('auth_token'); // Assuming the token is stored in localStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
// A simple function to save the token
|
||||||
|
function setAuthToken(token: string): void {
|
||||||
|
localStorage.setItem('auth_token', token);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A simple function to save the token
|
||||||
|
function delAuthToken(): void {
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new form.
|
||||||
|
* @param name The name of the form.
|
||||||
|
* @param fields The fields of the form in JSON format.
|
||||||
|
* @returns The ID of the created form.
|
||||||
|
*/
|
||||||
|
export async function createForm(name: string, fields: unknown): Promise<string> {
|
||||||
|
const token = getAuthToken(); // Get the token from storage
|
||||||
|
const response = await fetch(`${API_BASE_URL}/forms`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}` // Add token to the headers
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name, fields })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Error creating form: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all forms.
|
||||||
|
* @returns An array of forms.
|
||||||
|
*/
|
||||||
|
export async function getForms(): Promise<unknown[]> {
|
||||||
|
const token = getAuthToken(); // Get the token from storage
|
||||||
|
const response = await fetch(`${API_BASE_URL}/forms`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${token}` // Add token to the headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Error fetching forms: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a form.
|
||||||
|
* @param formId The ID of the form to submit.
|
||||||
|
* @param data The submission data in JSON format.
|
||||||
|
* @returns The ID of the created submission.
|
||||||
|
*/
|
||||||
|
export async function submitForm(formId: string, data: unknown): Promise<string> {
|
||||||
|
const token = getAuthToken(); // Get the token from storage
|
||||||
|
const response = await fetch(`${API_BASE_URL}/forms/${formId}/submissions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}` // Add token to the headers
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Error submitting form: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all submissions for a specific form.
|
||||||
|
* @param formId The ID of the form.
|
||||||
|
* @returns An array of submissions for the form.
|
||||||
|
*/
|
||||||
|
export async function getSubmissions(formId: string): Promise<unknown[]> {
|
||||||
|
const token = getAuthToken(); // Get the token from storage
|
||||||
|
const response = await fetch(`${API_BASE_URL}/forms/${formId}/submissions`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
Authorization: `Bearer ${token}` // Add token to the headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Error fetching submissions: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login and get the authentication token.
|
||||||
|
* @param username The username.
|
||||||
|
* @param password The password.
|
||||||
|
* @returns The authentication token.
|
||||||
|
*/
|
||||||
|
export async function login(username: string, password: string): Promise<void> {
|
||||||
|
const response = await fetch(`${API_BASE_URL}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Error logging in: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token } = await response.json();
|
||||||
|
setAuthToken(token); // Store the token in localStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout() {
|
||||||
|
delAuthToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loggedIn() {
|
||||||
|
return localStorage.getItem('auth_token') !== null;
|
||||||
|
}
|
32
frontend/src/lib/components/Dashboard.svelte
Normal file
32
frontend/src/lib/components/Dashboard.svelte
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let forms: any = [];
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const response = await axios.get('http://localhost:8080/forms');
|
||||||
|
forms = response.data;
|
||||||
|
});
|
||||||
|
|
||||||
|
function viewForm(id: number) {
|
||||||
|
goto(`/form/${id}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1>Forms Dashboard</h1>
|
||||||
|
{#if forms.length === 0}
|
||||||
|
<p>No forms available.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{#each forms as form}
|
||||||
|
<li>
|
||||||
|
<h3>{form.name}</h3>
|
||||||
|
<button on:click={() => viewForm(form.id)}>View</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
44
frontend/src/lib/components/FormBuilder.svelte
Normal file
44
frontend/src/lib/components/FormBuilder.svelte
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<script>
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
let formName = '';
|
||||||
|
/**
|
||||||
|
* @type {any[]}
|
||||||
|
*/
|
||||||
|
let fields = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} type
|
||||||
|
*/
|
||||||
|
function addField(type) {
|
||||||
|
fields.push({ label: '', name: '', type });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveForm() {
|
||||||
|
const response = await axios.post('http://localhost:8080/forms', {
|
||||||
|
name: formName,
|
||||||
|
fields
|
||||||
|
});
|
||||||
|
alert(`Form saved with ID: ${response.data}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1>Create a Form</h1>
|
||||||
|
<input type="text" bind:value={formName} placeholder="Form Name" />
|
||||||
|
|
||||||
|
<button on:click={() => addField('text')}>Add Text Field</button>
|
||||||
|
<button on:click={() => addField('number')}>Add Number Field</button>
|
||||||
|
|
||||||
|
{#each fields as field, index}
|
||||||
|
<div>
|
||||||
|
<input type="text" bind:value={field.label} placeholder="Field Label" />
|
||||||
|
<input type="text" bind:value={field.name} placeholder="Field Name" />
|
||||||
|
<span>{field.type}</span>
|
||||||
|
<button on:click={() => fields.splice(index, 1)}>Remove</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<button on:click={saveForm}>Save Form</button>
|
||||||
|
</div>
|
35
frontend/src/lib/components/FormRenderer.svelte
Normal file
35
frontend/src/lib/components/FormRenderer.svelte
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<script>
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
export let form;
|
||||||
|
/**
|
||||||
|
* @type {(arg0: {}) => void}
|
||||||
|
*/
|
||||||
|
export let onSubmit;
|
||||||
|
|
||||||
|
let formData = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ preventDefault: () => void; }} e
|
||||||
|
*/
|
||||||
|
function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit(formData);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form on:submit={handleSubmit}>
|
||||||
|
{#each form.fields as field}
|
||||||
|
<div>
|
||||||
|
<!-- svelte-ignore a11y_label_has_associated_control -->
|
||||||
|
<label>{field.label}</label>
|
||||||
|
{#if field.type === 'text'}
|
||||||
|
<input type="text" bind:value={formData[field.name]} />
|
||||||
|
{:else if field.type === 'number'}
|
||||||
|
<input type="number" bind:value={formData[field.name]} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
11
frontend/src/lib/components/Navbar.svelte
Normal file
11
frontend/src/lib/components/Navbar.svelte
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<nav>
|
||||||
|
<h1>Formies</h1>
|
||||||
|
<button>logout</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
</style>
|
32
frontend/src/lib/components/Routes.svelte
Normal file
32
frontend/src/lib/components/Routes.svelte
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
let forms: any = [];
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const response = await axios.get('http://localhost:8080/forms');
|
||||||
|
forms = response.data;
|
||||||
|
});
|
||||||
|
|
||||||
|
function viewForm(id: number) {
|
||||||
|
goto(`/form/${id}`);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1>Forms Dashboard</h1>
|
||||||
|
{#if forms.length === 0}
|
||||||
|
<p>No forms available.</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{#each forms as form}
|
||||||
|
<li>
|
||||||
|
<h3>{form.name}</h3>
|
||||||
|
<button on:click={() => viewForm(form.id)}>View</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
19
frontend/src/lib/types.ts
Normal file
19
frontend/src/lib/types.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
export interface FormField {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
field_type: 'text' | 'number' | 'date' | 'textarea';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Form {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fields: FormField[];
|
||||||
|
created_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Submission {
|
||||||
|
id: string;
|
||||||
|
form_id: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
created_at?: string;
|
||||||
|
}
|
7
frontend/src/routes/(auth)/+layout.svelte
Normal file
7
frontend/src/routes/(auth)/+layout.svelte
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Navbar from '$lib/components/Navbar.svelte';
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Navbar></Navbar>
|
||||||
|
{@render children()}
|
8
frontend/src/routes/(auth)/+layout.ts
Normal file
8
frontend/src/routes/(auth)/+layout.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { loggedIn } from '$lib/api';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export async function load() {
|
||||||
|
if (!loggedIn()) {
|
||||||
|
redirect(307, '/login');
|
||||||
|
}
|
||||||
|
}
|
62
frontend/src/routes/(auth)/create/+page.svelte
Normal file
62
frontend/src/routes/(auth)/create/+page.svelte
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { createForm } from '../../../lib/api';
|
||||||
|
import type { FormField } from '../../../lib/types';
|
||||||
|
|
||||||
|
let name = '';
|
||||||
|
let fields: FormField[] = [];
|
||||||
|
|
||||||
|
function addField() {
|
||||||
|
fields = [...fields, { label: '', name: '', field_type: 'text' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeField(index: number) {
|
||||||
|
fields = fields.filter((_, i) => i !== index);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveForm() {
|
||||||
|
try {
|
||||||
|
await createForm(name, fields);
|
||||||
|
alert('Form created successfully!');
|
||||||
|
location.href = '/';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create form:', error);
|
||||||
|
alert('An error occurred while creating the form.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h1>Create Form</h1>
|
||||||
|
<form on:submit|preventDefault={saveForm}>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Form Name:</label>
|
||||||
|
<input type="text" bind:value={name} placeholder="Enter form name" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Fields</h2>
|
||||||
|
{#each fields as field, i}
|
||||||
|
<div class="field-container">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Label:</label>
|
||||||
|
<input type="text" bind:value={field.label} placeholder="Enter field label" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Name:</label>
|
||||||
|
<input type="text" bind:value={field.name} placeholder="Enter field name" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Type:</label>
|
||||||
|
<select bind:value={field.field_type}>
|
||||||
|
<option value="text">Text</option>
|
||||||
|
<option value="number">Number</option>
|
||||||
|
<option value="date">Date</option>
|
||||||
|
<option value="textarea">Textarea</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button class="secondary" on:click={() => removeField(i)}>Remove</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<button class="secondary" on:click={addField}>Add Field</button>
|
||||||
|
<button type="submit" disabled={!name || fields.length === 0}>Save Form</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
61
frontend/src/routes/(auth)/form/[id]/+page.svelte
Normal file
61
frontend/src/routes/(auth)/form/[id]/+page.svelte
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { getForms, getSubmissions, submitForm } from '$lib/api';
|
||||||
|
import type { Form, Submission } from '$lib/types';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
export let params: { id: string };
|
||||||
|
let form: any | null = null;
|
||||||
|
let submissions: any[] = [];
|
||||||
|
let responseData: Record<string, any> = {};
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
const { id } = $page.params;
|
||||||
|
if (id) {
|
||||||
|
form = await getForms().then((forms) => forms.find((f: any) => f.id === id) || null);
|
||||||
|
submissions = await getSubmissions(id);
|
||||||
|
} else {
|
||||||
|
console.error('Route parameter id is missing');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function submitResponse() {
|
||||||
|
const { id } = $page.params;
|
||||||
|
await submitForm(id, responseData);
|
||||||
|
alert('Response submitted successfully!');
|
||||||
|
submissions = await getSubmissions(params.id);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<h1>{form?.name}</h1>
|
||||||
|
{#if form}
|
||||||
|
<form on:submit|preventDefault={submitResponse}>
|
||||||
|
{#each form.fields as field}
|
||||||
|
<div class="form-group">
|
||||||
|
<label>{field.label}</label>
|
||||||
|
{#if field.field_type === 'text'}
|
||||||
|
<input type="text" bind:value={responseData[field.name]} />
|
||||||
|
{:else if field.field_type === 'number'}
|
||||||
|
<input type="number" bind:value={responseData[field.name]} />
|
||||||
|
{:else if field.field_type === 'date'}
|
||||||
|
<input type="date" bind:value={responseData[field.name]} />
|
||||||
|
{:else if field.field_type === 'textarea'}
|
||||||
|
<textarea bind:value={responseData[field.name]}></textarea>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
<h2>Submissions</h2>
|
||||||
|
<div class="submissions-list">
|
||||||
|
{#each submissions as submission}
|
||||||
|
<div class="submission-item">
|
||||||
|
{JSON.stringify(submission.data)}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="loading">Loading...</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
5
frontend/src/routes/(auth)/form/[id]/+page.ts
Normal file
5
frontend/src/routes/(auth)/form/[id]/+page.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export function load({ params }) {
|
||||||
|
return {
|
||||||
|
params
|
||||||
|
};
|
||||||
|
}
|
22
frontend/src/routes/(auth)/main/+page.svelte
Normal file
22
frontend/src/routes/(auth)/main/+page.svelte
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { getForms } from '../../../lib/api';
|
||||||
|
import type { Form } from '../../../lib/types';
|
||||||
|
|
||||||
|
let forms: any;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
forms = await getForms();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<a href="/create" class="button">Create a New Form</a>
|
||||||
|
<ul class="forms-list">
|
||||||
|
{#each forms as form}
|
||||||
|
<li>
|
||||||
|
<a href={`/form/${form.id}`}>{form.name}</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
7
frontend/src/routes/+layout.svelte
Normal file
7
frontend/src/routes/+layout.svelte
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import '../app.css';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{@render children()}
|
1
frontend/src/routes/+layout.ts
Normal file
1
frontend/src/routes/+layout.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const ssr = false;
|
7
frontend/src/routes/+page.ts
Normal file
7
frontend/src/routes/+page.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { loggedIn } from '$lib/api';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export async function load() {
|
||||||
|
const page = loggedIn() ? '/main' : '/login';
|
||||||
|
redirect(307, page);
|
||||||
|
}
|
51
frontend/src/routes/login/+page.svelte
Normal file
51
frontend/src/routes/login/+page.svelte
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { login } from '$lib/api';
|
||||||
|
|
||||||
|
let username = '';
|
||||||
|
let password = '';
|
||||||
|
let errorMessage = '';
|
||||||
|
let isLoading = false;
|
||||||
|
|
||||||
|
const handleLogin = async () => {
|
||||||
|
isLoading = true;
|
||||||
|
errorMessage = ''; // Reset any previous error message
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
// If successful, you can redirect the user to another page or show a success message
|
||||||
|
window.location.href = '/main'; // Example redirection after login
|
||||||
|
} catch (error: any) {
|
||||||
|
errorMessage = error.message || 'Login failed. Please try again.';
|
||||||
|
} finally {
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="login-container">
|
||||||
|
<h2>Login</h2>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" bind:value={username} placeholder="Enter your username" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" bind:value={password} placeholder="Enter your password" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if errorMessage}
|
||||||
|
<div class="error-message">{errorMessage}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<button class="submit-button" on:click={handleLogin} disabled={isLoading}>
|
||||||
|
{#if isLoading}
|
||||||
|
<div class="loading">
|
||||||
|
<span>Loading...</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
Login
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
8
frontend/src/routes/login/+page.ts
Normal file
8
frontend/src/routes/login/+page.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { loggedIn } from '$lib/api';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export async function load() {
|
||||||
|
if (loggedIn()) {
|
||||||
|
redirect(307, '/');
|
||||||
|
}
|
||||||
|
}
|
BIN
frontend/static/favicon.png
Normal file
BIN
frontend/static/favicon.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 1.5 KiB |
18
frontend/svelte.config.js
Normal file
18
frontend/svelte.config.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-node';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
// Consult https://svelte.dev/docs/kit/integrations
|
||||||
|
// for more information about preprocessors
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
|
||||||
|
kit: {
|
||||||
|
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||||
|
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||||
|
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||||
|
adapter: adapter()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||||
|
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||||
|
}
|
6
frontend/vite.config.ts
Normal file
6
frontend/vite.config.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [sveltekit()]
|
||||||
|
});
|
25
package.json
25
package.json
@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "formies",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"main": "index.js",
|
|
||||||
"scripts": {
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
|
||||||
},
|
|
||||||
"keywords": [],
|
|
||||||
"author": "",
|
|
||||||
"license": "ISC",
|
|
||||||
"description": "",
|
|
||||||
"dependencies": {
|
|
||||||
"basic-auth": "^2.0.1",
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"dotenv": "^16.5.0",
|
|
||||||
"ejs": "^3.1.10",
|
|
||||||
"express": "^5.1.0",
|
|
||||||
"helmet": "^8.1.0",
|
|
||||||
"node-fetch": "^3.3.2",
|
|
||||||
"sqlite": "^5.1.1",
|
|
||||||
"sqlite3": "^5.1.7",
|
|
||||||
"uuid": "^11.1.0"
|
|
||||||
}
|
|
||||||
}
|
|
53
server.js
53
server.js
@ -1,53 +0,0 @@
|
|||||||
import dotenv from "dotenv";
|
|
||||||
import express from "express";
|
|
||||||
import helmet from "helmet";
|
|
||||||
import cors from "cors";
|
|
||||||
import adminRoutes from "./src/routes/admin.js";
|
|
||||||
import publicRoutes from "./src/routes/public.js";
|
|
||||||
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
const PORT = process.env.PORT || 3000;
|
|
||||||
|
|
||||||
// CORS configuration
|
|
||||||
app.use(cors({
|
|
||||||
origin: ['https://mohamad.dev', 'https://www.mohamad.dev'],
|
|
||||||
methods: ['GET', 'POST'],
|
|
||||||
credentials: true
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Middleware
|
|
||||||
app.use(helmet({
|
|
||||||
crossOriginResourcePolicy: { policy: "cross-origin" }
|
|
||||||
}));
|
|
||||||
app.use(express.json());
|
|
||||||
app.use(express.urlencoded({ extended: true }));
|
|
||||||
app.set("view engine", "ejs");
|
|
||||||
app.use(express.static('views', {
|
|
||||||
setHeaders: (res, path) => {
|
|
||||||
if (path.endsWith('.js')) {
|
|
||||||
res.setHeader('Content-Type', 'application/javascript');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Routes
|
|
||||||
app.use("/admin", adminRoutes);
|
|
||||||
app.use("/", publicRoutes);
|
|
||||||
|
|
||||||
// Start server
|
|
||||||
app.listen(80, () => {
|
|
||||||
console.log(`Server running on http://0.0.0.0:${PORT}`);
|
|
||||||
if (!process.env.ADMIN_USER || !process.env.ADMIN_PASSWORD) {
|
|
||||||
console.warn("WARNING: Admin routes are UNPROTECTED. Set ADMIN_USER and ADMIN_PASSWORD in .env");
|
|
||||||
}
|
|
||||||
if (process.env.ADMIN_USER && process.env.ADMIN_PASSWORD) {
|
|
||||||
console.log(`Admin access: User: ${process.env.ADMIN_USER}, Pass: (hidden)`);
|
|
||||||
}
|
|
||||||
if (process.env.NTFY_ENABLED === "true" && process.env.NTFY_TOPIC_URL) {
|
|
||||||
console.log(`Ntfy notifications enabled for topic: ${process.env.NTFY_TOPIC_URL}`);
|
|
||||||
} else {
|
|
||||||
console.log("Ntfy notifications disabled or topic not configured.");
|
|
||||||
}
|
|
||||||
});
|
|
@ -1,34 +0,0 @@
|
|||||||
import sqlite3 from 'sqlite3';
|
|
||||||
import { open } from 'sqlite';
|
|
||||||
|
|
||||||
// Create a database connection
|
|
||||||
const db = await open({
|
|
||||||
filename: './database.sqlite',
|
|
||||||
driver: sqlite3.Database
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize tables if they don't exist
|
|
||||||
await db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS forms (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
uuid TEXT NOT NULL UNIQUE,
|
|
||||||
name TEXT DEFAULT 'My Form',
|
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
thank_you_url TEXT,
|
|
||||||
thank_you_message TEXT,
|
|
||||||
ntfy_enabled INTEGER DEFAULT 1,
|
|
||||||
is_archived INTEGER DEFAULT 0,
|
|
||||||
allowed_domains TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS submissions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
form_uuid TEXT NOT NULL,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
ip_address TEXT,
|
|
||||||
submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
FOREIGN KEY (form_uuid) REFERENCES forms(uuid) ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
export default db;
|
|
@ -1,49 +0,0 @@
|
|||||||
import dbPromise from "../config/database.js";
|
|
||||||
|
|
||||||
const domainChecker = async (req, res, next) => {
|
|
||||||
const formUuid = req.params.formUuid;
|
|
||||||
const referer = req.headers.referer || req.headers.origin;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const db = await dbPromise;
|
|
||||||
const form = await db.get(
|
|
||||||
"SELECT allowed_domains FROM forms WHERE uuid = ?",
|
|
||||||
[formUuid]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!form) {
|
|
||||||
return res.status(404).json({ error: "Form not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no domains are specified, allow all
|
|
||||||
if (!form.allowed_domains) {
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowedDomains = form.allowed_domains.split(",").map((d) => d.trim());
|
|
||||||
|
|
||||||
if (!referer) {
|
|
||||||
return res.status(403).json({ error: "Referer header is required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const refererUrl = new URL(referer);
|
|
||||||
const isAllowed = allowedDomains.some(
|
|
||||||
(domain) =>
|
|
||||||
refererUrl.hostname === domain ||
|
|
||||||
refererUrl.hostname.endsWith("." + domain)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isAllowed) {
|
|
||||||
return res
|
|
||||||
.status(403)
|
|
||||||
.json({ error: "Submission not allowed from this domain" });
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Domain check error:", error);
|
|
||||||
res.status(500).json({ error: "Internal server error" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export default domainChecker;
|
|
@ -1,44 +0,0 @@
|
|||||||
const ipRateLimitStore = new Map();
|
|
||||||
|
|
||||||
// Clean up old entries every 5 minutes
|
|
||||||
setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [key, value] of ipRateLimitStore.entries()) {
|
|
||||||
if (now - value.timestamp > 60000) {
|
|
||||||
// 1 minute
|
|
||||||
ipRateLimitStore.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 5 * 60 * 1000);
|
|
||||||
|
|
||||||
const rateLimiter = (req, res, next) => {
|
|
||||||
const formUuid = req.params.formUuid;
|
|
||||||
const ip = req.ip;
|
|
||||||
const key = `${formUuid}_${ip}`;
|
|
||||||
const now = Date.now();
|
|
||||||
const windowMs = 60000; // 1 minute
|
|
||||||
const maxRequests = 5; // 5 requests per minute
|
|
||||||
|
|
||||||
const current = ipRateLimitStore.get(key) || { count: 0, timestamp: now };
|
|
||||||
|
|
||||||
// Reset if window has passed
|
|
||||||
if (now - current.timestamp > windowMs) {
|
|
||||||
current.count = 0;
|
|
||||||
current.timestamp = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if limit exceeded
|
|
||||||
if (current.count >= maxRequests) {
|
|
||||||
return res.status(429).json({
|
|
||||||
error: "Too many requests. Please try again later.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Increment counter
|
|
||||||
current.count++;
|
|
||||||
ipRateLimitStore.set(key, current);
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
|
|
||||||
export default rateLimiter;
|
|
@ -1,405 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
import dbPromise from "../config/database.js";
|
|
||||||
import { sendNtfyNotification } from "../services/notification.js";
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.get("/", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const db = await dbPromise;
|
|
||||||
const forms = await db.all(`
|
|
||||||
SELECT f.uuid, f.name, f.created_at, f.is_archived,
|
|
||||||
(SELECT COUNT(*) FROM submissions WHERE form_uuid = f.uuid) as submission_count
|
|
||||||
FROM forms f ORDER BY created_at DESC
|
|
||||||
`);
|
|
||||||
res.render("index", {
|
|
||||||
forms,
|
|
||||||
appUrl: `${req.protocol}://${req.get("host")}`,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching forms:", error);
|
|
||||||
res.status(500).send("Error fetching forms");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/create-form", async (req, res) => {
|
|
||||||
const formName = req.body.formName || "Untitled Form";
|
|
||||||
const newUuid = uuidv4();
|
|
||||||
try {
|
|
||||||
const db = await dbPromise;
|
|
||||||
await db.run("INSERT INTO forms (uuid, name) VALUES (?, ?)", [newUuid, formName]);
|
|
||||||
console.log(`Form created: ${formName} with UUID: ${newUuid}`);
|
|
||||||
await sendNtfyNotification(
|
|
||||||
"New Form Created",
|
|
||||||
`Form "${formName}" (UUID: ${newUuid}) was created.`,
|
|
||||||
"high"
|
|
||||||
);
|
|
||||||
res.redirect("/admin");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creating form:", error);
|
|
||||||
res.status(500).send("Error creating form");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/submissions/:formUuid", async (req, res) => {
|
|
||||||
const { formUuid } = req.params;
|
|
||||||
const page = parseInt(req.query.page) || 1;
|
|
||||||
const limit = parseInt(req.query.limit) || 10;
|
|
||||||
const offset = (page - 1) * limit;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const db = await dbPromise;
|
|
||||||
const formDetails = await db.get("SELECT name FROM forms WHERE uuid = ?", [formUuid]);
|
|
||||||
if (!formDetails) {
|
|
||||||
return res.status(404).send("Form not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get total count for pagination
|
|
||||||
const countResult = await db.get(
|
|
||||||
"SELECT COUNT(*) as total FROM submissions WHERE form_uuid = ?",
|
|
||||||
[formUuid]
|
|
||||||
);
|
|
||||||
const totalSubmissions = countResult.total;
|
|
||||||
const totalPages = Math.ceil(totalSubmissions / limit);
|
|
||||||
|
|
||||||
const submissions = await db.all(
|
|
||||||
"SELECT id, data, ip_address, submitted_at FROM submissions WHERE form_uuid = ? ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
|
||||||
[formUuid, limit, offset]
|
|
||||||
);
|
|
||||||
|
|
||||||
res.render("submissions", {
|
|
||||||
submissions,
|
|
||||||
formUuid,
|
|
||||||
formName: formDetails.name,
|
|
||||||
appUrl: `${req.protocol}://${req.get("host")}`,
|
|
||||||
pagination: {
|
|
||||||
currentPage: page,
|
|
||||||
totalPages,
|
|
||||||
totalSubmissions,
|
|
||||||
limit,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching submissions:", error);
|
|
||||||
res.status(500).send("Error fetching submissions");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/submissions/:formUuid/export", async (req, res) => {
|
|
||||||
const { formUuid } = req.params;
|
|
||||||
try {
|
|
||||||
const db = await dbPromise;
|
|
||||||
const formDetails = await db.get("SELECT name FROM forms WHERE uuid = ?", [formUuid]);
|
|
||||||
if (!formDetails) {
|
|
||||||
return res.status(404).send("Form not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const submissions = await db.all(
|
|
||||||
"SELECT data, ip_address, submitted_at FROM submissions WHERE form_uuid = ? ORDER BY submitted_at DESC",
|
|
||||||
[formUuid]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create CSV content
|
|
||||||
const headers = ["Submitted At", "IP Address"];
|
|
||||||
const rows = submissions.map((submission) => {
|
|
||||||
const data = JSON.parse(submission.data);
|
|
||||||
// Add all form fields as headers
|
|
||||||
Object.keys(data).forEach((key) => {
|
|
||||||
if (!headers.includes(key)) {
|
|
||||||
headers.push(key);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
submitted_at: new Date(submission.submitted_at).toISOString(),
|
|
||||||
ip_address: submission.ip_address,
|
|
||||||
...data,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Generate CSV content
|
|
||||||
let csvContent = headers.join(",") + "\n";
|
|
||||||
rows.forEach((row) => {
|
|
||||||
const values = headers.map((header) => {
|
|
||||||
const value = row[header] || "";
|
|
||||||
// Escape commas and quotes in values
|
|
||||||
return `"${String(value).replace(/"/g, '""')}"`;
|
|
||||||
});
|
|
||||||
csvContent += values.join(",") + "\n";
|
|
||||||
});
|
|
||||||
|
|
||||||
// Set response headers for CSV download
|
|
||||||
res.setHeader("Content-Type", "text/csv");
|
|
||||||
res.setHeader(
|
|
||||||
"Content-Disposition",
|
|
||||||
`attachment; filename="${formDetails.name}-submissions.csv"`
|
|
||||||
);
|
|
||||||
res.send(csvContent);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error exporting submissions:", error);
|
|
||||||
res.status(500).send("Error exporting submissions");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/delete-form/:formUuid", async (req, res) => {
|
|
||||||
const { formUuid } = req.params;
|
|
||||||
try {
|
|
||||||
const db = await dbPromise;
|
|
||||||
const formResult = await db.all("SELECT name FROM forms WHERE uuid = ?", [formUuid]);
|
|
||||||
const formName =
|
|
||||||
formResult.length > 0 ? formResult[0].name : `Form ${formUuid}`;
|
|
||||||
|
|
||||||
await db.run("DELETE FROM forms WHERE uuid = ?", [formUuid]);
|
|
||||||
console.log(`Form ${formUuid} deleted.`);
|
|
||||||
await sendNtfyNotification(
|
|
||||||
"Form Deleted",
|
|
||||||
`Form "${formName}" (UUID: ${formUuid}) was deleted.`,
|
|
||||||
"default"
|
|
||||||
);
|
|
||||||
res.redirect("/admin");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting form:", error);
|
|
||||||
res.status(500).send("Error deleting form.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/delete-submission/:submissionId", async (req, res) => {
|
|
||||||
const { submissionId } = req.params;
|
|
||||||
let formUuidForRedirect = "unknown-form";
|
|
||||||
try {
|
|
||||||
const db = await dbPromise;
|
|
||||||
const submissionResult = await db.all("SELECT form_uuid FROM submissions WHERE id = ?", [submissionId]);
|
|
||||||
if (submissionResult.length > 0) {
|
|
||||||
formUuidForRedirect = submissionResult[0].form_uuid;
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.run("DELETE FROM submissions WHERE id = ?", [submissionId]);
|
|
||||||
console.log(`Submission ${submissionId} deleted.`);
|
|
||||||
await sendNtfyNotification(
|
|
||||||
"Submission Deleted",
|
|
||||||
`Submission ID ${submissionId} (for form ${formUuidForRedirect}) was deleted.`,
|
|
||||||
"low"
|
|
||||||
);
|
|
||||||
if (formUuidForRedirect !== "unknown-form") {
|
|
||||||
res.redirect(`/admin/submissions/${formUuidForRedirect}`);
|
|
||||||
} else {
|
|
||||||
res.redirect("/admin");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting submission:", error);
|
|
||||||
res.status(500).send("Error deleting submission.");
|
|
||||||
if (formUuidForRedirect !== "unknown-form") {
|
|
||||||
res.redirect(`/admin/submissions/${formUuidForRedirect}`);
|
|
||||||
} else {
|
|
||||||
res.redirect("/admin");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/update-form-name/:formUuid", async (req, res) => {
|
|
||||||
const { formUuid } = req.params;
|
|
||||||
const { newName } = req.body;
|
|
||||||
|
|
||||||
if (!newName || newName.trim() === "") {
|
|
||||||
return res.status(400).send("New form name is required.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const db = await dbPromise;
|
|
||||||
const formResult = await db.all("SELECT name FROM forms WHERE uuid = ?", [formUuid]);
|
|
||||||
|
|
||||||
if (formResult.length === 0) {
|
|
||||||
return res.status(404).send("Form not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldName = formResult[0].name;
|
|
||||||
|
|
||||||
await db.run("UPDATE forms SET name = ? WHERE uuid = ?", [
|
|
||||||
newName,
|
|
||||||
formUuid,
|
|
||||||
]);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
`Form name updated from '${oldName}' to '${newName}' for UUID: ${formUuid}`
|
|
||||||
);
|
|
||||||
|
|
||||||
await sendNtfyNotification(
|
|
||||||
"Form Name Updated",
|
|
||||||
`Form name changed from '${oldName}' to '${newName}' for UUID: ${formUuid}.`,
|
|
||||||
"default"
|
|
||||||
);
|
|
||||||
|
|
||||||
res.redirect("/admin");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating form name:", error);
|
|
||||||
res.status(500).send("Error updating form name.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/update-thank-you-url/:formUuid", async (req, res) => {
|
|
||||||
const { formUuid } = req.params;
|
|
||||||
const { thankYouUrl } = req.body;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const db = await dbPromise;
|
|
||||||
await db.run("UPDATE forms SET thank_you_url = ? WHERE uuid = ?", [thankYouUrl, formUuid]);
|
|
||||||
console.log(`Thank You URL updated for form UUID: ${formUuid}`);
|
|
||||||
res.redirect("/admin");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating Thank You URL:", error);
|
|
||||||
res.status(500).send("Error updating Thank You URL.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/update-ntfy-enabled/:formUuid", async (req, res) => {
|
|
||||||
const { formUuid } = req.params;
|
|
||||||
const ntfyEnabled = req.body.ntfyEnabled === "true";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const db = await dbPromise;
|
|
||||||
await db.run("UPDATE forms SET ntfy_enabled = ? WHERE uuid = ?", [
|
|
||||||
ntfyEnabled,
|
|
||||||
formUuid,
|
|
||||||
]);
|
|
||||||
console.log(`Ntfy notifications updated for form UUID: ${formUuid}`);
|
|
||||||
res.redirect("/admin");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating Ntfy notifications setting:", error);
|
|
||||||
res.status(500).send("Error updating Ntfy notifications setting.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/clear-submissions/:formUuid", async (req, res) => {
|
|
||||||
const { formUuid } = req.params;
|
|
||||||
try {
|
|
||||||
// First get form name for notification before clearing
|
|
||||||
const db = await dbPromise;
|
|
||||||
const formResult = await db.all("SELECT name FROM forms WHERE uuid = ?", [formUuid]);
|
|
||||||
const formName =
|
|
||||||
formResult.length > 0 ? formResult[0].name : `Form ${formUuid}`;
|
|
||||||
|
|
||||||
// Delete all submissions for this form
|
|
||||||
await db.run("DELETE FROM submissions WHERE form_uuid = ?", [formUuid]);
|
|
||||||
console.log(`All submissions cleared for form ${formUuid}`);
|
|
||||||
await sendNtfyNotification(
|
|
||||||
"Submissions Cleared",
|
|
||||||
`All submissions for form "${formName}" (UUID: ${formUuid}) were cleared.`,
|
|
||||||
"default"
|
|
||||||
);
|
|
||||||
res.redirect(`/admin/submissions/${formUuid}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error clearing submissions:", error);
|
|
||||||
res.status(500).send("Error clearing submissions.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/update-thank-you-message/:formUuid", async (req, res) => {
|
|
||||||
const { formUuid } = req.params;
|
|
||||||
const { thankYouMessage } = req.body;
|
|
||||||
try {
|
|
||||||
const db = await dbPromise;
|
|
||||||
await db.run("UPDATE forms SET thank_you_message = ? WHERE uuid = ?", [thankYouMessage, formUuid]);
|
|
||||||
console.log(`Thank you message updated for form ${formUuid}`);
|
|
||||||
res.redirect("/admin");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating thank you message:", error);
|
|
||||||
res.status(500).send("Error updating thank you message.");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/archive-form/:formUuid", async (req, res) => {
|
|
||||||
const { formUuid } = req.params;
|
|
||||||
const { archive } = req.body;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const db = await dbPromise;
|
|
||||||
const formResult = await db.all("SELECT name FROM forms WHERE uuid = ?", [formUuid]);
|
|
||||||
|
|
||||||
if (formResult.length === 0) {
|
|
||||||
return res.status(404).send("Form not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.run("UPDATE forms SET is_archived = ? WHERE uuid = ?", [
|
|
||||||
archive === "true",
|
|
||||||
formUuid,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const action = archive === "true" ? "archived" : "unarchived";
|
|
||||||
await sendNtfyNotification(
|
|
||||||
`Form ${action}`,
|
|
||||||
`Form "${formResult[0].name}" (UUID: ${formUuid}) has been ${action}.`,
|
|
||||||
"default"
|
|
||||||
);
|
|
||||||
|
|
||||||
res.redirect("/admin");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating form archive status:", error);
|
|
||||||
res.status(500).send("Error updating form archive status");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/update-allowed-domains/:formUuid", async (req, res) => {
|
|
||||||
const { formUuid } = req.params;
|
|
||||||
const { allowedDomains } = req.body;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const db = await dbPromise;
|
|
||||||
const formResult = await db.all("SELECT name FROM forms WHERE uuid = ?", [formUuid]);
|
|
||||||
|
|
||||||
if (formResult.length === 0) {
|
|
||||||
return res.status(404).send("Form not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.run("UPDATE forms SET allowed_domains = ? WHERE uuid = ?", [
|
|
||||||
allowedDomains,
|
|
||||||
formUuid,
|
|
||||||
]);
|
|
||||||
|
|
||||||
await sendNtfyNotification(
|
|
||||||
"Form Allowed Domains Updated",
|
|
||||||
`Form "${formResult[0].name}" (UUID: ${formUuid}) allowed domains have been updated.`,
|
|
||||||
"default"
|
|
||||||
);
|
|
||||||
|
|
||||||
res.redirect("/admin");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating allowed domains:", error);
|
|
||||||
res.status(500).send("Error updating allowed domains");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/test-notification/:formUuid", async (req, res) => {
|
|
||||||
const { formUuid } = req.params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const db = await dbPromise;
|
|
||||||
const formResult = await db.all("SELECT name, ntfy_enabled FROM forms WHERE uuid = ?", [formUuid]);
|
|
||||||
|
|
||||||
if (formResult.length === 0) {
|
|
||||||
return res.status(404).send("Form not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formResult[0].ntfy_enabled) {
|
|
||||||
return res
|
|
||||||
.status(400)
|
|
||||||
.send("Ntfy notifications are not enabled for this form.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendNtfyNotification(
|
|
||||||
"Test Notification",
|
|
||||||
`This is a test notification for form "${formResult[0].name}" (UUID: ${formUuid}).`,
|
|
||||||
"default",
|
|
||||||
"test"
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
message: "Test notification sent successfully.",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error sending test notification:", error);
|
|
||||||
res.status(500).send("Error sending test notification");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
@ -1,104 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import dbPromise from "../config/database.js";
|
|
||||||
import { sendNtfyNotification } from "../services/notification.js";
|
|
||||||
import rateLimiter from "../middleware/rateLimiter.js";
|
|
||||||
import domainChecker from "../middleware/domainChecker.js";
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.get("/", (req, res) => res.redirect("/admin"));
|
|
||||||
router.get("/health", (req, res) => res.status(200).json({ status: "ok" }));
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/submit/:formUuid",
|
|
||||||
rateLimiter,
|
|
||||||
domainChecker,
|
|
||||||
async (req, res) => {
|
|
||||||
const { formUuid } = req.params;
|
|
||||||
const submissionData = { ...req.body };
|
|
||||||
const ipAddress = req.ip;
|
|
||||||
|
|
||||||
if (submissionData.honeypot_field && submissionData.honeypot_field !== "") {
|
|
||||||
console.log(
|
|
||||||
`Honeypot triggered for ${formUuid} by IP ${ipAddress}. Ignoring submission.`
|
|
||||||
);
|
|
||||||
if (submissionData._thankyou) {
|
|
||||||
return res.redirect(submissionData._thankyou);
|
|
||||||
}
|
|
||||||
return res.send(
|
|
||||||
"<h1>Thank You!</h1><p>Your submission has been received.</p>"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
delete submissionData.honeypot_field;
|
|
||||||
|
|
||||||
let formNameForNotification = `Form ${formUuid}`;
|
|
||||||
try {
|
|
||||||
const db = await dbPromise;
|
|
||||||
const form = await db.get(
|
|
||||||
"SELECT id, name, thank_you_url, thank_you_message, ntfy_enabled, is_archived FROM forms WHERE uuid = ?",
|
|
||||||
[formUuid]
|
|
||||||
);
|
|
||||||
if (!form) {
|
|
||||||
return res.status(404).send("Form endpoint not found.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (form.is_archived) {
|
|
||||||
return res
|
|
||||||
.status(410)
|
|
||||||
.send(
|
|
||||||
"This form has been archived and is no longer accepting submissions."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
formNameForNotification = form.name || `Form ${formUuid}`;
|
|
||||||
const ntfyEnabled = form.ntfy_enabled;
|
|
||||||
|
|
||||||
await db.run(
|
|
||||||
"INSERT INTO submissions (form_uuid, data, ip_address) VALUES (?, ?, ?)",
|
|
||||||
[formUuid, JSON.stringify(submissionData), ipAddress]
|
|
||||||
);
|
|
||||||
console.log(`Submission received for ${formUuid}:`, submissionData);
|
|
||||||
|
|
||||||
const submissionSummary = Object.entries(submissionData)
|
|
||||||
.filter(([key]) => key !== "_thankyou")
|
|
||||||
.map(([key, value]) => `${key}: ${value}`)
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
if (ntfyEnabled) {
|
|
||||||
await sendNtfyNotification(
|
|
||||||
`New Submission: ${formNameForNotification}`,
|
|
||||||
`Data: ${submissionSummary || "No data fields"
|
|
||||||
}\nFrom IP: ${ipAddress}`,
|
|
||||||
"high",
|
|
||||||
"incoming_form"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (form.thank_you_url) {
|
|
||||||
return res.redirect(form.thank_you_url);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (form.thank_you_message) {
|
|
||||||
return res.send(`<h1>Thank You!</h1><p>${form.thank_you_message}</p>`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (submissionData._thankyou) {
|
|
||||||
return res.redirect(submissionData._thankyou);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.send(
|
|
||||||
'<h1>Thank You!</h1><p>Your submission has been received.</p><p><a href="/">Back to formies</a></p>'
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error processing submission:", error);
|
|
||||||
await sendNtfyNotification(
|
|
||||||
`Submission Error: ${formNameForNotification}`,
|
|
||||||
`Failed to process submission for ${formUuid} from IP ${ipAddress}. Error: ${error.message}`,
|
|
||||||
"max"
|
|
||||||
);
|
|
||||||
res.status(500).send("Error processing submission.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
@ -1,33 +0,0 @@
|
|||||||
import fetch from "node-fetch";
|
|
||||||
|
|
||||||
export async function sendNtfyNotification(
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
priority = "default",
|
|
||||||
tags = ""
|
|
||||||
) {
|
|
||||||
if (process.env.NTFY_ENABLED !== "true" || !process.env.NTFY_TOPIC_URL) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const response = await fetch(process.env.NTFY_TOPIC_URL, {
|
|
||||||
method: "POST",
|
|
||||||
body: message,
|
|
||||||
headers: {
|
|
||||||
Title: title,
|
|
||||||
Priority: priority,
|
|
||||||
Tags: tags,
|
|
||||||
"Content-Type": "text/plain",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
console.error(`Ntfy error: ${response.status} ${await response.text()}`);
|
|
||||||
} else {
|
|
||||||
console.log("Ntfy notification sent successfully.");
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to send Ntfy notification:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default { sendNtfyNotification };
|
|
979
views/global.css
979
views/global.css
@ -1,979 +0,0 @@
|
|||||||
:root {
|
|
||||||
/* Scandinavian Industrial Palette */
|
|
||||||
--color-bg: #f5f7fa;
|
|
||||||
/* Very light cool gray - Scandi base */
|
|
||||||
--color-surface: #ffffff;
|
|
||||||
/* White - Scandi cleanliness */
|
|
||||||
--color-primary: #34495e;
|
|
||||||
/* Dark slate blue/gray - Industrial strength */
|
|
||||||
--color-primary-rgb: 52, 73, 94;
|
|
||||||
--color-secondary: #7f8c8d;
|
|
||||||
/* Grayish cyan/slate - Industrial accent */
|
|
||||||
--color-accent: #c09574;
|
|
||||||
/* Muted tan/light wood - Scandi warmth */
|
|
||||||
--color-accent-rgb: 192, 149, 116;
|
|
||||||
--color-text: #2c3e50;
|
|
||||||
/* Dark, similar to primary for harmony */
|
|
||||||
--color-text-light: #566573;
|
|
||||||
/* Lighter gray for secondary text */
|
|
||||||
--color-border: #e1e5ea;
|
|
||||||
/* Light, cool gray for subtle borders */
|
|
||||||
|
|
||||||
--color-success: #27ae60;
|
|
||||||
/* Clear Green */
|
|
||||||
--color-success-bg: rgba(39, 174, 96, 0.1);
|
|
||||||
--color-pending: #f39c12;
|
|
||||||
/* Clear Amber/Orange */
|
|
||||||
--color-pending-bg: rgba(243, 156, 18, 0.1);
|
|
||||||
--color-archived: #95a5a6;
|
|
||||||
/* Muted Silver/Gray */
|
|
||||||
--color-archived-bg: rgba(149, 165, 166, 0.15);
|
|
||||||
--color-danger: #c0392b;
|
|
||||||
/* Clear, strong Red */
|
|
||||||
--color-danger-bg: rgba(192, 57, 43, 0.1);
|
|
||||||
|
|
||||||
--shadow-sm: 0 2px 4px rgba(0, 0, 0, 0.05);
|
|
||||||
/* Softer, more diffuse */
|
|
||||||
--shadow-md: 0 5px 15px rgba(0, 0, 0, 0.08);
|
|
||||||
/* Softer, more diffuse */
|
|
||||||
--border-radius: 4px;
|
|
||||||
/* Slightly sharper edges */
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
box-sizing: border-box;
|
|
||||||
font-family: "Inter", "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
/* Modern sans-serif */
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
color: var(--color-text);
|
|
||||||
min-height: 100vh;
|
|
||||||
padding: 20px;
|
|
||||||
/* Removed background pattern for cleaner look */
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 1200px;
|
|
||||||
width: 100%;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skip-link {
|
|
||||||
position: absolute;
|
|
||||||
left: -9999px;
|
|
||||||
top: auto;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
overflow: hidden;
|
|
||||||
z-index: -9999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.skip-link:focus,
|
|
||||||
.skip-link:active {
|
|
||||||
display: block;
|
|
||||||
position: static;
|
|
||||||
width: auto;
|
|
||||||
height: auto;
|
|
||||||
overflow: visible;
|
|
||||||
padding: 10px 15px;
|
|
||||||
margin: 10px auto;
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
color: var(--color-surface);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 1rem;
|
|
||||||
z-index: 100000;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visually-hidden {
|
|
||||||
position: absolute;
|
|
||||||
width: 1px;
|
|
||||||
height: 1px;
|
|
||||||
margin: -1px;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
clip: rect(0, 0, 0, 0);
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Titles */
|
|
||||||
.page-title {
|
|
||||||
font-size: 2.1rem;
|
|
||||||
/* Slightly larger */
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-primary);
|
|
||||||
line-height: 1.2;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.07);
|
|
||||||
/* Very subtle shadow */
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 1.4rem;
|
|
||||||
/* Slightly larger */
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-primary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title svg {
|
|
||||||
color: var(--color-accent);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.button {
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
color: var(--color-surface);
|
|
||||||
/* Changed to var for consistency, typically white */
|
|
||||||
border: 1px solid var(--color-primary);
|
|
||||||
/* Ensure border for consistency */
|
|
||||||
padding: 10px 18px;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
cursor: pointer;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
text-decoration: none;
|
|
||||||
line-height: 1.5;
|
|
||||||
user-select: none;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button svg {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:hover {
|
|
||||||
background-color: #2c3e50;
|
|
||||||
/* Darker primary */
|
|
||||||
border-color: #2c3e50;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:active {
|
|
||||||
background-color: #212f3c;
|
|
||||||
/* Even darker primary */
|
|
||||||
border-color: #212f3c;
|
|
||||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:focus-visible {
|
|
||||||
outline: 2px solid var(--color-accent);
|
|
||||||
outline-offset: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-secondary {
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
color: var(--color-primary);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-secondary:hover {
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
border-color: #c8ced3;
|
|
||||||
/* Darker border */
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-secondary:active {
|
|
||||||
background-color: #e0e5ea;
|
|
||||||
/* Darker bg */
|
|
||||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button-secondary:focus-visible {
|
|
||||||
outline: 2px solid var(--color-accent);
|
|
||||||
outline-offset: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.button.button-sm {
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.button-sm:hover {
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.button-sm:active {
|
|
||||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.button.button-danger {
|
|
||||||
background-color: var(--color-danger);
|
|
||||||
border-color: var(--color-danger);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.button-danger:hover {
|
|
||||||
background-color: #a93226;
|
|
||||||
/* Darker danger */
|
|
||||||
border-color: #a93226;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.button-danger:active {
|
|
||||||
background-color: #922b21;
|
|
||||||
/* Even darker danger */
|
|
||||||
border-color: #922b21;
|
|
||||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button.button-danger:focus-visible {
|
|
||||||
outline: 2px solid var(--color-danger);
|
|
||||||
outline-offset: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Generic Card */
|
|
||||||
.simple-card {
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
padding: 20px;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
margin-bottom: 24px;
|
|
||||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.simple-card:hover {
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
/* Standardized lift */
|
|
||||||
}
|
|
||||||
|
|
||||||
.simple-card .card-title {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-primary);
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Card (for listing forms) */
|
|
||||||
.form-card {
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-card:hover {
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
/* transform: translateY(-2px); */
|
|
||||||
/* Standardized lift */
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-card-header {
|
|
||||||
padding: 16px;
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
/* Clean header */
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-title {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 1rem;
|
|
||||||
color: var(--color-primary);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-menu {
|
|
||||||
color: var(--color-text-light);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 6px;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
transition: background-color 0.2s ease, color 0.2s ease;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-menu:hover {
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-menu:focus-visible {
|
|
||||||
outline: 2px solid var(--color-accent);
|
|
||||||
outline-offset: 1px;
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.form-card-content {
|
|
||||||
padding: 16px;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-submission-count {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--color-text-light);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-url-info {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-light);
|
|
||||||
margin-top: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
word-break: break-all;
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
/* Use main BG for slight recess */
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-url-info input[type="text"] {
|
|
||||||
flex-grow: 1;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
padding: 0;
|
|
||||||
background-color: transparent;
|
|
||||||
cursor: text;
|
|
||||||
border: none;
|
|
||||||
color: var(--color-text);
|
|
||||||
/* Ensure text color is readable */
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-url-info input[type="text"]:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.form-badge {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.2;
|
|
||||||
white-space: nowrap;
|
|
||||||
margin-left: 8px;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-badge.archived {
|
|
||||||
background-color: var(--color-archived-bg);
|
|
||||||
color: var(--color-archived);
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-badge.active {
|
|
||||||
background-color: var(--color-success-bg);
|
|
||||||
color: var(--color-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Dropdown Menu */
|
|
||||||
.dropdown {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-menu {
|
|
||||||
display: none;
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
top: 100%;
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
min-width: 200px;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
z-index: 1000;
|
|
||||||
padding: 8px 0;
|
|
||||||
margin-top: 4px;
|
|
||||||
list-style: none;
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px);
|
|
||||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-menu.show {
|
|
||||||
display: block;
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
padding: 8px 16px;
|
|
||||||
text-align: left;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--color-text);
|
|
||||||
background-color: transparent;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
white-space: nowrap;
|
|
||||||
transition: background-color 0.2s ease, color 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item:hover {
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item:focus-visible {
|
|
||||||
outline: 2px solid var(--color-accent);
|
|
||||||
outline-offset: -2px;
|
|
||||||
/* Inset outline */
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.dropdown-divider {
|
|
||||||
height: 1px;
|
|
||||||
background-color: var(--color-border);
|
|
||||||
margin: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item.text-danger {
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item.text-danger:hover {
|
|
||||||
background-color: var(--color-danger-bg);
|
|
||||||
color: var(--color-danger);
|
|
||||||
/* Keep text color as danger */
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item.text-danger:focus-visible {
|
|
||||||
outline: 2px solid var(--color-danger);
|
|
||||||
background-color: var(--color-danger-bg);
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Modals */
|
|
||||||
.modal {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
z-index: 1050;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
overflow-y: auto;
|
|
||||||
background-color: rgba(var(--color-primary-rgb), 0.6);
|
|
||||||
/* Primary color backdrop */
|
|
||||||
backdrop-filter: blur(4px);
|
|
||||||
/* Increased blur */
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal.show {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-dialog {
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
padding: 24px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
width: 95%;
|
|
||||||
max-width: 500px;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
position: relative;
|
|
||||||
margin: 20px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
transform: translateY(-20px);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal.show .modal-dialog {
|
|
||||||
transform: translateY(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding-bottom: 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-title {
|
|
||||||
font-size: 1.3rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-primary);
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-close {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
font-size: 1.8rem;
|
|
||||||
font-weight: bold;
|
|
||||||
color: var(--color-text-light);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0.5rem;
|
|
||||||
margin: -0.5rem;
|
|
||||||
line-height: 1;
|
|
||||||
transition: color 0.2s ease;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-close:hover {
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-close:focus-visible {
|
|
||||||
outline: 2px solid var(--color-accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
color: var(--color-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
padding-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 10px;
|
|
||||||
padding-top: 16px;
|
|
||||||
margin-top: 16px;
|
|
||||||
border-top: 1px solid var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Form Elements (inputs, labels) */
|
|
||||||
.form-label {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"],
|
|
||||||
input[type="email"],
|
|
||||||
textarea {
|
|
||||||
width: 100%;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="text"]:focus-visible,
|
|
||||||
textarea:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
box-shadow: 0 0 0 3px rgba(var(--color-accent-rgb), 0.25);
|
|
||||||
/* Accent glow */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Specific style for the read-only URL input inside .form-url-info */
|
|
||||||
.form-url-info input[type="text"].form-url-display {
|
|
||||||
/* Inherits styles from .form-url-info input[type="text"] */
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-text {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
color: var(--color-text-light);
|
|
||||||
margin-top: 4px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* For Form Manager Specific Layouts */
|
|
||||||
.create-form-section {
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
padding: 24px;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
margin-bottom: 40px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-form-section .section-title {
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.create-form-section .form-group {
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Copy URL Button Specifics */
|
|
||||||
.copy-button {
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
color: var(--color-primary);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
box-shadow: none;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy-button:hover {
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
/* Use main BG for hover */
|
|
||||||
border-color: #c8ced3;
|
|
||||||
/* Darker border */
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy-button:active {
|
|
||||||
background-color: #e0e5ea;
|
|
||||||
/* Darker bg */
|
|
||||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy-button .copy-text {
|
|
||||||
display: inline-block;
|
|
||||||
min-width: 35px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy-button.copied .copy-text {
|
|
||||||
color: var(--color-success);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy-button.copied svg {
|
|
||||||
color: var(--color-success);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Submissions Page specific */
|
|
||||||
.dashboard-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-title {
|
|
||||||
font-size: 2.1rem;
|
|
||||||
/* Matched page-title */
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-primary);
|
|
||||||
line-height: 1.2;
|
|
||||||
margin: 0;
|
|
||||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.07);
|
|
||||||
/* Matched page-title */
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-info-custom {
|
|
||||||
padding: 16px;
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-left: 4px solid var(--color-accent);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
margin-bottom: 24px;
|
|
||||||
color: var(--color-text-light);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Submissions Table */
|
|
||||||
.submissions-table-wrapper {
|
|
||||||
overflow-x: auto;
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submissions-table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
min-width: 700px;
|
|
||||||
caption-side: bottom;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submissions-table caption {
|
|
||||||
padding: 10px;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--color-text-light);
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submissions-table th,
|
|
||||||
.submissions-table td {
|
|
||||||
padding: 14px 16px;
|
|
||||||
text-align: left;
|
|
||||||
border-bottom: 1px solid var(--color-border);
|
|
||||||
vertical-align: top;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submissions-table th {
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--color-primary);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
/* Use main BG for header */
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submissions-table th:first-child {
|
|
||||||
/* No specific radius if table wrapper has it */
|
|
||||||
}
|
|
||||||
|
|
||||||
.submissions-table th:last-child {
|
|
||||||
/* No specific radius if table wrapper has it */
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submissions-table tbody tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submissions-table tbody tr:hover {
|
|
||||||
background-color: color-mix(in srgb, var(--color-bg), #000000 3%);
|
|
||||||
/* Subtle darker BG hover */
|
|
||||||
}
|
|
||||||
|
|
||||||
.submissions-table td:last-child {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-action {
|
|
||||||
color: var(--color-text-light);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.2s ease, transform 0.1s ease;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 4px;
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-action:hover {
|
|
||||||
color: var(--color-accent);
|
|
||||||
transform: scale(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-action:focus-visible {
|
|
||||||
outline: 2px solid var(--color-accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-action.delete:hover {
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-action.delete:focus-visible {
|
|
||||||
outline: 2px solid var(--color-danger);
|
|
||||||
outline-offset: 2px;
|
|
||||||
color: var(--color-danger);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.submission-data-item {
|
|
||||||
margin-bottom: 8px;
|
|
||||||
word-break: break-word;
|
|
||||||
padding-bottom: 4px;
|
|
||||||
border-bottom: 1px dashed var(--color-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.submission-data-item:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.submission-data-item strong {
|
|
||||||
color: var(--color-primary);
|
|
||||||
margin-right: 6px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pagination */
|
|
||||||
.pagination {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 32px 0 16px 0;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-item .page-link {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 8px 12px;
|
|
||||||
text-decoration: none;
|
|
||||||
color: var(--color-primary);
|
|
||||||
background-color: var(--color-surface);
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--border-radius);
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
min-width: 38px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-item .page-link:hover {
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-item .page-link:focus-visible {
|
|
||||||
outline: 2px solid var(--color-accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.page-item.active .page-link {
|
|
||||||
background-color: var(--color-primary);
|
|
||||||
color: var(--color-surface);
|
|
||||||
border-color: var(--color-primary);
|
|
||||||
font-weight: 600;
|
|
||||||
box-shadow: var(--shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-item.active .page-link:hover {
|
|
||||||
background-color: #2c3e50;
|
|
||||||
/* Darker primary */
|
|
||||||
border-color: #2c3e50;
|
|
||||||
box-shadow: var(--shadow-md);
|
|
||||||
color: var(--color-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-item.disabled .page-link {
|
|
||||||
color: var(--color-text-light);
|
|
||||||
pointer-events: none;
|
|
||||||
background-color: var(--color-bg);
|
|
||||||
border-color: var(--color-border);
|
|
||||||
opacity: 0.7;
|
|
||||||
cursor: not-allowed;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-item.disabled .page-link:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
.pagination-info {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--color-text-light);
|
|
||||||
font-size: 0.85rem;
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Utility classes */
|
|
||||||
.mb-2 {
|
|
||||||
margin-bottom: 0.5rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mt-2 {
|
|
||||||
margin-top: 0.5rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.me-2 {
|
|
||||||
margin-right: 0.5rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-3 {
|
|
||||||
margin-bottom: 1rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mt-3 {
|
|
||||||
margin-top: 1rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.me-3 {
|
|
||||||
margin-right: 1rem !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.d-flex {
|
|
||||||
display: flex !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.justify-content-between {
|
|
||||||
justify-content: space-between !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.align-items-center {
|
|
||||||
align-items: center !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.align-items-start {
|
|
||||||
align-items: flex-start !important;
|
|
||||||
}
|
|
240
views/index.ejs
240
views/index.ejs
@ -1,240 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>formies</title>
|
|
||||||
<link rel="stylesheet" href="/global.css">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<h1 class="page-title">formies</h1>
|
|
||||||
|
|
||||||
<main id="main-content">
|
|
||||||
<!-- Create New Form -->
|
|
||||||
<section class="create-form-section">
|
|
||||||
<h2 class="section-title">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
aria-hidden="true" focusable="false">
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
||||||
</svg>
|
|
||||||
Create New Form
|
|
||||||
</h2>
|
|
||||||
<form action="/admin/create-form" method="POST">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="formNameInput" class="form-label">Form Name</label>
|
|
||||||
<input type="text" id="formNameInput" name="formName" placeholder="e.g., Contact Us, Feedback"
|
|
||||||
required aria-describedby="formNameHelp" />
|
|
||||||
<small id="formNameHelp" class="form-text">A descriptive name for your new form.</small>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="button">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
aria-hidden="true" focusable="false">
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
||||||
</svg>
|
|
||||||
Create Form
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Forms List -->
|
|
||||||
<section>
|
|
||||||
<h2 class="section-title">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
aria-hidden="true" focusable="false">
|
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
|
||||||
<polyline points="14 2 14 8 20 8"></polyline>
|
|
||||||
<line x1="16" y1="13" x2="8" y2="13"></line>
|
|
||||||
<line x1="16" y1="17" x2="8" y2="17"></line>
|
|
||||||
<polyline points="10 9 9 9 8 9"></polyline>
|
|
||||||
</svg>
|
|
||||||
Your Forms
|
|
||||||
</h2>
|
|
||||||
<% if (forms.length===0) { %>
|
|
||||||
<p class="alert-info-custom">No forms created yet. Create your first form above!</p>
|
|
||||||
<% } else { %>
|
|
||||||
<% forms.forEach(form=> { %>
|
|
||||||
<div class="form-card">
|
|
||||||
<div class="form-card-header">
|
|
||||||
<h3 class="form-title">
|
|
||||||
<a href="/admin/submissions/<%= form.uuid %>">
|
|
||||||
<%= form.name %>
|
|
||||||
</a>
|
|
||||||
<% if (form.is_archived) { %>
|
|
||||||
<span class="form-badge archived">Archived</span>
|
|
||||||
<% } %>
|
|
||||||
</h3>
|
|
||||||
<div class="dropdown">
|
|
||||||
<button type="button" class="form-menu" data-action="toggle-dropdown"
|
|
||||||
aria-label="Actions for form <%= form.name %>" aria-expanded="false"
|
|
||||||
aria-controls="dropdownMenu<%= form.uuid %>">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"
|
|
||||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
||||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
|
|
||||||
focusable="false">
|
|
||||||
<circle cx="12" cy="12" r="1"></circle>
|
|
||||||
<circle cx="19" cy="12" r="1"></circle>
|
|
||||||
<circle cx="5" cy="12" r="1"></circle>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<ul class="dropdown-menu" id="dropdownMenu<%= form.uuid %>" role="menu"
|
|
||||||
aria-labelledby="actionsButton<%= form.uuid %>">
|
|
||||||
<!-- Added aria-labelledby for context -->
|
|
||||||
<li role="none"><a role="menuitem" class="dropdown-item"
|
|
||||||
href="/admin/submissions/<%= form.uuid %>">View Submissions</a>
|
|
||||||
</li>
|
|
||||||
<li role="none"><a role="menuitem" class="dropdown-item"
|
|
||||||
href="/admin/submissions/<%= form.uuid %>/export">Export
|
|
||||||
Submissions</a>
|
|
||||||
</li>
|
|
||||||
<li role="separator" class="dropdown-divider">
|
|
||||||
<hr class="dropdown-divider" />
|
|
||||||
</li>
|
|
||||||
<li role="none"><button role="menuitem" type="button" class="dropdown-item"
|
|
||||||
data-action="show-modal"
|
|
||||||
data-modal="renameModal<%= form.uuid %>">Rename Form</button>
|
|
||||||
</li>
|
|
||||||
<li role="none"><button role="menuitem" type="button" class="dropdown-item"
|
|
||||||
data-action="show-modal"
|
|
||||||
data-modal="domainsModal<%= form.uuid %>">Set Allowed
|
|
||||||
Domains</button>
|
|
||||||
</li>
|
|
||||||
<li role="none"><button role="menuitem" type="button" class="dropdown-item"
|
|
||||||
data-action="test-notification" data-form-id="<%= form.uuid %>">Test
|
|
||||||
Notification</button></li>
|
|
||||||
<li role="separator" class="dropdown-divider">
|
|
||||||
<hr class="dropdown-divider" />
|
|
||||||
</li>
|
|
||||||
<li role="none">
|
|
||||||
<form action="/admin/archive-form/<%= form.uuid %>" method="POST"
|
|
||||||
style="display: block;">
|
|
||||||
<input type="hidden" name="archive"
|
|
||||||
value="<%= form.is_archived ? 'false' : 'true' %>" />
|
|
||||||
<button type="submit" class="dropdown-item">
|
|
||||||
<%= form.is_archived ? 'Unarchive Form' : 'Archive Form' %>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
<li role="none">
|
|
||||||
<form action="/admin/delete-form/<%= form.uuid %>" method="POST"
|
|
||||||
style="display: block;">
|
|
||||||
<button type="submit" class="dropdown-item text-danger"
|
|
||||||
onclick="return confirm('Are you sure you want to delete this form? This action cannot be undone.')"
|
|
||||||
aria-describedby="deleteWarning<%= form.uuid %>">Delete
|
|
||||||
Form</button>
|
|
||||||
</form>
|
|
||||||
<span id="deleteWarning<%= form.uuid %>"
|
|
||||||
class="visually-hidden">Warning:
|
|
||||||
Deleting this form is permanent and cannot be undone.</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-card-content">
|
|
||||||
<p class="form-submission-count">
|
|
||||||
<%= form.submission_count %> submission<%= form.submission_count !==1 ? 's' : ''
|
|
||||||
%>
|
|
||||||
</p>
|
|
||||||
<div class="form-url-info">
|
|
||||||
<label for="formUrl<%= form.uuid %>" class="form-label visually-hidden">Form URL
|
|
||||||
for <%= form.name %></label>
|
|
||||||
<input type="text" id="formUrl<%= form.uuid %>" readonly
|
|
||||||
value="<%= appUrl %>/submit/<%= form.uuid %>" class="form-url-display"
|
|
||||||
aria-label="Form URL for <%= form.name %> (Read-only)">
|
|
||||||
<button type="button" class="button button-sm button-secondary copy-button"
|
|
||||||
data-copy-target="#formUrl<%= form.uuid %>" title="Copy URL to clipboard">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14"
|
|
||||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
|
||||||
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"
|
|
||||||
focusable="false">
|
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1">
|
|
||||||
</path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Rename Modal -->
|
|
||||||
<div class="modal" id="renameModal<%= form.uuid %>" role="dialog" aria-modal="true"
|
|
||||||
aria-hidden="true" aria-labelledby="renameModalTitle<%= form.uuid %>">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="renameModalTitle<%= form.uuid %>">Rename Form</h5>
|
|
||||||
<button type="button" class="btn-close" data-action="hide-modal"
|
|
||||||
data-modal="renameModal<%= form.uuid %>"
|
|
||||||
aria-label="Close rename form modal">×</button>
|
|
||||||
</div>
|
|
||||||
<form action="/admin/update-form-name/<%= form.uuid %>" method="POST">
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="newName<%= form.uuid %>" class="form-label">New Name</label>
|
|
||||||
<input type="text" id="newName<%= form.uuid %>" name="newName"
|
|
||||||
value="<%= form.name %>" required />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="button button-secondary"
|
|
||||||
data-action="hide-modal"
|
|
||||||
data-modal="renameModal<%= form.uuid %>">Cancel</button>
|
|
||||||
<button type="submit" class="button">Save Changes</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Allowed Domains Modal -->
|
|
||||||
<div class="modal" id="domainsModal<%= form.uuid %>" role="dialog" aria-modal="true"
|
|
||||||
aria-hidden="true" aria-labelledby="domainsModalTitle<%= form.uuid %>">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title" id="domainsModalTitle<%= form.uuid %>">Set Allowed
|
|
||||||
Domains</h5>
|
|
||||||
<button type="button" class="btn-close" data-action="hide-modal"
|
|
||||||
data-modal="domainsModal<%= form.uuid %>"
|
|
||||||
aria-label="Close allowed domains modal">×</button>
|
|
||||||
</div>
|
|
||||||
<form action="/admin/update-allowed-domains/<%= form.uuid %>" method="POST">
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="allowedDomains<%= form.uuid %>" class="form-label">Allowed
|
|
||||||
Domains (comma-separated)</label>
|
|
||||||
<input type="text" id="allowedDomains<%= form.uuid %>"
|
|
||||||
name="allowedDomains"
|
|
||||||
placeholder="example.com, subdomain.example.com"
|
|
||||||
value="<%= form.allowed_domains ? form.allowed_domains.join(', ') : '' %>"
|
|
||||||
aria-describedby="domainsHelp<%= form.uuid %>" />
|
|
||||||
<small class="form-text" id="domainsHelp<%= form.uuid %>">Leave empty to
|
|
||||||
allow submissions from any domain.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="button button-secondary"
|
|
||||||
data-action="hide-modal"
|
|
||||||
data-modal="domainsModal<%= form.uuid %>">Cancel</button>
|
|
||||||
<button type="submit" class="button">Save Changes</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% }); %>
|
|
||||||
<% } %>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/main.js"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
@ -1,71 +0,0 @@
|
|||||||
// Dropdown functionality
|
|
||||||
function toggleDropdown(button) {
|
|
||||||
// Find the closest .dropdown and then the .dropdown-menu inside it
|
|
||||||
const dropdown = button.closest('.dropdown');
|
|
||||||
if (!dropdown) return;
|
|
||||||
const dropdownMenu = dropdown.querySelector('.dropdown-menu');
|
|
||||||
if (dropdownMenu) {
|
|
||||||
dropdownMenu.classList.toggle('show');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modal functionality
|
|
||||||
function showModal(modalId) {
|
|
||||||
document.getElementById(modalId).classList.add('show');
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideModal(modalId) {
|
|
||||||
document.getElementById(modalId).classList.remove('show');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize all event listeners
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
// Handle dropdown toggles
|
|
||||||
document.querySelectorAll('[data-action="toggle-dropdown"]').forEach(button => {
|
|
||||||
button.addEventListener('click', function () {
|
|
||||||
toggleDropdown(this);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle modal show buttons
|
|
||||||
document.querySelectorAll('[data-action="show-modal"]').forEach(button => {
|
|
||||||
button.addEventListener('click', function () {
|
|
||||||
const modalId = this.dataset.modal;
|
|
||||||
showModal(modalId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle modal hide buttons
|
|
||||||
document.querySelectorAll('[data-action="hide-modal"]').forEach(button => {
|
|
||||||
button.addEventListener('click', function () {
|
|
||||||
const modalId = this.dataset.modal;
|
|
||||||
hideModal(modalId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle test notification buttons
|
|
||||||
document.querySelectorAll('[data-action="test-notification"]').forEach(button => {
|
|
||||||
button.addEventListener('click', function () {
|
|
||||||
const formId = this.dataset.formId;
|
|
||||||
// Implement test notification functionality here
|
|
||||||
console.log('Testing notification for form:', formId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close dropdowns when clicking outside
|
|
||||||
document.addEventListener('click', function (event) {
|
|
||||||
// Only close if click is outside any .dropdown
|
|
||||||
if (!event.target.closest('.dropdown')) {
|
|
||||||
document.querySelectorAll('.dropdown-menu.show').forEach(menu => {
|
|
||||||
menu.classList.remove('show');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close modals when clicking outside
|
|
||||||
document.addEventListener('click', function (event) {
|
|
||||||
if (event.target.classList.contains('modal')) {
|
|
||||||
event.target.classList.remove('show');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,177 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Submissions - <%= formName %>
|
|
||||||
</title>
|
|
||||||
<link rel="stylesheet" href="/global.css">
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<main id="main-content">
|
|
||||||
<div class="dashboard-header">
|
|
||||||
<h1 class="dashboard-title">Submissions for <%= formName %>
|
|
||||||
</h1>
|
|
||||||
<div>
|
|
||||||
<a href="/admin" class="button button-secondary me-2">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
aria-hidden="true" focusable="false">
|
|
||||||
<line x1="19" y1="12" x2="5" y2="12"></line>
|
|
||||||
<polyline points="12 19 5 12 12 5"></polyline>
|
|
||||||
</svg>
|
|
||||||
Back to Forms
|
|
||||||
</a>
|
|
||||||
<a href="/admin/submissions/<%= formUuid %>/export" class="button">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
|
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
|
||||||
aria-hidden="true" focusable="false">
|
|
||||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
|
||||||
<polyline points="7 10 12 15 17 10"></polyline>
|
|
||||||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
|
||||||
</svg>
|
|
||||||
Export CSV
|
|
||||||
</a>
|
|
||||||
<form action="/admin/clear-submissions/<%= formUuid %>" method="POST"
|
|
||||||
style="display: inline-block; margin-left: 0.5rem;">
|
|
||||||
<button type="submit" class="button button-danger"
|
|
||||||
onclick="return confirm('Are you sure you want to delete ALL submissions for this form? This action cannot be undone.')">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
|
|
||||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
|
||||||
stroke-linejoin="round" aria-hidden="true" focusable="false">
|
|
||||||
<polyline points="3 6 5 6 21 6"></polyline>
|
|
||||||
<path
|
|
||||||
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
|
|
||||||
</path>
|
|
||||||
<line x1="10" y1="11" x2="10" y2="17"></line>
|
|
||||||
<line x1="14" y1="11" x2="14" y2="17"></line>
|
|
||||||
</svg>
|
|
||||||
Clear All Submissions
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% if (submissions.length===0) { %>
|
|
||||||
<div class="alert-info-custom">No submissions yet for this form.</div>
|
|
||||||
<% } else { %>
|
|
||||||
<div class="submissions-table-wrapper">
|
|
||||||
<table class="submissions-table">
|
|
||||||
<caption class="visually-hidden">List of submissions for the form named <%= formName %>.
|
|
||||||
</caption>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col">Submitted At</th>
|
|
||||||
<th scope="col">IP Address</th>
|
|
||||||
<th scope="col">Data</th>
|
|
||||||
<th scope="col" style="text-align: right;">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<% submissions.forEach(submission=> { %>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<%= new Date(submission.submitted_at).toLocaleString() %>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<%= submission.ip_address %>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<% const data=JSON.parse(submission.data); %>
|
|
||||||
<% Object.entries(data).forEach(([key, value])=> { %>
|
|
||||||
<% if (key !=='honeypot_field' && key !=='_thankyou' ) { %>
|
|
||||||
<div class="submission-data-item">
|
|
||||||
<strong>
|
|
||||||
<%= key %>:
|
|
||||||
</strong>
|
|
||||||
<span>
|
|
||||||
<%= typeof value==='object' ? JSON.stringify(value) :
|
|
||||||
value %>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<% } %>
|
|
||||||
<% }); %>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="table-actions">
|
|
||||||
<form action="/admin/delete-submission/<%= submission.id %>"
|
|
||||||
method="POST" style="display: inline;">
|
|
||||||
<button type="submit" class="table-action delete"
|
|
||||||
title="Delete Submission"
|
|
||||||
onclick="return confirm('Are you sure you want to delete this submission?')"
|
|
||||||
aria-label="Delete submission from <%= submission.ip_address %> at <%= new Date(submission.submitted_at).toLocaleString() %>">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18"
|
|
||||||
viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
||||||
stroke-width="2" stroke-linecap="round"
|
|
||||||
stroke-linejoin="round" aria-hidden="true"
|
|
||||||
focusable="false">
|
|
||||||
<polyline points="3 6 5 6 21 6"></polyline>
|
|
||||||
<path
|
|
||||||
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2">
|
|
||||||
</path>
|
|
||||||
<line x1="10" y1="11" x2="10" y2="17"></line>
|
|
||||||
<line x1="14" y1="11" x2="14" y2="17"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<% }); %>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
<% if (pagination.totalPages> 1) { %>
|
|
||||||
<nav aria-label="Submissions pagination">
|
|
||||||
<ul class="pagination">
|
|
||||||
<% if (pagination.currentPage> 1) { %>
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link"
|
|
||||||
href="/admin/submissions/<%= formUuid %>?page=<%= pagination.currentPage - 1 %>&limit=<%= pagination.limit %>">Previous</a>
|
|
||||||
</li>
|
|
||||||
<% } else { %>
|
|
||||||
<li class="page-item disabled"><span class="page-link"
|
|
||||||
aria-disabled="true">Previous</span></li>
|
|
||||||
<% } %>
|
|
||||||
<% for(let i=1; i <=pagination.totalPages; i++) { %>
|
|
||||||
<li
|
|
||||||
class="page-item <%= i === pagination.currentPage ? 'active' : '' %>">
|
|
||||||
<a class="page-link"
|
|
||||||
href="/admin/submissions/<%= formUuid %>?page=<%= i %>&limit=<%= pagination.limit %>"
|
|
||||||
<% if (i===pagination.currentPage) { %>
|
|
||||||
aria-current="page" <% } %>>
|
|
||||||
<%= i %>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<% } %>
|
|
||||||
<% if (pagination.currentPage < pagination.totalPages) { %>
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link"
|
|
||||||
href="/admin/submissions/<%= formUuid %>?page=<%= pagination.currentPage + 1 %>&limit=<%= pagination.limit %>">Next</a>
|
|
||||||
</li>
|
|
||||||
<% } else { %>
|
|
||||||
<li class="page-item disabled"><span class="page-link"
|
|
||||||
aria-disabled="true">Next</span></li>
|
|
||||||
<% } %>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
<div class="pagination-info" role="status" aria-live="polite">
|
|
||||||
Showing <%= (pagination.currentPage - 1) * pagination.limit + 1 %> to <%=
|
|
||||||
Math.min(pagination.currentPage * pagination.limit, pagination.totalSubmissions) %>
|
|
||||||
of <%= pagination.totalSubmissions %> submissions
|
|
||||||
</div>
|
|
||||||
<% } %>
|
|
||||||
<% } %>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
<script src="/main.js"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
Loading…
Reference in New Issue
Block a user