diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..08926877662fbbcb7caeab67484f03394099831c --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +POSTGRES_USER= +POSTGRES_PASSWORD= +POSTGRES_DB= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..1cd5fbcf8c2fe948a71c69d64fad98ebda7ad183 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +db/ +.env +.DS_Store +src/server/html/ +.idea/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 2e3ddd6d8b2132e3c4b53de7ba0e9ce61e60670a..08018e27836989ec35c8bf0ac2f0b1b491b5556f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1 +1,6 @@ -FROM php:8.0-apache \ No newline at end of file +FROM php:8.0-apache + +RUN apt-get update && apt-get install -y libpq-dev +RUN docker-php-ext-install pdo pdo_pgsql +COPY ./php.ini /usr/local/etc/php/php.ini +RUN a2enmod rewrite \ No newline at end of file diff --git a/README.md b/README.md index 1132afae3862a26173197e27c2d754967e6a953d..74e59f30e0b01fc99b8b4c9a6b4a894012aa8141 100644 --- a/README.md +++ b/README.md @@ -1 +1,134 @@ # Drawl + +Drawl adalah sebuah aplikasi berbasis web yang memungkinkan pengguna terdaftar untuk membuat daftar "watchlist". Di +dalam watchlist ini, pengguna dapat menyimpan daftar anime dan drama yang ingin ditonton atau direkomendasikan ke orang +lain. Terdapat dua visibility, yaitu public dan private watchlist. Aplikasi ini juga memberikan kesempatan bagi pengguna +yang belum terdaftar untuk menjelajahi dan melihat watchlist serta katalog anime dan drama yang ada tanpa harus +mendaftar. Selain itu, pengguna terdaftar juga +dapat menyimpan dan menyukai suatu watchlist. + +Terdapat tiga role dalam aplikasi ini, yaitu unregistered user, registered user, dan admin. Admin bertugas untuk +menambahkan catalog anime dan drama. + +## Daftar Isi + +- [Daftar Requirements](#daftar-requirements) +- [Cara Instalasi](#cara-instalasi) +- [Cara Menjalankan Server](#cara-menjalankan-server) +- [Tangkapan Layar](#tangkapan-layar) +- [Pembagian Tugas](#pembagian-tugas) + +## Daftar Requirements + +- Docker + +## Cara Instalasi + +Silakan kunjungi halaman official docker pada [link](https://www.docker.com/products/docker-desktop/) berikut. Lalu +install sesuai dengan sistem operasi Anda. + +## Cara Menjalankan Server + +1. Pastikan docker telah terinstall +2. Build docker image dengan menajalankan command pada folder `scripts/build-image.sh` +3. Pastikan port `5432`, `8008`, dan `8080` tidak sedang digunakan +4. Jalankan server dengan command `docker compose up` +5. Tunggu beberapa detik hingga server berhasil berjalan dan database siap menerima koneksi. +6. Aplikasi siap untuk digunakan +7. Jika ingin melakukan seed catalog, dapat menggunakan query yang ada pada folder `/src/seed/seed.sql` + +## Tangkapan Layar + +### Home + + + +### Sign In + + + +### Sign Up + + + +### Profile + + + +### My Bookmark + + + +### My Watchlist + + + +### Catalog + + + +### Catalog Create + + + +### Catalog Delete + + + +### Catalog Detail + + + +### Catalog Edit + + + +### Watchlist Detail + + + +### Watchlist Detail + + + +## Pembagian Tugas + +### Server Side + +| Tugas | NIM | +|--------------------|----------| +| Setup Awal Project | 13521150 | +| Sign In | 13521048 | +| Sign Up | 13521048 | +| Profile | 13521048 | +| My Bookmark | 13521153 | +| My Watchlist | 13521153 | +| Catalog | 13521153 | +| Catalog Create | 13521153 | +| Catalog Delete | 13521153 | +| Catalog Detail | 13521153 | +| Catalog Edit | 13521153 | +| Home | 13521150 | +| Watchlist Create | 13521150 | +| Watchlist Delete | 13521150 | +| Watchlist Edit | 13521150 | + +### Client Side + +| Tugas | NIM | +|--------------------|----------| +| Setup Awal Project | 13521150 | +| Sign In | 13521048 | +| Sign Up | 13521048 | +| Profile | 13521048 | +| My Bookmark | 13521153 | +| My Watchlist | 13521153 | +| Catalog | 13521153 | +| Catalog Create | 13521153 | +| Catalog Delete | 13521153 | +| Catalog Detail | 13521153 | +| Catalog Edit | 13521153 | +| Home | 13521150 | +| Watchlist Create | 13521150 | +| Watchlist Delete | 13521150 | +| Watchlist Edit | 13521150 | \ No newline at end of file diff --git a/assets/lighthouse/catalog-create.png b/assets/lighthouse/catalog-create.png new file mode 100644 index 0000000000000000000000000000000000000000..ffc30456db7e401133bdf59a24e36251fbf31b95 Binary files /dev/null and b/assets/lighthouse/catalog-create.png differ diff --git a/assets/lighthouse/catalog-delete.png b/assets/lighthouse/catalog-delete.png new file mode 100644 index 0000000000000000000000000000000000000000..4bdc783a43865d0d0be5170b4836bda917c7d0a0 Binary files /dev/null and b/assets/lighthouse/catalog-delete.png differ diff --git a/assets/lighthouse/catalog-detail.png b/assets/lighthouse/catalog-detail.png new file mode 100644 index 0000000000000000000000000000000000000000..0a702365f27b4f031d2b7b17281e31c0f2f5d871 Binary files /dev/null and b/assets/lighthouse/catalog-detail.png differ diff --git a/assets/lighthouse/catalog-edit.png b/assets/lighthouse/catalog-edit.png new file mode 100644 index 0000000000000000000000000000000000000000..0c2d6d35630db390ec2eb8d9f7853fb425d3e5ce Binary files /dev/null and b/assets/lighthouse/catalog-edit.png differ diff --git a/assets/lighthouse/catalog.png b/assets/lighthouse/catalog.png new file mode 100644 index 0000000000000000000000000000000000000000..1addcbe4772e0e4110fb54e511e61a0b0c2bd7a3 Binary files /dev/null and b/assets/lighthouse/catalog.png differ diff --git a/assets/lighthouse/home.png b/assets/lighthouse/home.png new file mode 100644 index 0000000000000000000000000000000000000000..937c92a34d1977b311d96439ac66c1c2b262cbd9 Binary files /dev/null and b/assets/lighthouse/home.png differ diff --git a/assets/lighthouse/my-bookmark.png b/assets/lighthouse/my-bookmark.png new file mode 100644 index 0000000000000000000000000000000000000000..5f990c91341c3b0edf49e8b3fa2a7a6499ffdabb Binary files /dev/null and b/assets/lighthouse/my-bookmark.png differ diff --git a/assets/lighthouse/my-watchlist.png b/assets/lighthouse/my-watchlist.png new file mode 100644 index 0000000000000000000000000000000000000000..3d2614733002e02414841cbb61d28cf094cd36ea Binary files /dev/null and b/assets/lighthouse/my-watchlist.png differ diff --git a/assets/lighthouse/profile.png b/assets/lighthouse/profile.png new file mode 100644 index 0000000000000000000000000000000000000000..49213d89e27aef3a2d37fc8a04debdc33893abf4 Binary files /dev/null and b/assets/lighthouse/profile.png differ diff --git a/assets/lighthouse/signin.png b/assets/lighthouse/signin.png new file mode 100644 index 0000000000000000000000000000000000000000..80bfebfd18bdccdb606689053a585f9456f9863b Binary files /dev/null and b/assets/lighthouse/signin.png differ diff --git a/assets/lighthouse/signup.png b/assets/lighthouse/signup.png new file mode 100644 index 0000000000000000000000000000000000000000..2941c4aff19d751f609226009997657a847d13c8 Binary files /dev/null and b/assets/lighthouse/signup.png differ diff --git a/assets/lighthouse/watchlist-delete.png b/assets/lighthouse/watchlist-delete.png new file mode 100644 index 0000000000000000000000000000000000000000..d66eddf49b9fbc1b64091ddbdd3158b6e0824b38 Binary files /dev/null and b/assets/lighthouse/watchlist-delete.png differ diff --git a/assets/lighthouse/watchlist-detail.png b/assets/lighthouse/watchlist-detail.png new file mode 100644 index 0000000000000000000000000000000000000000..ed087cee6f6e31644d3c64f3c16105eccc99a20f Binary files /dev/null and b/assets/lighthouse/watchlist-detail.png differ diff --git a/assets/lighthouse/watchlist-edit.png b/assets/lighthouse/watchlist-edit.png new file mode 100644 index 0000000000000000000000000000000000000000..ad85ffa30b77eb6ae8bbbe9cb5f40761f39d6510 Binary files /dev/null and b/assets/lighthouse/watchlist-edit.png differ diff --git a/docker-compose.yml b/docker-compose.yml index 7c5e0aeeb93e6ef1a07efefaf9b94d87a8878aad..e29b6893d953a795cce2214bcbf9237e5ba1faef 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,5 +2,25 @@ version: '3.3' services: web: image: tubes-1:latest + volumes: + - ./src/server:/var/www + - ./src/public:/var/www/html ports: - 8008:80 + db: + image: postgres:latest + restart: always + volumes: + - "./db:/var/lib/postgresql/data" + - "./src/migration:/docker-entrypoint-initdb.d/" + ports: + - "5432:5432" + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + adminer: + image: adminer:latest + restart: always + ports: + - "8080:8080" diff --git a/php.ini b/php.ini new file mode 100644 index 0000000000000000000000000000000000000000..4e75d5fcb6dcc57e1506a0cb77065d1f06886980 --- /dev/null +++ b/php.ini @@ -0,0 +1,3 @@ +file_uploads = On +upload_max_filesize = 100M +post_max_size = 100M \ No newline at end of file diff --git a/src/migration/db.sql b/src/migration/db.sql new file mode 100644 index 0000000000000000000000000000000000000000..14fc6159cb1cb3697b2f5810931d6c8acbf82a7c --- /dev/null +++ b/src/migration/db.sql @@ -0,0 +1,231 @@ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role') THEN + CREATE TYPE role AS ENUM ('BASIC', 'ADMIN'); + END IF; +END +$$; + +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + uuid VARCHAR(36) NOT NULL UNIQUE, + name VARCHAR(40) NOT NULL, + password VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + role role DEFAULT 'BASIC' NOT NULL, + + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions ( + id VARCHAR(16) PRIMARY KEY, + user_id INT NOT NULL, + expired TIMESTAMPTZ NOT NULL, + + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) +); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'category') THEN + CREATE TYPE category AS ENUM ('ANIME', 'DRAMA', 'MIXED'); + END IF; +END +$$; + +CREATE TABLE IF NOT EXISTS catalogs ( + id SERIAL PRIMARY KEY, + uuid VARCHAR(36) NOT NULL UNIQUE, + title VARCHAR(100) NOT NULL, + description VARCHAR(255), + poster VARCHAR(255) NOT NULL, + trailer VARCHAR(255), + category category NOT NULL, + + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + + CHECK (category IN ('ANIME', 'DRAMA')) +); + +CREATE OR REPLACE FUNCTION updated_at() +RETURNS TRIGGER +LANGUAGE PLPGSQL +AS $$ +BEGIN + NEW.updated_at = NOW(); + return NEW; +END; +$$; + +CREATE OR REPLACE TRIGGER t_user_updated_at BEFORE UPDATE +ON users FOR EACH ROW EXECUTE PROCEDURE updated_at(); + +CREATE OR REPLACE TRIGGER t_catalog_updated_at BEFORE UPDATE +ON catalogs FOR EACH ROW EXECUTE PROCEDURE updated_at(); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'visibility') THEN + CREATE TYPE visibility AS ENUM ('PRIVATE', 'PUBLIC'); + END IF; +END +$$; + +CREATE TABLE IF NOT EXISTS watchlists ( + id SERIAL NOT NULL PRIMARY KEY, + uuid VARCHAR(36) NOT NULL UNIQUE, + title VARCHAR(40) NOT NULL, + description VARCHAR(255), + category category NOT NULL DEFAULT 'MIXED', + user_id integer NOT NULL, + item_count integer DEFAULT 0 NOT NULL, + like_count integer DEFAULT 0 NOT NULL, + visibility visibility DEFAULT 'PRIVATE' NOT NULL, + + created_at timestamp DEFAULT now() NOT NULL, + updated_at timestamp DEFAULT now() NOT NULL, + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE OR REPLACE TRIGGER t_watchlist_updated_at BEFORE UPDATE +ON watchlists FOR EACH ROW EXECUTE PROCEDURE updated_at(); + +CREATE TABLE IF NOT EXISTS watchlist_items( + id SERIAL NOT NULL PRIMARY KEY, + uuid VARCHAR(36) NOT NULL UNIQUE, + + rank INT NOT NULL, + description VARCHAR(255), + watchlist_id integer NOT NULL, + catalog_id integer NOT NULL, + + created_at timestamp DEFAULT now() NOT NULL, + updated_at timestamp DEFAULT now() NOT NULL, + + FOREIGN KEY (watchlist_id) REFERENCES watchlists(id) ON DELETE CASCADE, + FOREIGN KEY (catalog_id) REFERENCES catalogs(id) ON DELETE CASCADE +); + +CREATE OR REPLACE TRIGGER t_watchlist_items_updated_at BEFORE UPDATE +ON watchlists FOR EACH ROW EXECUTE PROCEDURE updated_at(); + +CREATE OR REPLACE FUNCTION watchlist_item_count() +RETURNS TRIGGER +LANGUAGE PLPGSQL +AS $$ +BEGIN + IF (TG_OP = 'INSERT') THEN + UPDATE watchlists SET item_count = item_count + 1 WHERE id = NEW.watchlist_id; + ELSIF (TG_OP = 'DELETE') THEN + UPDATE watchlists SET item_count = item_count - 1 WHERE id = OLD.watchlist_id; + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE TRIGGER t_watchlist_item_count AFTER INSERT OR DELETE +ON watchlist_items FOR EACH ROW EXECUTE PROCEDURE watchlist_item_count(); + +CREATE TABLE IF NOT EXISTS watchlist_like ( + id SERIAL NOT NULL PRIMARY KEY, + user_id integer NOT NULL, + watchlist_id integer NOT NULL, + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (watchlist_id) REFERENCES watchlists(id) ON DELETE CASCADE +); + + +CREATE OR REPLACE FUNCTION watchlist_like_count() +RETURNS TRIGGER +LANGUAGE PLPGSQL +AS $$ +BEGIN + IF (TG_OP = 'INSERT') THEN + UPDATE watchlists SET like_count = like_count + 1 WHERE id = NEW.watchlist_id; + ELSIF (TG_OP = 'DELETE') THEN + UPDATE watchlists SET like_count = like_count - 1 WHERE id = OLD.watchlist_id; + END IF; + RETURN NEW; +END; +$$; + +CREATE OR REPLACE TRIGGER t_watchlist_like_count AFTER INSERT OR DELETE +ON watchlist_like FOR EACH ROW EXECUTE PROCEDURE watchlist_like_count(); + +CREATE TABLE IF NOT EXISTS watchlist_save ( + id SERIAL NOT NULL PRIMARY KEY, + user_id integer NOT NULL, + watchlist_id integer NOT NULL, + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (watchlist_id) REFERENCES watchlists(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS comments ( + id SERIAL NOT NULL PRIMARY KEY, + uuid VARCHAR(36) NOT NULL UNIQUE, + content VARCHAR(255) NOT NULL, + user_id integer NOT NULL, + watchlist_id integer NOT NULL, + + created_at timestamp DEFAULT now() NOT NULL, + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (watchlist_id) REFERENCES watchlists(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS tags ( + id SERIAL PRIMARY KEY, + name VARCHAR(40) NOT NULL UNIQUE, + + created_at timestamp DEFAULT now() NOT NULL +); + +CREATE TABLE IF NOT EXISTS watchlist_tag ( + watchlist_id integer NOT NULL, + tag_id integer NOT NULL, + + PRIMARY KEY (watchlist_id, tag_id), + FOREIGN KEY (watchlist_id) REFERENCES watchlists(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE +); + +INSERT INTO users (uuid, name, password, email, role) VALUES ('72c5a265715fa26c', 'admin', '$2y$10$rAfDHA4M4ftn8K7Wx82wf.fFODD7PCE/t9CVnBwdLnTDBYjnq7ZnO', 'admin@drawl.com', 'ADMIN'); +INSERT INTO tags(name) VALUES ('ACTION'), + ('ADVENTURE'), + ('ANIMALS'), + ('BUSINESS'), + ('COMEDY'), + ('CRIME'), + ('DETECTIVE'), + ('DOCUMENTARY'), + ('DRAMA'), + ('FAMILY'), + ('FANTASY'), + ('FOOD'), + ('HISTORICAL'), + ('HORROR'), + ('LAW'), + ('LIFE'), + ('MANGA'), + ('MEDICAL'), + ('MATURE'), + ('MYSTERY'), + ('MUSIC'), + ('MILITARY'), + ('MELODRAMA'), + ('PSYCHOLOGICAL'), + ('ROMANCE'), + ('SCHOOL'), + ('SCI-FI'), + ('SPORTS'), + ('SUPERNATURAL'), + ('THRILLER'), + ('YOUTH') +; \ No newline at end of file diff --git a/src/public/.htaccess b/src/public/.htaccess new file mode 100644 index 0000000000000000000000000000000000000000..9e25198b5a4aad8a2399f162f7c2a40e77090aea --- /dev/null +++ b/src/public/.htaccess @@ -0,0 +1,4 @@ +RewriteEngine On +RewriteCond %{REQUEST_FILENAME} !-f +RewriteCond %{REQUEST_FILENAME} !-d +RewriteRule ^(.*)$ index.php/$1 [L] \ No newline at end of file diff --git a/src/public/assets/fonts/karla/karla-latin-200-italic.woff2 b/src/public/assets/fonts/karla/karla-latin-200-italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..54705f8eea00e1ce119e0d6d18a79fd00c58daab Binary files /dev/null and b/src/public/assets/fonts/karla/karla-latin-200-italic.woff2 differ diff --git a/src/public/assets/fonts/karla/karla-latin-200-normal.woff2 b/src/public/assets/fonts/karla/karla-latin-200-normal.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..fc394ea4f8c58232ae8765336748fd9a13d2d2ae Binary files /dev/null and b/src/public/assets/fonts/karla/karla-latin-200-normal.woff2 differ diff --git a/src/public/assets/fonts/karla/karla-latin-400-italic.woff2 b/src/public/assets/fonts/karla/karla-latin-400-italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..9e9891f80db3dddb227925102b38c87331b26380 Binary files /dev/null and b/src/public/assets/fonts/karla/karla-latin-400-italic.woff2 differ diff --git a/src/public/assets/fonts/karla/karla-latin-400-normal.woff2 b/src/public/assets/fonts/karla/karla-latin-400-normal.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..24d3513e4917784b44287ba856a979f6d0f0a72d Binary files /dev/null and b/src/public/assets/fonts/karla/karla-latin-400-normal.woff2 differ diff --git a/src/public/assets/fonts/karla/karla-latin-600-italic.woff2 b/src/public/assets/fonts/karla/karla-latin-600-italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..3d5d9024923d7a5244dfc0cbd6b8046fe158bcb6 Binary files /dev/null and b/src/public/assets/fonts/karla/karla-latin-600-italic.woff2 differ diff --git a/src/public/assets/fonts/karla/karla-latin-600-normal.woff2 b/src/public/assets/fonts/karla/karla-latin-600-normal.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b3be775a15eafbfe5f1d39c8a74464ec0191dfef Binary files /dev/null and b/src/public/assets/fonts/karla/karla-latin-600-normal.woff2 differ diff --git a/src/public/assets/fonts/karla/karla-latin-800-italic.woff2 b/src/public/assets/fonts/karla/karla-latin-800-italic.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..99ffa5f485f372d42158ac2c1b9d3eb8b71ab9b1 Binary files /dev/null and b/src/public/assets/fonts/karla/karla-latin-800-italic.woff2 differ diff --git a/src/public/assets/fonts/karla/karla-latin-800-normal.woff2 b/src/public/assets/fonts/karla/karla-latin-800-normal.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..51770db63579a8bc11d7ccc593013b36b252a50a Binary files /dev/null and b/src/public/assets/fonts/karla/karla-latin-800-normal.woff2 differ diff --git a/src/public/assets/icons/asc.php b/src/public/assets/icons/asc.php new file mode 100644 index 0000000000000000000000000000000000000000..ed0a098546505309928376c69d508bec676bf9e7 --- /dev/null +++ b/src/public/assets/icons/asc.php @@ -0,0 +1,7 @@ +<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" class="icon icon-asc"> + <path d="m3 8 4-4 4 4" /> + <path d="M7 4v16" /> + <path d="M11 12h4" /> + <path d="M11 16h7" /> + <path d="M11 20h10" /> +</svg> \ No newline at end of file diff --git a/src/public/assets/icons/bookmark.php b/src/public/assets/icons/bookmark.php new file mode 100644 index 0000000000000000000000000000000000000000..367bf45fe328c8b968006b2de32493dbd3093583 --- /dev/null +++ b/src/public/assets/icons/bookmark.php @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" + stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="icon icon-bookmark" + data-type="<?= $type ?? "unfilled" ?>"> + <path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" /> +</svg> \ No newline at end of file diff --git a/src/public/assets/icons/cancel.php b/src/public/assets/icons/cancel.php new file mode 100644 index 0000000000000000000000000000000000000000..706d750d093c02f125bddf7451798674dae73f74 --- /dev/null +++ b/src/public/assets/icons/cancel.php @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none"> + <path d="M1 13L7.00001 7.00002M7.00001 7.00002L13 1M7.00001 7.00002L1 1M7.00001 7.00002L13 13" stroke="black" + stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /> +</svg> \ No newline at end of file diff --git a/src/public/assets/icons/chevron-down.php b/src/public/assets/icons/chevron-down.php new file mode 100644 index 0000000000000000000000000000000000000000..a83082cd1ffeb85909773f843bfabee7cc33358c --- /dev/null +++ b/src/public/assets/icons/chevron-down.php @@ -0,0 +1,4 @@ +<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" class="lucide lucide-chevron-down"> + <path d="m6 9 6 6 6-6" /> +</svg> \ No newline at end of file diff --git a/src/public/assets/icons/clapperboard.php b/src/public/assets/icons/clapperboard.php new file mode 100644 index 0000000000000000000000000000000000000000..f225387f69df44cac4dc80c984eebeea43ff80f0 --- /dev/null +++ b/src/public/assets/icons/clapperboard.php @@ -0,0 +1,6 @@ +<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" class="icon icon-clapperboard"> + <path d="M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3Z" /> + <path d="m6.2 5.3 3.1 3.9" /> + <path d="m12.4 3.4 3.1 4" /> + <path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" /> +</svg> \ No newline at end of file diff --git a/src/public/assets/icons/delete.php b/src/public/assets/icons/delete.php new file mode 100644 index 0000000000000000000000000000000000000000..126721acc98239e068df4ae5d5780d946a3accfd --- /dev/null +++ b/src/public/assets/icons/delete.php @@ -0,0 +1,3 @@ +<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="32px" height="32px" viewBox="0 0 64 64"> + <path d="M 26.9375 4 C 26.0435 4 25.203813 4.3940781 24.632812 5.0800781 C 24.574813 5.1490781 24.527234 5.2256406 24.490234 5.3066406 L 22.357422 10 L 11 10 C 9.346 10 8 11.346 8 13 L 8 19 C 8 20.654 9.346 22 11 22 L 13 22 L 13 57 C 13 58.654 14.346 60 16 60 L 48 60 C 49.654 60 51 58.654 51 57 L 51 22 L 53 22 C 54.654 22 56 20.654 56 19 L 56 13 C 56 11.346 54.654 10 53 10 L 41.644531 10 L 39.511719 5.3066406 C 39.474719 5.2256406 39.426141 5.1480781 39.369141 5.0800781 C 38.797141 4.3940781 37.957453 4 37.064453 4 L 26.9375 4 z M 26.9375 6 L 37.0625 6 C 37.3225 6 37.569906 6.1003437 37.753906 6.2773438 L 39.447266 10 L 24.552734 10 L 26.246094 6.2773438 C 26.431094 6.1003438 26.6775 6 26.9375 6 z M 11 12 L 53 12 C 53.551 12 54 12.448 54 13 L 54 19 C 54 19.552 53.551 20 53 20 L 11 20 C 10.449 20 10 19.552 10 19 L 10 13 C 10 12.448 10.449 12 11 12 z M 14 14 C 13.448 14 13 14.447 13 15 L 13 17 C 13 17.553 13.448 18 14 18 C 14.552 18 15 17.553 15 17 L 15 15 C 15 14.447 14.552 14 14 14 z M 19 14 C 18.448 14 18 14.447 18 15 L 18 17 C 18 17.553 18.448 18 19 18 C 19.552 18 20 17.553 20 17 L 20 15 C 20 14.447 19.552 14 19 14 z M 24 14 C 23.448 14 23 14.447 23 15 L 23 17 C 23 17.553 23.448 18 24 18 C 24.552 18 25 17.553 25 17 L 25 15 C 25 14.447 24.552 14 24 14 z M 29 14 C 28.448 14 28 14.447 28 15 L 28 17 C 28 17.553 28.448 18 29 18 C 29.552 18 30 17.553 30 17 L 30 15 C 30 14.447 29.552 14 29 14 z M 35 14 C 34.448 14 34 14.447 34 15 L 34 17 C 34 17.553 34.448 18 35 18 C 35.552 18 36 17.553 36 17 L 36 15 C 36 14.447 35.552 14 35 14 z M 40 14 C 39.448 14 39 14.447 39 15 L 39 17 C 39 17.553 39.448 18 40 18 C 40.552 18 41 17.553 41 17 L 41 15 C 41 14.447 40.552 14 40 14 z M 45 14 C 44.448 14 44 14.447 44 15 L 44 17 C 44 17.553 44.448 18 45 18 C 45.552 18 46 17.553 46 17 L 46 15 C 46 14.447 45.552 14 45 14 z M 50 14 C 49.448 14 49 14.447 49 15 L 49 17 C 49 17.553 49.448 18 50 18 C 50.552 18 51 17.553 51 17 L 51 15 C 51 14.447 50.552 14 50 14 z M 15 22 L 49 22 L 49 57 C 49 57.552 48.551 58 48 58 L 16 58 C 15.449 58 15 57.552 15 57 L 15 56 L 38 56 C 38.552 56 39 55.553 39 55 C 39 54.447 38.552 54 38 54 L 15 54 L 15 22 z M 20 28 C 19.448 28 19 28.447 19 29 L 19 41 C 19 41.553 19.448 42 20 42 C 20.552 42 21 41.553 21 41 L 21 29 C 21 28.447 20.552 28 20 28 z M 28 28 C 27.448 28 27 28.447 27 29 L 27 49 C 27 49.553 27.448 50 28 50 C 28.552 50 29 49.553 29 49 L 29 29 C 29 28.447 28.552 28 28 28 z M 36 28 C 35.448 28 35 28.447 35 29 L 35 49 C 35 49.553 35.448 50 36 50 C 36.552 50 37 49.553 37 49 L 37 29 C 37 28.447 36.552 28 36 28 z M 44 28 C 43.448 28 43 28.447 43 29 L 43 33 C 43 33.553 43.448 34 44 34 C 44.552 34 45 33.553 45 33 L 45 29 C 45 28.447 44.552 28 44 28 z M 44 36 C 43.448 36 43 36.447 43 37 L 43 49 C 43 49.553 43.448 50 44 50 C 44.552 50 45 49.553 45 49 L 45 37 C 45 36.447 44.552 36 44 36 z M 20 44 C 19.448 44 19 44.447 19 45 L 19 49 C 19 49.553 19.448 50 20 50 C 20.552 50 21 49.553 21 49 L 21 45 C 21 44.447 20.552 44 20 44 z M 42 54 C 41.448 54 41 54.447 41 55 C 41 55.553 41.448 56 42 56 L 46 56 C 46.552 56 47 55.553 47 55 C 47 54.447 46.552 54 46 54 L 42 54 z"></path> +</svg> \ No newline at end of file diff --git a/src/public/assets/icons/desc.php b/src/public/assets/icons/desc.php new file mode 100644 index 0000000000000000000000000000000000000000..c19b68124ee5efdc515d9c9b79fe36994ea308fa --- /dev/null +++ b/src/public/assets/icons/desc.php @@ -0,0 +1,7 @@ +<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" class="lucide lucide-arrow-down-wide-narrow"> + <path d="m3 16 4 4 4-4" /> + <path d="M7 20V4" /> + <path d="M11 4h10" /> + <path d="M11 8h7" /> + <path d="M11 12h4" /> +</svg> \ No newline at end of file diff --git a/src/public/assets/icons/dot.php b/src/public/assets/icons/dot.php new file mode 100644 index 0000000000000000000000000000000000000000..dbbe2607563c72e598a7ed698f22fd3ff94bdfdd --- /dev/null +++ b/src/public/assets/icons/dot.php @@ -0,0 +1,3 @@ +<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" class="lucide lucide-dot"> + <circle cx="12.1" cy="12.1" r="1" /> +</svg> \ No newline at end of file diff --git a/src/public/assets/icons/edit.php b/src/public/assets/icons/edit.php new file mode 100644 index 0000000000000000000000000000000000000000..96560c384482c0338e165312c16461e5ca6245d8 --- /dev/null +++ b/src/public/assets/icons/edit.php @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none"> + <path + d="M9.96006 3.56001L12.52 1L17 5.48001L14.44 8.03998M9.96006 3.56001L1.26509 12.2549C1.09536 12.4246 1 12.6549 1 12.8949V17H5.1051C5.34515 17 5.57536 16.9047 5.7451 16.7349L14.44 8.03998M9.96006 3.56001L14.44 8.03998" + stroke="#0F172A" stroke-width="1.49583" stroke-linecap="round" stroke-linejoin="round" /> +</svg> \ No newline at end of file diff --git a/src/public/assets/icons/error.php b/src/public/assets/icons/error.php new file mode 100644 index 0000000000000000000000000000000000000000..87c5c9a9422cb6e0512865e4361da84a0cef1748 --- /dev/null +++ b/src/public/assets/icons/error.php @@ -0,0 +1,9 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none"> + <path + d="M9 17C13.4182 17 17 13.4182 17 9C17 4.58172 13.4182 1 9 1C4.58172 1 1 4.58172 1 9C1 13.4182 4.58172 17 9 17Z" + stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /> + <path d="M11.4006 12.2003L6.60059 5.80029" stroke="black" stroke-width="1.5" stroke-linecap="round" + stroke-linejoin="round" /> + <path d="M6.60059 12.2003L11.4006 5.80029" stroke="black" stroke-width="1.5" stroke-linecap="round" + stroke-linejoin="round" /> +</svg> \ No newline at end of file diff --git a/src/public/assets/icons/grip-vertical.php b/src/public/assets/icons/grip-vertical.php new file mode 100644 index 0000000000000000000000000000000000000000..98136a2beb965ba854dd6d42c76881e89a5989ed --- /dev/null +++ b/src/public/assets/icons/grip-vertical.php @@ -0,0 +1,8 @@ +<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" class="icon icon-grip-vertical"> + <circle cx="9" cy="12" r="1" /> + <circle cx="9" cy="5" r="1" /> + <circle cx="9" cy="19" r="1" /> + <circle cx="15" cy="12" r="1" /> + <circle cx="15" cy="5" r="1" /> + <circle cx="15" cy="19" r="1" /> +</svg> \ No newline at end of file diff --git a/src/public/assets/icons/info.php b/src/public/assets/icons/info.php new file mode 100644 index 0000000000000000000000000000000000000000..217ef2466fbf62301feabd4d6cb86bc642cf3aee --- /dev/null +++ b/src/public/assets/icons/info.php @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="2" height="3" viewBox="0 0 2 3" fill="none"> + <path d="M1 1.30598L1.006 1.29932" stroke="black" stroke-width="1.5" stroke-linecap="round" + stroke-linejoin="round" /> +</svg> \ No newline at end of file diff --git a/src/public/assets/icons/lock.php b/src/public/assets/icons/lock.php new file mode 100644 index 0000000000000000000000000000000000000000..1ac6723f62583198e074f416fe3bda2ec0c372fa --- /dev/null +++ b/src/public/assets/icons/lock.php @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="12" height="14" viewBox="0 0 12 14" fill="none"> + <path + d="M9 7H10.05C10.2985 7 10.5 7.20145 10.5 7.45V12.55C10.5 12.7985 10.2985 13 10.05 13H1.95C1.70147 13 1.5 12.7985 1.5 12.55V7.45C1.5 7.20145 1.70147 7 1.95 7H3M9 7V4C9 3 8.4 1 6 1C3.6 1 3 3 3 4V7M9 7H3" + stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /> +</svg> \ No newline at end of file diff --git a/src/public/assets/icons/love.php b/src/public/assets/icons/love.php new file mode 100644 index 0000000000000000000000000000000000000000..86cee7cd8a9ecbb77bf93b8061146838fb28164b --- /dev/null +++ b/src/public/assets/icons/love.php @@ -0,0 +1,6 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" + stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="icon icon-heart" + data-type="<?= $type ?? "unfilled" ?>"> + <path + d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z" /> +</svg> \ No newline at end of file diff --git a/src/public/assets/icons/menu.php b/src/public/assets/icons/menu.php new file mode 100644 index 0000000000000000000000000000000000000000..bc4b8af3a410394c93c11e368c980ec2121c2b02 --- /dev/null +++ b/src/public/assets/icons/menu.php @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="18" height="14" viewBox="0 0 18 14" fill="none"> + <path d="M1 1H16.4286" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /> + <path d="M1 7H16.4286" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /> + <path d="M1 13H16.4286" stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /> +</svg> \ No newline at end of file diff --git a/src/public/assets/icons/plus.php b/src/public/assets/icons/plus.php new file mode 100644 index 0000000000000000000000000000000000000000..6517d7a94e602745f29b27333ef7a2b1ae6dcd76 --- /dev/null +++ b/src/public/assets/icons/plus.php @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus"> + <path d="M5 12h14" /> + <path d="M12 5v14" /> +</svg> \ No newline at end of file diff --git a/src/public/assets/icons/search.php b/src/public/assets/icons/search.php new file mode 100644 index 0000000000000000000000000000000000000000..e6aca1214ea9f64b288a2822f755d8e2c8c88ab9 --- /dev/null +++ b/src/public/assets/icons/search.php @@ -0,0 +1,4 @@ +<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" class="lucide lucide-search"> + <circle cx="11" cy="11" r="8" /> + <path d="m21 21-4.3-4.3" /> +</svg> \ No newline at end of file diff --git a/src/public/assets/icons/success.php b/src/public/assets/icons/success.php new file mode 100644 index 0000000000000000000000000000000000000000..bd6348451040b9a68d5e49576020b114c044485e --- /dev/null +++ b/src/public/assets/icons/success.php @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none"> + <path + d="M7 13C10.3137 13 13 10.3137 13 7C13 3.68629 10.3137 1 7 1C3.68629 1 1 3.68629 1 7C1 10.3137 3.68629 13 7 13Z" + stroke="black" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /> +</svg> \ No newline at end of file diff --git a/src/public/assets/icons/trash.php b/src/public/assets/icons/trash.php new file mode 100644 index 0000000000000000000000000000000000000000..2ee21928e606ff05b65249181a4e81763e331708 --- /dev/null +++ b/src/public/assets/icons/trash.php @@ -0,0 +1,12 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18" fill="none"> + <path + d="M15.2218 8.11133V16.4669C15.2218 16.7615 14.983 17.0002 14.6885 17.0002H3.31068C3.01613 17.0002 2.77734 16.7615 2.77734 16.4669V8.11133" + stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /> + <path d="M7.22266 13.4447V8.11133" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" + stroke-linejoin="round" /> + <path d="M10.7773 13.4447V8.11133" stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" + stroke-linejoin="round" /> + <path + d="M17 4.55556H12.5556M12.5556 4.55556V1.53333C12.5556 1.23878 12.3168 1 12.0222 1H5.97778C5.68323 1 5.44444 1.23878 5.44444 1.53333V4.55556M12.5556 4.55556H5.44444M1 4.55556H5.44444" + stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /> +</svg> \ No newline at end of file diff --git a/src/public/assets/icons/user.php b/src/public/assets/icons/user.php new file mode 100644 index 0000000000000000000000000000000000000000..0084567817ae83f90d4986c308c750df18a81dbc --- /dev/null +++ b/src/public/assets/icons/user.php @@ -0,0 +1,10 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="21" height="22" viewBox="0 0 21 22" fill="none"> + <path + d="M10.5 1.5C5.25329 1.5 1 5.75329 1 11C1 16.2467 5.25329 20.5 10.5 20.5C15.7467 20.5 20 16.2467 20 11C20 5.75329 15.7467 1.5 10.5 1.5Z" + stroke="#1E2124" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> + <path d="M3.15747 17.0284C3.15747 17.0284 5.27504 14.325 10.5 14.325C15.725 14.325 17.8427 17.0284 17.8427 17.0284" + stroke="#1E2124" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> + <path + d="M10.4999 11C12.074 11 13.3499 9.7241 13.3499 8.15005C13.3499 6.57604 12.074 5.30005 10.4999 5.30005C8.92585 5.30005 7.6499 6.57604 7.6499 8.15005C7.6499 9.7241 8.92585 11 10.4999 11Z" + stroke="#1E2124" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> +</svg> \ No newline at end of file diff --git a/src/public/assets/icons/x.php b/src/public/assets/icons/x.php new file mode 100644 index 0000000000000000000000000000000000000000..b5aceff64dd9692669d48b3680d022fc93af4dd1 --- /dev/null +++ b/src/public/assets/icons/x.php @@ -0,0 +1,4 @@ +<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" class="icon icon-x"> + <path d="M18 6 6 18" /> + <path d="m6 6 12 12" /> +</svg> \ No newline at end of file diff --git a/src/public/assets/images/Suzume.webp b/src/public/assets/images/Suzume.webp new file mode 100644 index 0000000000000000000000000000000000000000..b7d441f7ad5de6a1d34fcc12f7ee0fc8e86ce6af Binary files /dev/null and b/src/public/assets/images/Suzume.webp differ diff --git a/src/public/assets/images/Tomorrow.webp b/src/public/assets/images/Tomorrow.webp new file mode 100644 index 0000000000000000000000000000000000000000..07da06b6da6d18be0aea5b513a5b7d735f6c913c Binary files /dev/null and b/src/public/assets/images/Tomorrow.webp differ diff --git a/src/public/assets/images/catalogs/posters/5a5ac4ad0c3a5e7c.webp b/src/public/assets/images/catalogs/posters/5a5ac4ad0c3a5e7c.webp new file mode 100644 index 0000000000000000000000000000000000000000..0b915c9f2363cf4fff97c124da1ebd98f3012b50 Binary files /dev/null and b/src/public/assets/images/catalogs/posters/5a5ac4ad0c3a5e7c.webp differ diff --git a/src/public/assets/images/catalogs/posters/5a6b36907cd0f469.webp b/src/public/assets/images/catalogs/posters/5a6b36907cd0f469.webp new file mode 100644 index 0000000000000000000000000000000000000000..05cd190eba43ef330ebd42a0909d34fdd45cb3bc Binary files /dev/null and b/src/public/assets/images/catalogs/posters/5a6b36907cd0f469.webp differ diff --git a/src/public/assets/images/catalogs/posters/no-poster.webp b/src/public/assets/images/catalogs/posters/no-poster.webp new file mode 100644 index 0000000000000000000000000000000000000000..f5a0f34071dd9e57d1c1ced894189082f60c6140 Binary files /dev/null and b/src/public/assets/images/catalogs/posters/no-poster.webp differ diff --git a/src/public/assets/videos/catalogs/trailers/6517bd1c9f2a7_Frieren b/src/public/assets/videos/catalogs/trailers/6517bd1c9f2a7_Frieren new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/public/assets/videos/catalogs/trailers/6517bd1c9f2a7_FrierenBeyond's the Journey Ends.mp4 b/src/public/assets/videos/catalogs/trailers/6517bd1c9f2a7_FrierenBeyond's the Journey Ends.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..99c1c2430523e47ea2c8a065c65278533fc26d0a Binary files /dev/null and b/src/public/assets/videos/catalogs/trailers/6517bd1c9f2a7_FrierenBeyond's the Journey Ends.mp4 differ diff --git a/src/public/assets/videos/catalogs/trailers/a3e992b0d939a896.mp4 b/src/public/assets/videos/catalogs/trailers/a3e992b0d939a896.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..99c1c2430523e47ea2c8a065c65278533fc26d0a Binary files /dev/null and b/src/public/assets/videos/catalogs/trailers/a3e992b0d939a896.mp4 differ diff --git a/src/public/assets/videos/catalogs/trailers/the-journey-of-elaina.mp4 b/src/public/assets/videos/catalogs/trailers/the-journey-of-elaina.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..99c1c2430523e47ea2c8a065c65278533fc26d0a Binary files /dev/null and b/src/public/assets/videos/catalogs/trailers/the-journey-of-elaina.mp4 differ diff --git a/src/public/css/catalog-detail.css b/src/public/css/catalog-detail.css new file mode 100644 index 0000000000000000000000000000000000000000..9c1716ce00c6d8012fdd077ceb00c34f9ecebc1b --- /dev/null +++ b/src/public/css/catalog-detail.css @@ -0,0 +1,77 @@ +.catalog-detail-header { + display: flex; + flex-direction: column; + align-items: flex-start; + align-self: stretch; +} + +.catalog-detail-header-poster { + background-color: var(--accent-400); + width: 100%; + height: 240px; +} + +.catalog-detail-content { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + flex: 1 0 0; + margin: 0 12px; +} + +.catalog-detail-content h2 { + width: 100%; + overflow-wrap: break-word; +} + +.poster { + margin-top: -100px; + margin-left: 12px; +} + +.button-container { + position: fixed; + bottom: 20px; + right: 20px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.btn-icon { + background: #FFF; + border: 1px solid rgba(0, 0, 0, .1); + cursor: pointer; + padding: 10px 10px; + border-radius: 6px; +} + +.btn-icon:hover { + background-color: var(--accent-300); + transition: all; + transition-duration: 400ms; + transition-timing-function: ease-in-out; +} + +@media screen and (min-width: 640px) { + .poster { + margin-left: 32px; + margin-top: -40px; + } + + .catalog-detail-content { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + flex: 1 0 0; + margin: 0 32px; + } + + .button-container { + bottom: 40px; + right: 3rem; + gap: 10px; + } +} diff --git a/src/public/css/catalog-form.css b/src/public/css/catalog-form.css new file mode 100644 index 0000000000000000000000000000000000000000..943817704fa2c4c29d09255a37f968e2de8698fe --- /dev/null +++ b/src/public/css/catalog-form.css @@ -0,0 +1,16 @@ +form { + display: flex; + flex-direction: column; + width: 100%; + gap: 1rem; +} + +form > button { + margin-top: 40px; +} + +@media screen and (min-width: 640px) { + .container { + width: 800px; + } + } \ No newline at end of file diff --git a/src/public/css/catalog.css b/src/public/css/catalog.css new file mode 100644 index 0000000000000000000000000000000000000000..e0a38b10e46d7b653deb5185d836ea94dfe22638 --- /dev/null +++ b/src/public/css/catalog.css @@ -0,0 +1,29 @@ +form { + display: flex; + flex-wrap: wrap; + gap: 1rem; +} + +.input { + display: flex; + align-items: center; + gap: 10px; +} + +.search-filter { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 2rem; + align-self: stretch; +} + +@media screen and (min-width: 640px) { + form { + display: flex; + align-items: center; + gap: 1.5rem; + } +} \ No newline at end of file diff --git a/src/public/css/components/modal/watchlistAddItem.css b/src/public/css/components/modal/watchlistAddItem.css new file mode 100644 index 0000000000000000000000000000000000000000..ea9037ec5b13185ec3c99b9da80c73aff48b5dfa --- /dev/null +++ b/src/public/css/components/modal/watchlistAddItem.css @@ -0,0 +1,23 @@ +.form-search { + display: flex; +} + +.search { + display: flex; + align-items: center; + width: 100%; + gap: 0.4rem; + margin: 0 0 1rem 0; +} + +.search__input { + flex-grow: 1; +} + +.search__items { + display: flex; + padding: 0 0 1rem 0; + flex-direction: column; + max-height: 60vh; + overflow: auto; +} \ No newline at end of file diff --git a/src/public/css/components/modal/watchlistAddSearchItem.css b/src/public/css/components/modal/watchlistAddSearchItem.css new file mode 100644 index 0000000000000000000000000000000000000000..dd5dbaa80a022629b8e5122cbd93572da7a842d5 --- /dev/null +++ b/src/public/css/components/modal/watchlistAddSearchItem.css @@ -0,0 +1,34 @@ +.search-item { + display: flex; + gap: 1rem; + align-items: center; + padding: 1rem 0; +} + +.search-item:not(:last-child) { + border-bottom: 1px solid rgba(0, 0, 0, .1); +} + +.search-item__poster { + height: 8rem; + width: 5.4rem; + object-fit: cover; + border-radius: 0.6rem; +} + +.search-item__content { + display: flex; + flex-direction: column; + gap: 0.4rem; + flex-grow: 1; + width: 50%; +} + +.search-item__action { + padding: 0.2rem; +} + +.search-item__title { + width: 100%; + overflow-wrap: break-word; +} \ No newline at end of file diff --git a/src/public/css/components/watchlist/watchlistItem.css b/src/public/css/components/watchlist/watchlistItem.css new file mode 100644 index 0000000000000000000000000000000000000000..29e47a31e347987421e3b6ae2f9731ac143a8b0a --- /dev/null +++ b/src/public/css/components/watchlist/watchlistItem.css @@ -0,0 +1,64 @@ +.watchlist-item { + display: flex; + align-items: center; + gap: 1rem; + width: 100%; + padding: 1rem 0; + cursor: move; +} + +.watchlist-item:not(:last-child) { + border-bottom: 1px solid rgba(0, 0, 0, .1); +} + +.watchlist-item__wrapper { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 0.4rem; + width: 100%; +} + +.watchlist-item__poster { + height: 8rem; + width: 5.4rem; + border-radius: 0.4rem; + object-fit: cover; +} + +.watchlist-item__content { + display: flex; + flex-direction: column; + gap: 1rem; + width: 100%; +} +/* .watchlist-item__description { + font-size: 0.8rem; +} */ + +.watchlist-item__actions { + display: flex; + flex-direction: column; + align-items: center; + justify-content: star; + gap: 2rem; +} + +.watchlist-item__delete { + padding: 0; +} + +.dragging { + opacity: .5; +} + +@media screen and (min-width: 640px) { + /* .watchlist-item__description { + font-size: 1rem; + } */ + .watchlist-item__poster { + height: 10rem; + width: 7.4rem; + } +} \ No newline at end of file diff --git a/src/public/css/editProfile.css b/src/public/css/editProfile.css new file mode 100644 index 0000000000000000000000000000000000000000..cf3034eb7efe8018c481169791e731dcf3dad427 --- /dev/null +++ b/src/public/css/editProfile.css @@ -0,0 +1,100 @@ + + + +.edit-parameters { + align-self: stretch; + height: 415px; + padding-bottom: 24px; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + gap: 16px; + display: flex; +} + +.my-profile-container { + align-self: stretch; + height: 24px; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + gap: 32px; + display: flex +} + + +.display-name { + align-self: stretch; + height: 44px; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + gap: 8px; + display: flex +} + + +.input-container { + width: 100%; + height: 100%; + justify-content: flex-start; + align-items: flex-start; + gap: 12px; + display: inline-flex +} + +.input-box { + flex: 1 1 0; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + gap: 6px; + display: inline-flex; +} + +.password.display-name { + align-self: stretch; + height: 231px; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + gap: 8px; + display: flex +} + +.password-button-container { + align-self: stretch; + height: 204px; + gap: 24px; +} + +.password-container { + align-self: stretch; + height: 140px; + gap: 8px; +} + +.password-title { + align-self: stretch; + height: 66px; + gap: 10px; +} + +.password-texts { + justify-content: center; + align-items: center; + gap: 10px; + display: inline-flex +} + +.red-star { + color: #FC7979; + font-size: 14px; + font-weight: 400; + line-height: 20px; +} + + + + + diff --git a/src/public/css/error-page.css b/src/public/css/error-page.css new file mode 100644 index 0000000000000000000000000000000000000000..ccf06a80978525b2ca6684611cd90107c165af66 --- /dev/null +++ b/src/public/css/error-page.css @@ -0,0 +1,55 @@ +.error-page-container div:has(h1) { + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + flex: 1 0 0; + align-self: stretch; +} + +.error-page-container h1 { + color: var(--accent-800, #8C48A8); + font-size: 10rem; + font-weight: bolder; + mix-blend-mode: color-dodge; +} + +.error-page-container h2 { + font-size: 1.5rem; + font-weight: bolder; + text-align: center; +} + +.error-page-container p { + font-size: 1rem; + text-align: center; +} + +.error-page-container div:has(h2) { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 16px; + align-self: stretch; +} + +.error-page-container a { + width: fit-content; + margin-top: 24px; +} + +.error-page-container span { + filter: blur(2px); +} + +@media screen and (min-width: 640px){ + .error-page-container h1 { + font-size: 12rem; + } + + .error-page-container h2 { + font-size: 2rem; + } +} \ No newline at end of file diff --git a/src/public/css/global.css b/src/public/css/global.css new file mode 100644 index 0000000000000000000000000000000000000000..d358c67392517cb9609de03ae4b61e13da149238 --- /dev/null +++ b/src/public/css/global.css @@ -0,0 +1,1299 @@ +:root { + --primary-100: #424549; + --primary-200: #36393E; + --primary-300: #282B30; + --primary-400: #1E2124; + + --accent-100: #FBF7FD; + --accent-200: #F5EDFA; + --accent-300: #EDDEF6; + --accent-400: #DFC4EE; + --accent-500: #CC9EE2; + --accent-600: #BD84D7; + --accent-700: #A35BC2; + --accent-800: #8C48A8; + --accent-900: #753F8A; + --accent-1000: #5F346F; + --accent-1100: #421B50; + + --slate-100: #f1f5f9; + --slate-200: #e2e8f0; + --slate-300: #cbd5e1; + --slate-400: #94a3b8; + --slate-500: #64748b; + --slate-600: #475569; + --slate-700: #334155; + --slate-800: #1e293b; + --slate-900: #0f172a; + + --error-background: #FEE2E2; + --error-foreground: #B91C1C; + + --info-background: #dbeafe; + --info-foreground: #1d4ed8; + + --success-background: #dcfce7; + --success-foreground: #15803d; +} + +@font-face { + font-family: 'Karla'; + src: url(/assets/fonts/karla/karla-latin-200-normal.woff2); + font-weight: 200; + font-style: normal; + font-display: swap; + +} + +@font-face { + font-family: 'Karla'; + src: url(/assets/fonts/karla/karla-latin-200-italic.woff2); + font-weight: 200; + font-style: italic; + font-display: swap; + +} + +@font-face { + font-family: 'Karla'; + src: url(/assets/fonts/karla/karla-latin-400-normal.woff2); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Karla'; + src: url(/assets/fonts/karla/karla-latin-400-italic.woff2); + font-weight: 400; + font-style: italic; + font-display: swap; + +} + +@font-face { + font-family: 'Karla'; + src: url(/assets/fonts/karla/karla-latin-600-normal.woff2); + font-weight: 600; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Karla'; + src: url(/assets/fonts/karla/karla-latin-600-italic.woff2); + font-weight: 600; + font-style: italic; + font-display: swap; +} + +@font-face { + font-family: 'Karla'; + src: url(/assets/fonts/karla/karla-latin-800-normal.woff2); + font-weight: 800; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: 'Karla'; + src: url(/assets/fonts/karla/karla-latin-800-italic.woff2); + font-weight: 800; + font-style: italic; + font-display: swap; +} + +* { + inset: unset; + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: 'Karla'; + font-weight: 400; +} + +body { + position: relative; + display: flex; + min-height: 100vh; + flex-direction: column; + color: var(--primary-300); +} + +/* Typography */ + +h2 { + color: var(--accent-800); + font-size: 1.5rem; + font-style: normal; + font-weight: 600; + line-height: 1.5rem; +} + +h3 { + color: var(--accent-800); + font-weight: 600; + font-size: 1rem; +} + +h4 { + color: var(--accent-800, #8C48A8); +} + +p { + color: var(--slate-700, #334155); + font-size: 0.875rem; + font-style: normal; + font-weight: 400; + line-height: normal; + overflow-wrap: break-word; + width: 100%; +} + +p.subtitle, +span.subtitle { + color: var(--slate-600, #94A3B8); + font-size: 0.875rem; + font-style: normal; + font-weight: 400; + line-height: normal; +} + +a { + text-decoration: none; + color: var(--primary-300); +} + +.title { + color: var(--accent-800); +} + +.brand__title { + font-size: 1.5rem; + color: var(--accent-800); + font-weight: 800; +} + +.span-icon { + display: flex; + align-items: center; + justify-content: center; +} + +/* Layout */ + +main, +.container { + display: flex; + flex-direction: column; + padding: 0 1rem; + max-width: 80rem; + width: 100%; + margin: 3rem auto; + position: relative; + z-index: 10; + gap: 2rem; + min-height: 80vh; +} + +.alert-error { + display: flex; + background-color: var(--accent-300); + width: 100%; + padding: 0.4rem; + border-radius: 0.4rem; +} + +.hidden { + display: none !important; +} + +/* Button */ +button { + background: none; + border: none; + cursor: pointer; + border-radius: 6px; + padding: 8px 16px; + gap: 8px; + display: flex; + justify-content: center; + align-items: center; + font-weight: 600; + white-space: nowrap; + text-transform: uppercase; +} + +button:hover { + background-color: var(--accent-300); + transition: all; + transition-duration: 400ms; + transition-timing-function: ease-in-out; +} + +.btn { + background: none; + border: none; + font: inherit; + color: inherit; + cursor: pointer; + border-radius: 6px; + padding: 8px 16px; + gap: 8px; + display: flex; + justify-content: center; + align-items: center; + font-weight: 600; + white-space: nowrap; + text-transform: uppercase; +} + +.btn:hover { + background-color: var(--accent-300); + transition: all; + transition-duration: 400ms; + transition-timing-function: ease-in-out; +} + +.btn-icon { + padding: 10px 10px; +} + +.btn-bold { + background: var(--accent-1100, #421B50); + color: var(--accent-100, #FBF7FD); +} + +.btn-bold:hover { + background-color: var(--accent-1000, #5F346F); + transition: all; + transition-duration: 400ms; + transition-timing-function: ease-in-out; +} + +.btn-primary { + display: inline-flex; + width: 100%; + max-width: fit-content; + text-decoration: none; + color: var(--primary-400); + background-color: var(--accent-300); + padding: 8px 12px; + border-radius: 5px; + border: none; + cursor: pointer; + align-items: center; + justify-content: center; + gap: 0.4rem; + font-size: 1rem; +} + +.btn-primary:hover { + background-color: var(--accent-300); + transition: all; + transition-duration: 400ms; + transition-timing-function: ease-in-out; +} + +.btn-text { + color: var(--accent-800); +} + +.btn-ghost:hover { + background-color: transparent; +} + +.btn-outline { + border: 1px solid rgba(0, 0, 0, .1); +} + +.btn-outline:hover { + background-color: var(--accent-100); +} + +/* Navbar */ + +.navbar { + top: 1rem; + position: sticky; + padding: 0 1rem; + z-index: 40; +} + +.navbar-toggle { + padding: 10px 10px; +} + +.navbar-content { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + max-width: 80rem; + width: 100%; + background-color: rgba(255, 255, 255, 0.5); + backdrop-filter: blur(8px); + box-shadow: rgba(99, 99, 99, 0.2) 0px 2px 8px 0px; + margin: 0 auto; + border-radius: 0.4rem; + z-index: 40; + position: relative; +} + +.navbar-header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 1rem 1.5rem; +} + +.navbar-menu { + width: 100%; + position: absolute; + top: 72px; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 1rem; + padding: 1rem 1.5rem; + background-color: #FFF; + box-shadow: rgba(99, 99, 99, 0.2) 0px 2px 8px 0px; +} + +.navbar-menu.collapsed { + display: none; +} + +.navbar-menu .profile-menu { + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 1rem; + background-color: #FFF; +} + +.navbar-menu .profile-menu a, +.navbar-menu .profile-menu button { + width: 100%; + align-items: flex-start; + justify-content: flex-start; +} + +.navbar-menu .profile-menu.collapsed { + display: flex; +} + +.navbar-menu:not(.collapsed) .profile-icon { + display: none; +} + +.navbar-menu a { + width: 100%; + align-items: flex-start; + justify-content: flex-start; +} + +.navbar:has(.navbar-toggle:focus) .navbar-menu { + display: flex; +} + +@media screen and (min-width: 640px) { + .navbar-toggle { + display: none; + } + + .navbar-menu { + display: flex; + background-color: transparent; + position: static; + flex-direction: row; + align-items: center; + justify-content: flex-end; + box-shadow: none; + gap: 0; + } + + .navbar-menu .profile-menu { + width: fit-content; + position: absolute; + top: 80px; + padding: 1rem 1.5rem; + border-radius: 6px; + box-shadow: rgba(99, 99, 99, 0.2) 0px 2px 8px 0px; + } + + .navbar-menu.collapsed { + display: flex; + } + + .navbar-menu .profile-menu.collapsed { + display: none; + } + + .navbar-menu a { + width: auto; + } + + .navbar-header { + padding: none; + } +} + +/* Content Layout */ + +.content { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1.5rem; + align-self: stretch; +} + +.content .pagination { + display: flex; + align-items: flex-start; + margin-top: 80px; +} + +/* Media */ + +.poster { + transition: top ease 1s; + position: relative; + height: 8rem; + width: 5.4rem; + object-fit: cover; + border-radius: 0.6rem; + box-shadow: rgba(104, 104, 104, 0.45) 5px 2.4px 5px; +} + +@media screen and (min-width: 500px) { + .poster { + height: 10rem; + width: 7.4rem; + } +} + +/* Tag */ + +.tag { + display: flex; + padding: 0px 10px; + justify-content: center; + align-items: center; + gap: 10px; + border-radius: 8px; + background: var(--accent-300, #EDDEF6); + width: fit-content; + + font-size: 0.8rem; + font-style: normal; + font-weight: 400; + text-transform: lowercase; +} + +/* Avatar */ + +.avatar { + display: flex; + width: 2.5rem; + height: 2.5rem; + justify-content: center; + align-items: center; + flex-shrink: 0; + border-radius: 2.5rem; + object-fit: cover; + background: url(<path-to-image>), lightgray 50% / cover no-repeat; +} + +/* Alert */ + +.alert { + display: flex; + padding: 16px 6px; + justify-content: space-between; + align-items: flex-start; + align-self: stretch; + border-radius: 6px; +} + +.alert[data-type="error"] { + background-color: var(--error-background); +} + +.alert[data-type="error"] h3 { + color: var(--error-foreground); +} + +.alert[data-type="error"] p { + color: var(--error-foreground); +} + +.alert[data-type="error"] svg path { + stroke: var(--error-foreground); +} + +.alert[data-type="info"] { + background-color: var(--info-background); +} + +.alert[data-type="info"] h3 { + color: var(--info-foreground); +} + +.alert[data-type="error"] p { + color: var(--error-foreground); +} + +.alert[data-type="info"] svg path { + stroke: var(--info-foreground); +} + +.alert[data-type="success"] { + background-color: var(--success-background); +} + +.alert[data-type="success"] h3 { + color: var(--success-foreground); +} + +.alert[data-type="success"] svg path { + stroke: var(--success-foreground); +} + +.alert div { + display: flex; + padding: 0px 12px; + flex-direction: column; + align-items: flex-start; + gap: 6px; + flex: 1 0 0; +} + +.alert h3 { + font-size: 0.875rem; +} + +.alert p { + font-size: 0.625rem; +} + +.alert > svg { + display: none; +} + +/* LOADING */ +.loading { + display: flex; + align-items: center; + justify-content: center; + padding: 0.6rem; +} + +@media screen and (min-width: 640px) { + .alert { + padding: 24px 20px; + } + + .alert h3 { + font-size: 1rem; + } + + .alert p { + font-size: 0.875rem; + } + + .alert > svg { + display: block; + } + + .alert div { + padding: 0px 24px; + gap: 10px; + } +} + +/* Card */ +.card { + width: 100%; + display: flex; + flex-direction: column; + align-items: flex-end; + align-self: stretch; + justify-content: space-between; + padding-bottom: 24px; + gap: 24px; + border-bottom: 1px solid var(--slate-100, #CBD5E1); +} + +.card-content { + width: 100%; + display: flex; + align-items: flex-start; + gap: 24px; +} + +.card-title { + font-size: 1.2rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; +} + +.card-body { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + width: 100%; + overflow: hidden; +} + +.card-body > p { + overflow: hidden; + word-wrap: break-word; + width: 100%; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; +} + +.card-button-container { + display: flex; + align-items: flex-start; + gap: 24px; +} + +.card-comment .card-content .card-body .header { + display: flex; + align-items: center; + gap: 10px; +} + +@media screen and (min-width: 640px) { + .card { + flex-direction: row; + align-items: flex-start; + } + + .card-title { + max-width: 100%; + } + + .card-content { + width: 90%; + } + +} + +/* Select */ +.c-select-menu { + min-width: 200px; + width: 100%; + position: relative; +} + +.c-select-menu .c-select-btn { + display: flex; + padding: 0.35rem; + border: 1px solid rgba(0, 0, 0, .1); + border-radius: 0.4rem; + align-items: center; + justify-content: space-between; + cursor: pointer; + position: relative; +} + +.c-select-menu .c-select-options { + position: absolute; + padding: 0.4rem; + top: 3rem; + background-color: #fff; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 0.4rem; + min-width: 200px; + width: 100%; + z-index: 50; + max-height: 20rem; + overflow: auto; +} + +.c-select-options .c-select-option { + display: flex; + padding: 0.6rem; + cursor: pointer; + align-items: center; + border-radius: 0.4rem; +} + +.c-select-options .c-select-option:hover { + background-color: var(--accent-100); +} + +.c-select-hide { + display: none; +} + +/* Pagination */ +.pagination { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + margin-top: 80px; +} + +.pagination-elips { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + border-radius: 8px; + width: 36px; + height: 36px; +} + +.pagination-item { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + border-radius: 8px; + width: 36px; + height: 36px; +} + +.pagination-item:hover { + background-color: var(--accent-300); + transition: all; + transition-duration: 400ms; + transition-timing-function: ease-in-out; +} + +.pagination-item[data-type="active"] { + background: var(--accent-300, #EDDEF6); +} + +.pagination-item.prev { + transform: rotate(90deg); +} + +.pagination-item.next { + transform: rotate(-90deg); +} + +/* Dialog */ + +.dialog { + position: fixed; + z-index: 100; + top: 0; + left: 0; + display: flex; + width: 100%; + height: 100vh; + justify-content: center; + align-items: center; + background-color: rgba(255, 255, 255, .5); +} + +.dialog__content { + display: flex; + padding: 20px; + max-width: 90vw; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 20px; + flex-shrink: 0; + border-radius: 10px; + background: #FFF; + margin: 6px; + border: 1px solid rgba(0, 0, 0, .1); +} + +.dialog__content p, +.dialog__content h2 { + align-self: stretch; + overflow-wrap: break-word; + text-align: center; +} + +.dialog__button-container { + display: flex; + justify-content: center; + align-items: flex-start; + gap: 20px; + margin-top: 20px; +} + +body:has(.dialog:not(.hidden)) { + overflow: hidden; +} + +main:has(.dialog:not(.hidden)) { + overflow: hidden; +} + +@media screen and (min-width: 640px) { + .dialog__content { + margin: 0; + max-width: 500px; + } +} + +/* Input */ + +.input-group { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; +} + +.input-required::after { + content: '*'; + color: #FC7979; + margin-left: 8px; +} + +input { + width: 100%; + display: flex; + padding: 0.75rem; + border-radius: 0.4rem; + align-items: flex-start; + align-self: stretch; + border: 1px solid var(--slate-200, #CBD5E1); + background: #FFF; + font-style: normal; + font-size: 0.875rem +} + +label { + color: var(--slate-900, #0F172A); + font-size: 0.875rem; + font-style: normal; + font-weight: 400; +} + +textarea { + width: 100%; + display: flex; + height: 160px; + padding: 0.75rem; + border-radius: 0.4rem; + align-items: flex-start; + align-self: stretch; + border: 1px solid var(--slate-200, #CBD5E1); + background: #FFF; + font-style: normal; + font-size: 0.875rem; +} + +input:focus { + outline: none; + outline: 1px solid var(--accent-800); +} + +textarea:focus { + outline: none; + outline: 1px solid var(--accent-800); +} + + +/* Form */ + +.form-default { + display: flex; + flex-direction: column; + width: 100%; + gap: 1rem; +} + +.form-input-default { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.form-input-radio-default { + display: flex; + align-items: center; + gap: 0.8rem; +} + +.input-default { + padding: 0.75rem; + border-radius: 0.4rem; + border: 1px solid rgba(0, 0, 0, .1); + width: 100%; +} + +.input-default:focus { + outline: none; + outline: 1px solid var(--accent-800); +} + +/* Icon */ + +.icon-heart:hover { + fill: var(--accent-600); + transition: all; + transition-duration: 400ms; + transition-timing-function: ease-in-out; +} + +.icon-heart[data-type="filled"] { + fill: var(--accent-600); +} + +.icon-heart[data-type="unfilled"] { + fill: transparent; +} + +.icon-bookmark:hover { + fill: var(--accent-600); + transition: all; + transition-duration: 400ms; + transition-timing-function: ease-in-out; +} + +.icon-bookmark[data-type="filled"] { + fill: var(--accent-600); +} + +.icon-bookmark[data-type="unfilled"] { + fill: transparent; +} + +/* Modal */ + +.modal { + width: 100%; + display: flex; + align-items: center; + flex-direction: column; +} + +.modal__trigger { + width: 100%; +} + + +.modal__content { + display: flex; + gap: 1rem; + position: relative; + width: 100%; + max-width: 40rem; + margin: 1rem; + flex-direction: column; + padding: 1rem; + background-color: white; + border-radius: 0.4rem; + max-height: 80vh; + overflow: auto; + box-shadow: rgba(149, 157, 165, 0.2) 0px 8px 24px; +} + +.modal__backdrop { + position: fixed; + display: none; + align-items: center; + justify-content: center; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1000; + width: 100%; + height: 100vh; + background-color: rgba(255, 255, 255, .5); +} + +.modal__close { + position: absolute; + top: 10px; + right: 10px; + max-width: fit-content; + padding: 0; +} + +.icon-x { + color: rgba(0, 0, 0, .4); +} + +/* Watchlist Card */ + +.watchlist__card { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.6rem; + padding: 1.6rem 0; +} + +.watchlist__card .card-content { + flex-direction: column; + align-items: center; +} + +.list__poster { + display: flex; + flex-direction: row-reverse; + align-items: center; + position: relative; +} + +.list__poster a:not(:first-child), +.list__poster div:not(:first-child) { + margin-right: -2rem; +} + +.list__poster a:hover, +.list__poster div:hover { + position: relative; + z-index: 20 !important; + top: -10px; +} + +.watchlist__wrapper-type-author { + display: flex; + flex-direction: row; + gap: 0.6rem; + flex-wrap: wrap; +} + +.watchlist__meta { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.watchlist__type { + background-color: var(--accent-300); + padding: 0.2rem 0.4rem; + border-radius: 0.6rem; + max-width: fit-content; +} + +.watchlist__description { + margin: 0.4rem 0; +} + +.watchlist__card .author-name { + color: var(--accent-800); +} + +.watchlist__item-count { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.875rem; + color: var(--accent-800); +} + +.watchlist__card .icon-clapperboard { + width: 1.2rem; +} + +.watchlist__actions { + display: flex; + flex-direction: row-reverse; + align-items: center; + justify-content: space-between; + gap: 1rem; + width: 100%; +} + +.watchlist__action-save { + display: flex; + align-items: center; + justify-content: center; +} + +.watchlist__action-love { + display: flex; + align-items: center; + justify-content: center; + gap: 0.4rem; +} + +.watchlist__card .btn-ghost { + padding: 0; +} + +.watchlist__visibility-title { + display: flex; + align-items: center; + gap: 4px; +} + +@media screen and (min-width: 872px) { + .watchlist__card { + flex-direction: row; + } + + .watchlist__actions { + flex-direction: column; + max-width: fit-content; + } + + .watchlist__action-love { + flex-direction: column; + } + + .watchlist__card .card-content { + flex-direction: row; + } +} + +/* No Item */ + +.no-item__container { + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + flex: 1 0 0; + align-self: stretch; + gap: 10px; +} + +.no-item__container h1 { + color: var(--accent-800, #8C48A8); + font-weight: bolder; + mix-blend-mode: color-dodge; + text-align: center; + font-size: 4rem; +} + +.no-item__container h2 { + text-align: center; +} + +.no-item__container p { + font-size: 1rem; + text-align: center; +} + +.no-item__container div:has(h2) { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 16px; + align-self: stretch; +} + +.no-item__container a { + width: fit-content; + margin-top: 24px; +} + +.no-item__container span { + filter: blur(2px); +} + +/* Toast */ + +.toast { + min-width: 250px; + max-width: 80vw; + background-color: var(--error-background); + color: var(--error-foreground); + text-align: center; + border-radius: 6px; + display: flex; + padding: 16px 6px; + justify-content: space-between; + align-items: flex-start; + align-self: stretch; + position: fixed; + z-index: 100; + margin: 0 auto; + left: 0; + right: 0; + top: 8px; +} + +.toast[data-type="error"] { + background-color: var(--error-background); +} + +.toast[data-type="error"] h3 { + color: var(--error-foreground); +} + +.toast[data-type="error"] p { + color: var(--error-foreground); +} + +.toast[data-type="error"] svg path { + stroke: var(--error-foreground); +} + +.toast[data-type="info"] { + background-color: var(--info-background); +} + +.toast[data-type="info"] h3 { + color: var(--info-foreground); +} + +.toast[data-type="error"] p { + color: var(--error-foreground); +} + +.toast[data-type="info"] svg path { + stroke: var(--info-foreground); +} + +.toast[data-type="success"] { + background-color: var(--success-background); +} + +.toast[data-type="success"] h3 { + color: var(--success-foreground); +} + +.toast[data-type="success"] svg path { + stroke: var(--success-foreground); +} + +.toast div { + display: flex; + padding: 0px 12px; + flex-direction: column; + align-items: flex-start; + gap: 6px; + flex: 1 0 0; +} + + +.toast p { + text-align: start; +} + +.toast > svg { + display: none; +} + +@media screen and (min-width: 640px) { + .toast { + max-width: 400px; + } +} + +.catalog-trailer { + width: 80%; + max-width: 600px; + align-self: stretch; +} \ No newline at end of file diff --git a/src/public/css/home.css b/src/public/css/home.css new file mode 100644 index 0000000000000000000000000000000000000000..a7fdea48a3996cef03d17eedd5a115cef8cefc62 --- /dev/null +++ b/src/public/css/home.css @@ -0,0 +1,185 @@ +.button-auth { + display: flex; + justify-content: center; + align-items: center; + gap: 4px; +} + +.form-search-filter { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +@media screen and (min-width: 1024px) { + .form-search-filter { + flex-direction: row; + } +} + +.search { + display: flex; + align-items: center; + flex-grow: 1; + width: 100%; + gap: 1rem; +} + +.input-search { + flex-grow: 1; +} + +.filter { + display: flex; + flex-direction: column; + width: 100%; + justify-content: left; + align-items: center; + gap: 1rem; + z-index: 50; +} + +.filter__sort { + display: flex; + justify-content: center; + align-items: center; + gap: 0.6rem; + width: 100%; +} + + +.btn-sort { + padding: 0.5rem; + background-color: #fff; + border: 1px solid rgba(0, 0, 0, .1); + border-radius: 0.4rem; + cursor: pointer; + +} + +.btn--apply { + width: 100%; + max-width: none; +} + + +.icon-new { + font-size: 12px; +} + +.list__watchlist { + display: flex; + flex-direction: column; +} + +.watchlist { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.6rem; + padding: 1.6rem 0; +} + + +.watchlist:not(:last-child) { + border-bottom: 1px solid rgba(0, 0, 0, .1); +} + +.list__poster { + display: flex; + align-items: center; + position: relative; +} + +.poster:not(:first-child) { + margin-left: -2rem; +} + +.poster:hover { + z-index: 20 !important; + top: -10px; +} + + +.watchlist__content { + display: flex; + flex-direction: column; + gap: 0.8rem; + flex-grow: 1; + width: 100%; +} + +.watchlist__title { + color: var(--accent-800); + font-weight: 600; + font-size: 1.3rem; +} + +.watchlist__meta { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.watchlist__type { + background-color: var(--accent-300); + padding: 0.2rem 0.4rem; + border-radius: 0.6rem; + max-width: fit-content; +} + +.catalog-list-content-author { + color: var(--primary-100); +} + +.author-name { + color: var(--accent-800); +} + +.watchlist__dot { + display: none; +} + +.watchlist__date { + font-size: 0.9rem; + color: var(--slate-400); +} + +.catalog-list-btn { + background-color: transparent; + border: none; + padding: 0; +} + +.watchlist__action-love { + display: flex; + align-items: center; + justify-content: center; + gap: 0.4rem; +} + +@media screen and (min-width: 872px) { + .watchlist { + flex-direction: row; + } + + .watchlist__actions { + flex-direction: column; + max-width: fit-content; + } + + .watchlist__action-love { + flex-direction: column; + } +} + +@media screen and (min-width: 1024px) { + .filter { + flex-direction: row; + } + + .btn--apply { + max-width: fit-content; + } +} \ No newline at end of file diff --git a/src/public/css/signIn.css b/src/public/css/signIn.css new file mode 100644 index 0000000000000000000000000000000000000000..d71a618dc5842cd69e27dfc4e3939be0dd851b10 --- /dev/null +++ b/src/public/css/signIn.css @@ -0,0 +1,108 @@ +.signin-container { + width: 100%; + height: 100%; + background: white; + justify-content: flex-start; + align-items: center; + display: inline-flex; + flex-grow: 1; +} + +.signin-poster { + display: none; + width: 50%; + height: 100vh; + object-fit: cover; + max-width: 50vw; +} + +.right-side { + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; + justify-content: center; + width: 100%; +} + +.main-container { + max-width: 500px; + width: 90%; + padding: 24px; + background: white; + border-radius: 6px; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 40px; + display: flex; +} + +.welcome-text { + flex-direction: column; + justify-content: center; + align-items: center; + gap: 1rem; + display: flex; +} + +.welcome-text__h2 { + font-weight: bold; + font-size: 1.5rem; +} + +.welcome-text__h1 { + text-align: center; +} + +.inputs { + flex-direction: column; + display: flex; + gap: 1rem; + width: 100%; +} + +.parameter { + height: 62px; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + gap: 6px; + display: flex; +} + +.parameter-title { + color: #0f172a; + font-size: 14px; + font-weight: 500; + line-height: 20px; + word-wrap: break-word; +} + +.inputs > button { + margin-bottom: 20px; +} + +form p:has(a) { + text-align: center; +} + + + +@media screen and (min-width: 640px) { + .welcome-text__h2 { + font-size: 2rem; + } +} + +@media screen and (min-width: 1024px) { + .signin-poster { + display: block; + } +} + +.signup-link { + color: var(--accent-800); + font-size: 14px; + text-decoration: underline; +} \ No newline at end of file diff --git a/src/public/css/signUp.css b/src/public/css/signUp.css new file mode 100644 index 0000000000000000000000000000000000000000..c13558166cf80593adfb3a075e2a3534b2b682a6 --- /dev/null +++ b/src/public/css/signUp.css @@ -0,0 +1,105 @@ +.signup-container { + width: 100%; + height: 100%; + background: white; + justify-content: flex-start; + align-items: center; + display: inline-flex; + flex-grow: 1; +} + +.signup-poster { + display: none; + width: 50%; + height: 100vh; + object-fit: cover; + max-width: 50vw; +} + +.right-side { + display: flex; + flex-direction: column; + gap: 1rem; + align-items: center; + justify-content: center; + width: 100%; +} + +.main-container { + max-width: 500px; + width: 90%; + padding: 24px; + background: white; + border-radius: 6px; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 40px; + display: flex; +} + +.welcome-text { + flex-direction: column; + justify-content: center; + align-items: center; + gap: 1rem; + display: flex; +} + +.welcome-text__h2 { + font-weight: bold; + font-size: 1.5rem; +} + +.welcome-text__h1 { + text-align: center; +} + +.inputs { + flex-direction: column; + display: flex; + gap: 1rem; + width: 100%; +} + +.parameter { + height: 62px; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + gap: 6px; + display: flex; +} + +.parameter-title { + color: #0f172a; + font-size: 14px; + font-weight: 500; + line-height: 20px; + word-wrap: break-word; +} + + + +form p:has(a) { + text-align: center; +} + + +@media screen and (min-width: 640px) { + .welcome-text__h2 { + font-size: 2rem; + } +} + +@media screen and (min-width: 1024px) { + .signup-poster { + display: block; + } +} + +.signin-link { + color: var(--accent-800); + font-size: 14px; + text-decoration: underline; +} \ No newline at end of file diff --git a/src/public/css/watchlist-detail.css b/src/public/css/watchlist-detail.css new file mode 100644 index 0000000000000000000000000000000000000000..99a0a6d9c05c8d8c0d069540fb03f87a8672922f --- /dev/null +++ b/src/public/css/watchlist-detail.css @@ -0,0 +1,96 @@ +article.header { + display: flex; + justify-content: space-between; + flex-direction: column; + align-items: flex-end; + gap: 32px; + align-self: stretch; +} + +article.header .detail { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 10px; + width: 100%; +} + +article.header .container-subtitle { + display: flex; + align-items: center; + gap: 4px; +} + +article.header .container-button { + width: 100%; + display: flex; + align-items: flex-start; + justify-content: center; +} + +.container-btn-love { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: small; +} + +button#show-more { + width: 100%; +} + + +form { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 24px; + align-self: stretch; +} + +.watchlist-detail__button-container { + position: fixed; + bottom: 20px; + right: 20px; + display: flex; + flex-direction: column; + gap: 10px; + width: fit-content; +} + +.watchlist-detail__button-container .btn-icon { + background: #FFF; + border: 1px solid rgba(0, 0, 0, .1); + cursor: pointer; + padding: 10px 10px; + border-radius: 6px; +} + +.watchlist-detail__button-container .btn-icon:hover { + background-color: var(--accent-300); + transition: all; + transition-duration: 400ms; + transition-timing-function: ease-in-out; +} + +@media screen and (min-width: 640px) { + article.header { + flex-direction: row; + align-items: flex-start; + } + + .watchlist-detail__button-container { + bottom: 40px; + right: 3rem; + gap: 10px; + } + + article.header .container-button { + justify-content: flex-end; + } + + article.header .detail { + width: 80%; + } +} diff --git a/src/public/css/watchlist-self.css b/src/public/css/watchlist-self.css new file mode 100644 index 0000000000000000000000000000000000000000..af6f1adb8da7936320d32e62a08d1f15d81782a0 --- /dev/null +++ b/src/public/css/watchlist-self.css @@ -0,0 +1,30 @@ +.search-filter { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 2rem; + align-self: stretch; +} + +.search-filter>div { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.search-filter .visibility { + display: flex; + align-items: center; + gap: 4px; +} + +.visibility .selected { + background-color: var(--accent-300); +} + +.no-item__container a { + text-decoration: underline; + color: var(--accent-800); +} \ No newline at end of file diff --git a/src/public/css/watchlistCreate.css b/src/public/css/watchlistCreate.css new file mode 100644 index 0000000000000000000000000000000000000000..054e8ff15aecad03f8f36fbd7617f166a781f242 --- /dev/null +++ b/src/public/css/watchlistCreate.css @@ -0,0 +1,109 @@ +.container__create-watchlist { + display: flex; + flex-direction: column; + gap: 4rem; + align-items: flex-start; + margin-bottom: 8rem; +} + +.container__form { + width: 100%; + flex-grow: 1; +} + +.form__create-watchlist { + width: 100%; +} + +.form-watchlist-actions { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.tags { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.2rem; + margin: 0.2rem 0; +} + +.checkbox { + max-width: fit-content; +} + +.input-tag { + display: flex; + flex-direction: row-reverse; + align-items: center; + justify-content: start; + gap: 1rem; +} + +.watchlist-items__title { + margin: 1rem 0 0 0; +} + +.watchlist-items { + display: flex; + flex-direction: column; +} + +.actions { + position: fixed; + bottom: 0; + left: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1rem; + width: 100%; + background-color: white; + padding: 2rem 3rem; +} + +.btn__add-item { + min-width: 18rem; +} + +.btn__save { + min-width: 18rem; + width: 100%; +} + +.form-search { + display: flex; +} + +.search { + display: flex; + align-items: center; + width: 100%; + gap: 0.4rem; +} + +.search__input { + flex-grow: 1; +} + +@media screen and (min-width: 784px) { + .tags { + grid-template-columns: repeat(5, minmax(0, 1fr)); + } +} + + +@media screen and (min-width: 1024px) { + .container__create-watchlist { + flex-direction: row; + } + + .actions { + position: sticky; + top: 8rem; + left: auto; + bottom: auto; + max-width: 20rem; + } +} \ No newline at end of file diff --git a/src/public/index.php b/src/public/index.php new file mode 100644 index 0000000000000000000000000000000000000000..d0ffb63eca3753f23fd104a03e749b6ab4e861a9 --- /dev/null +++ b/src/public/index.php @@ -0,0 +1,8 @@ +<?php + +// Initial config +define('ROOT_PATH', '/var/www'); +require_once ROOT_PATH . '/config/bootstrap.php'; + +// Routing +require_once ROOT_PATH . '/routes/view.php'; diff --git a/src/public/js/catalog/createUpdate.js b/src/public/js/catalog/createUpdate.js new file mode 100644 index 0000000000000000000000000000000000000000..a9986031bc7d281aea32489c966bcb7491205dcc --- /dev/null +++ b/src/public/js/catalog/createUpdate.js @@ -0,0 +1,185 @@ +const CATEGORY = ["ANIME", "DRAMA"]; + +function validateInput(formData, update = false) { + if (!formData.get("title") || formData.get("title").trim() === "") { + return { + valid: false, + message: "Title is required.", + }; + } + if (formData.get("title").length > 40) { + return { + valid: false, + message: "Title is too long. Maximum 40 chars.", + }; + } + if (formData.get("description") && formData.get("description").length > 255) { + return { + valid: false, + message: "Description is too long. Maximum 255 chars.", + }; + } + if ( + !formData.get("category") || + !CATEGORY.includes(formData.get("category").trim()) + ) { + return { + valid: false, + message: "Category is invalid.", + }; + } + + if (!update && !formData.get("poster").name) { + return { + valid: false, + message: "Poster is required.", + }; + } + return { + valid: true, + }; +} + +function createCatalog(form) { + const apiUrl = `/api/catalog/create`; + const formData = new FormData(form); + + const validate = validateInput(formData); + + if (!validate.valid) { + showToast("Error", validate.message, "error"); + return; + } + + const xhttp = new XMLHttpRequest(); + xhttp.open("POST", apiUrl, true); + + xhttp.onreadystatechange = function () { + if (xhttp.readyState === 4) { + const response = JSON.parse(xhttp.responseText); + if (xhttp.status === 200 && response.status === 200) { + showToast( + "Success", + `Catalog ${response.data.title} created`, + "success" + ); + setTimeout(() => { + window.location.href = `/catalog/${response.data.uuid}`; + }, [1000]); + } else { + try { + showToast("Error", response.message); + } catch (e) { + showToast("Error", "Something went wrong", "error"); + } + } + } + }; + + xhttp.send(formData); +} + +function updateCatalog(form) { + const urlParts = window.location.pathname.split("/"); + const uuidIndex = urlParts.indexOf("catalog") + 1; + const uuid = urlParts[uuidIndex]; + const apiUrl = `/api/catalog/${uuid}/update`; + const formData = new FormData(form); + + const validate = validateInput(formData, true); + + if (!validate.valid) { + showToast("Error", validate.message, "error"); + return; + } + + const xhttp = new XMLHttpRequest(); + xhttp.open("POST", apiUrl, true); + + xhttp.onreadystatechange = function () { + if (xhttp.readyState === 4) { + const response = JSON.parse(xhttp.responseText); + if (xhttp.status === 200 && response.status === 200) { + showToast("Success", "Catalog updated", "success"); + setTimeout(() => { + window.location.href = `/catalog/${uuid}`; + }, [1000]); + } else { + try { + showToast("Error", response.message); + } catch (e) { + showToast("Error", "Something went wrong", "error"); + } + } + } + }; + + xhttp.send(formData); +} + +const form = document.querySelector("form#catalog-create-update"); + +form.addEventListener("submit", function (event) { + event.preventDefault(); + + const currentUrl = window.location.href; + const url = new URL(currentUrl); + const urlPathname = url.pathname.split("/"); + const action = urlPathname[urlPathname.length - 1].toLowerCase(); + + if (action === "create") { + dialog( + "Create Catalog", + `Are you sure you want to create this catalog?`, + "create", + "create", + "Confirm", + () => { + createCatalog(form); + } + ); + } else if (action === "edit") { + dialog( + "Update Catalog", + `Are you sure you want to update this catalog?`, + "update", + "update", + "Confirm", + () => { + updateCatalog(form); + } + ); + } +}); + +posterImg = document.querySelector("img#poster"); +if (posterImg) { + posterInput = document.querySelector("input#posterField"); + posterInput.addEventListener("change", function (event) { + if (this.files[0]) { + var reader = new FileReader(); + + reader.readAsDataURL(this.files[0]); + + reader.onloadend = function () { + posterImg.src = reader.result; + }; + } + }); +} + +trailerVideo = document.querySelector("video#trailer"); +if (trailerVideo) { + trailerInput = document.querySelector("input#trailerField"); + trailerInput.addEventListener("change", function (event) { + if (this.files[0]) { + var reader = new FileReader(); + + reader.readAsDataURL(this.files[0]); + + reader.onloadend = function () { + trailerVideo.src = reader.result; + }; + } + }); +} diff --git a/src/public/js/catalog/delete.js b/src/public/js/catalog/delete.js new file mode 100644 index 0000000000000000000000000000000000000000..de2c8056536e030b53137e4ef05326d313a0b305 --- /dev/null +++ b/src/public/js/catalog/delete.js @@ -0,0 +1,47 @@ +function deleteCatalog(uuid, title) { + const xhttp = new XMLHttpRequest(); + xhttp.open("DELETE", `/api/catalog/${uuid}/delete`, true); + xhttp.setRequestHeader("Content-Type", "application/json"); + + xhttp.onreadystatechange = function () { + if (xhttp.readyState === 4) { + if (xhttp.status === 200) { + showToast("Success", `Catalog ${title} deleted`, "success"); + setTimeout(() => { + window.location.href = "/catalog"; + }, 1000); + } else { + try { + const response = JSON.parse(xhttp.responseText); + showToast("Error", response.message, "error"); + } catch (error) { + showToast("Error", "Something went wrong", "error"); + } + } + } + }; + + xhttp.send(); +} + +const deleteTriggerButtons = document.querySelectorAll( + `.catalog-delete-trigger` +); +deleteTriggerButtons.forEach((deleteTriggerButton) => { + if (deleteTriggerButton) { + deleteTriggerButton.addEventListener("click", () => { + const uuid = deleteTriggerButton.getAttribute("data-uuid"); + const title = deleteTriggerButton.getAttribute("data-title"); + dialog( + "Delete Catalog", + `Are you sure you want to delete ${title}?`, + uuid, + "delete", + "Delete", + () => { + deleteCatalog(uuid, title); + } + ); + }); + } +}); diff --git a/src/public/js/components/alert.js b/src/public/js/components/alert.js new file mode 100644 index 0000000000000000000000000000000000000000..91d641977e416965c3b159cff93f88548ab9d0ec --- /dev/null +++ b/src/public/js/components/alert.js @@ -0,0 +1,7 @@ +btnClose = document.querySelector(".alert button"); + +if (btnClose) { + btnClose.addEventListener("click", () => { + document.querySelector(".alert").remove(); + }); +} diff --git a/src/public/js/components/modal.js b/src/public/js/components/modal.js new file mode 100644 index 0000000000000000000000000000000000000000..f262d3a36c627534f29c253d42a8742b666a3860 --- /dev/null +++ b/src/public/js/components/modal.js @@ -0,0 +1,31 @@ +function modal() { + const modals = document.querySelectorAll('.modal'); + const modalTriggers = document.querySelectorAll('.modal__trigger'); + const containerDefault = document.querySelector('main'); + const modalClose = document.querySelector('.modal__close'); + + modals.forEach(modal => { + const modalTrigger = modal.querySelector('.modal__trigger'); + const modalContentWrapper = modal.querySelector('#modal__content'); + const modalClose = modal.querySelector('.modal__close'); + + modalTrigger.addEventListener('click', function () { + if (modalContentWrapper.style.display === 'flex') { + modalContentWrapper.classList.style.display = 'none' + } else { + modalContentWrapper.style.display = 'flex'; + document.body.style.overflow = 'hidden'; + containerDefault.style.zIndex = '1000'; + } + }) + + modalClose.addEventListener('click', function () { + modalContentWrapper.style.display = 'none'; + document.body.style.overflow = 'unset'; + containerDefault.style.zIndex = '10'; + }) + }) +} + +modal(); + diff --git a/src/public/js/components/modal/watchlistAddItem.js b/src/public/js/components/modal/watchlistAddItem.js new file mode 100644 index 0000000000000000000000000000000000000000..739714a72cea223c4bd925c53c7b425ed32e48b4 --- /dev/null +++ b/src/public/js/components/modal/watchlistAddItem.js @@ -0,0 +1,222 @@ +const PAGE_SIZE = 4; +const MAX_ITEMS = 50; +const PLUS_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus"><path d="M5 12h14" /><path d="M12 5v14" /></svg>` + +const inputSearch = document.querySelector('.search__input'); +const btnGetData = document.querySelector('.get__data'); +const searchItems = document.querySelector('.search__items'); + +let watchlistItemContainer = document.querySelector('.watchlist-items'); +let catalogSelected = []; +let page = 1; +let isLoading = false; +let endOfList = true; + +function deleteItemAction(id) { + const item = document.querySelector(`div[data-id="${id}"]`); + if (item) { + catalogSelected = catalogSelected.filter((e) => { + return e !== id + }); + const btnAddToList = searchItems.querySelector(`button.search-item__action[data-id="${id}"]`); + if (btnAddToList) { + btnAddToList.innerHTML = PLUS_ICON; + } + item.remove(); + + if (catalogSelected.length === 0) { + const itemsPlaceholder = document.createElement("p"); + itemsPlaceholder.classList.add("items-placeholder"); + itemsPlaceholder.textContent = "No items selected."; + watchlistItemContainer.appendChild(itemsPlaceholder); + } + } +} + +function deleteItem() { + const btnWatchlistItemDeletes = document.querySelectorAll('.watchlist-item__delete'); + btnWatchlistItemDeletes.forEach(btnWatchlistItemDelete => { + btnWatchlistItemDelete.addEventListener('click', () => { + deleteItemAction(btnWatchlistItemDelete.dataset.id); + }) + }) +} + +function drag() { + const watchlistItems = document.querySelectorAll('.watchlist-item') + + watchlistItems.forEach(watchlistItem => { + watchlistItem.addEventListener('dragstart', () => { + setTimeout(() => watchlistItem.classList.add('dragging'), 0); + }); + watchlistItem.addEventListener('dragend', () => watchlistItem.classList.remove("dragging")); + }); +} + +function sortableItems(e) { + e.preventDefault(); + const afterElement = getDragAfterElement(watchlistItemContainer, e.clientY); + const draggable = document.querySelector('.dragging'); + if (afterElement === null) { + watchlistItemContainer.appendChild(draggable); + } else { + watchlistItemContainer.insertBefore(draggable, afterElement); + } +} + +function getDragAfterElement(container, y) { + const draggableElements = [...container.querySelectorAll('.watchlist-item:not(.dragging)')]; + + return draggableElements.reduce((closest, child) => { + const box = child.getBoundingClientRect(); + const offset = y - box.top - box.height / 2; + if (offset < 0 && offset > closest.offset) { + return {offset: offset, element: child} + } + return closest; + }, {offset: Number.NEGATIVE_INFINITY}).element +} + +function createLoading() { + const loading = document.createElement("div"); + loading.classList.add('loading'); + loading.innerHTML = 'Loading...'; + searchItems.appendChild(loading); +} + +function fetchSearch(replace = false) { + if (replace) page = 1; + + const xhttp = new XMLHttpRequest(); + xhttp.onreadystatechange = function () { + if (this.readyState === 4) { + isLoading = false; + const loading = document.querySelector('.loading'); + if (loading) { + loading.remove(); + if (this.response === "") { + endOfList = true; + } + } + replace ? searchItems.innerHTML = this.response : searchItems.innerHTML += this.response; + + if (this.response === "" && searchItems.innerHTML === "") { + const notFound = document.createElement("div"); + notFound.classList.add("loading"); + notFound.innerHTML = "No Results Found."; + searchItems.appendChild(notFound); + } + + if (this.response) page++; + const btnAddToList = searchItems.querySelectorAll('.search-item__action'); + btnAddToList.forEach(e => { + if (catalogSelected.includes(e.dataset.id)) { + e.innerHTML = '✔ï¸'; + } + + e.addEventListener('click', () => { + if (!catalogSelected.includes(e.dataset.id)) { + if (catalogSelected.length >= MAX_ITEMS) { + showToast("Failed", "You can add up to 50 items"); + setTimeout(() => { + const toast = document.querySelector("#toast"); + toast.classList.add("hidden"); + }, 2000); + } else { + e.innerHTML = '✔ï¸'; + catalogSelected.push(e.dataset.id); + const xhttp = new XMLHttpRequest(); + xhttp.onreadystatechange = function () { + if (xhttp.readyState === 4) { + const itemsPlaceholder = document.querySelector("p.items-placeholder"); + if (itemsPlaceholder) { + itemsPlaceholder.remove(); + } + const wrapper = document.createElement('div'); + wrapper.classList.add('watchlist-item'); + wrapper.draggable = "true"; + wrapper.dataset.id = e.dataset.id; + wrapper.innerHTML = xhttp.response; + watchlistItemContainer.appendChild(wrapper); + deleteItem(); + drag(); + } + } + xhttp.open("GET", "/api/watchlist/item?id=" + e.dataset.id, true); + xhttp.send(); + } + } else { + deleteItemAction(e.dataset.id); + } + }) + }); + } else if (!endOfList) { + const loading = document.querySelector('.loading'); + if (loading == null) { + isLoading = true; + createLoading(); + } + } + } + xhttp.open("GET", `/api/catalog?title=${inputSearch.value.trim()}&page=${page}&pageSize=${PAGE_SIZE}`, true); + xhttp.send(); +} + +function getCatalogSelected() { + catalogSelected = []; + const btnDelete = document.querySelectorAll("button.watchlist-item__delete"); + + btnDelete.forEach(btn => { + catalogSelected.push(btn.dataset.id); + }) +} + +let search = () => { + if (inputSearch.value.trim() !== "") { + endOfList = false; + searchItems.innerHTML = ""; + fetchSearch(true); + } +} + +const debounce = (fn, delay) => { + let timer; + return function () { + clearTimeout(timer); + timer = setTimeout(() => { + fn(); + }, delay); + } +} + +search = debounce(search, 1000); + +inputSearch.addEventListener('keydown', function (e) { + if (e.keyCode === 13) { + e.preventDefault(); + } +}) + +inputSearch.addEventListener('input', search); + +searchItems.addEventListener('scroll', () => { + if (searchItems.offsetHeight + searchItems.scrollTop - searchItems.clientHeight + 100 > 0 && !endOfList && !isLoading) { + fetchSearch(); + } +}) +searchItems.addEventListener('touchmove', () => { + if (searchItems.offsetHeight + searchItems.scrollTop - searchItems.clientHeight + 100 > 0 && !endOfList && !isLoading) { + fetchSearch(); + } +}) + +watchlistItemContainer.addEventListener("dragover", sortableItems); +btnAddItem.addEventListener('click', () => { + endOfList = false; + getCatalogSelected(); + fetchSearch(true); +}) + +drag(); +getCatalogSelected(); +deleteItem(); \ No newline at end of file diff --git a/src/public/js/components/navbar.js b/src/public/js/components/navbar.js new file mode 100644 index 0000000000000000000000000000000000000000..c46ff5597fde538d9f9952ca44a4e3092bca7684 --- /dev/null +++ b/src/public/js/components/navbar.js @@ -0,0 +1,60 @@ +navbarToggle = document.getElementById("navbar-toggle"); +if (navbarToggle) { + navbarToggle.addEventListener("click", function () { + navbarMenu = document.getElementById("navbar-menu"); + if (navbarMenu.classList.contains("collapsed")) { + navbarMenu.classList.remove("collapsed"); + navbarToggle.focus(); + this.setAttribute("aria-expanded", "true"); + } else { + navbarMenu.classList.add("collapsed"); + navbarToggle.blur(); + this.setAttribute("aria-expanded", "false"); + } + }); + + navbarToggle.addEventListener("blur", function (e) { + if ( + e.relatedTarget && + (e.relatedTarget.parentElement.id === "navbar-menu" || + e.relatedTarget.parentElement.id === "profile-menu") + ) { + return; + } else { + navbarMenu = document.getElementById("navbar-menu"); + navbarMenu.classList.add("collapsed"); + navbarToggle.blur(); + this.setAttribute("aria-expanded", "false"); + } + }); +} + +profileMenuToggle = document.getElementById("profile-menu-toggle"); +if (profileMenuToggle) { + profileMenuToggle.addEventListener("click", function () { + profileMenu = document.getElementById("profile-menu"); + if (profileMenu.classList.contains("collapsed")) { + profileMenu.classList.remove("collapsed"); + profileMenuToggle.focus(); + this.setAttribute("aria-expanded", "true"); + } else { + profileMenu.classList.add("collapsed"); + profileMenuToggle.blur(); + this.setAttribute("aria-expanded", "false"); + } + }); + + profileMenuToggle.addEventListener("blur", function (e) { + if ( + e.relatedTarget && + e.relatedTarget.parentElement.id === "profile-menu" + ) { + return; + } else { + profileMenu = document.getElementById("profile-menu"); + profileMenu.classList.add("collapsed"); + profileMenuToggle.blur(); + this.setAttribute("aria-expanded", "false"); + } + }); +} diff --git a/src/public/js/components/select.js b/src/public/js/components/select.js new file mode 100644 index 0000000000000000000000000000000000000000..3c0b54377ac9384d2801598581690415acea832a --- /dev/null +++ b/src/public/js/components/select.js @@ -0,0 +1,17 @@ +const selects = document.querySelectorAll('.c-select-menu'); + +selects.forEach(select => { + const selectBtn = select.querySelector('.c-select-btn-text'); + select.addEventListener('click', () => { + options = select.querySelector('.c-select-options'); + options.classList.toggle('c-select-hide'); + option = options.querySelectorAll('.c-select-option'); + input = select.querySelector('#' + select.dataset.id); + option.forEach(optn => { + optn.addEventListener('click', (event) => { + selectBtn.textContent = event.currentTarget.innerText; + input.value = event.currentTarget.innerText.trim(); + }) + }) + }) +}); \ No newline at end of file diff --git a/src/public/js/components/watchlist/watchlistItem.js b/src/public/js/components/watchlist/watchlistItem.js new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/public/js/global.js b/src/public/js/global.js new file mode 100644 index 0000000000000000000000000000000000000000..bce2ff47c26a85586d54d343f0d746f8b9a0f71d --- /dev/null +++ b/src/public/js/global.js @@ -0,0 +1,182 @@ +function showToast(title, message, type = "error") { + const oldToast = document.querySelector("#toast"); + if (oldToast) { + oldToast.remove(); + } + const toast = document.createElement("div"); + toast.id = "toast"; + toast.classList.add("toast"); + toast.setAttribute("data-type", type); + toast.innerHTML = ` + <div> + <h3> + ${title} + </h3> + <p> + ${message} + </p> +</div> + <button id="close" class="btn-ghost"> + <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none"> + <path d="M1 13L7.00001 7.00002M7.00001 7.00002L13 1M7.00001 7.00002L1 1M7.00001 7.00002L13 13" stroke="black" + stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /> + </svg> + </button> + `; + const body = document.querySelector("body"); + body.appendChild(toast); + const toastClose = toast.querySelector("button"); + toastClose.addEventListener("click", () => { + toast.remove(); + }); +} + +function dialog( + title, + message, + dialogId, + actionId, + actionButtonText, + onaction +) { + const body = document.querySelector("body"); + + const dialog = document.createElement("div"); + dialog.classList.add("dialog"); + dialog.id = `dialog-${dialogId}`; + dialog.innerHTML = ` + <div class="dialog__content"> + <h2> + ${title} + </h2> + <p> + ${message} + </p> + <div class="dialog__button-container"> + <button id="cancel"> + Cancel + </button> + <button id=${actionId} class="btn-bold"> + ${actionButtonText} + </button> + </div> + </div> + `; + + body.appendChild(dialog); + + const cancelButton = dialog.querySelector("#cancel"); + cancelButton.addEventListener("click", () => { + dialog.remove(); + }); + + const actionButton = dialog.querySelector(`#${actionId}`); + actionButton.addEventListener("click", () => { + dialog.remove(); + onaction(); + }); +} + +// Lik and Save Watchlist + +function like() { + const btnLikes = document.querySelectorAll(".btn__like"); + btnLikes.forEach((btn) => { + if (btn.dataset.id) { + let liked = btn.dataset.liked; + btn.addEventListener("click", () => { + const iconLike = btn.querySelector(".icon-heart"); + const likeCountEl = document.querySelector( + `span[data-id='${btn.dataset.id}']` + ); + if (!liked) { + likeCountEl.innerHTML = `${parseInt(likeCountEl.textContent) + 1}`; + iconLike.dataset.type = "filled"; + } else { + likeCountEl.innerHTML = `${parseInt(likeCountEl.textContent) - 1}`; + iconLike.dataset.type = "unfilled"; + } + liked = !liked; + + let data = { + watchlistUUID: btn.dataset.id, + }; + data = JSON.stringify(data); + + const xhttp = new XMLHttpRequest(); + xhttp.open("POST", "/api/watchlist/like", true); + xhttp.setRequestHeader("Content-Type", "application/json"); + + xhttp.onreadystatechange = function () { + if (this.readyState === 4) { + } + }; + + xhttp.send(data); + }); + } + }); +} + +function save() { + const btnSaves = document.querySelectorAll(".btn__save"); + btnSaves.forEach((btn) => { + if (btn.dataset.id) { + let saved = btn.dataset.saved; + btn.addEventListener("click", () => { + const iconSaved = btn.querySelector(".icon-bookmark"); + if (!saved) { + iconSaved.dataset.type = "filled"; + } else { + iconSaved.dataset.type = "unfilled"; + } + saved = !saved; + + let data = { + watchlistUUID: btn.dataset.id, + }; + data = JSON.stringify(data); + + const xhttp = new XMLHttpRequest(); + xhttp.open("POST", "/api/watchlist/save", true); + xhttp.setRequestHeader("Content-Type", "application/json"); + + xhttp.onreadystatechange = function () { + if (this.readyState === 4) { + } + }; + + xhttp.send(data); + }); + } + }); +} + +logoutBtn = document.querySelector("button#logout"); +if (logoutBtn) { + logoutBtn.addEventListener("click", () => { + const xhttp = new XMLHttpRequest(); + xhttp.open("POST", "/api/auth/logout", true); + xhttp.setRequestHeader("Content-Type", "application/json"); + + xhttp.onreadystatechange = function () { + if (this.readyState === 4) { + if (xhttp.status === 200) { + showToast("Success", "Logout success", "success"); + setTimeout(() => { + window.location.href = `/`; + }, [1000]); + } else { + try { + const response = JSON.parse(xhttp.responseText); + showToast("Error", response.message); + } catch (e) { + showToast("Error", "Something went wrong", "error"); + } + } + } + }; + + xhttp.send(); + }); +} diff --git a/src/public/js/home.js b/src/public/js/home.js new file mode 100644 index 0000000000000000000000000000000000000000..a0a4c89ee4aa6fa833e21cf6d746b2e3a08b15d2 --- /dev/null +++ b/src/public/js/home.js @@ -0,0 +1,75 @@ +const btnSort = document.querySelector(".btn-sort"); +const sortAsc = document.querySelector(".btn-sort-asc"); +const sortDesc = document.querySelector(".btn-sort-desc"); +const images = document.querySelectorAll(".poster"); +const order = document.querySelector("#order"); +const btnApply = document.querySelector("#btn-apply"); +const watchlists = document.querySelector(".list__watchlist"); +const inputSearch = document.querySelector(".input-search"); + +if (btnSort) { + btnSort.addEventListener("click", () => { + sortAsc.classList.toggle("hidden"); + sortDesc.classList.toggle("hidden"); + order.value = order.value == "asc" ? "desc" : "asc"; + }); +} + +const debounce = (fn, delay) => { + let timer; + return function () { + clearTimeout(timer); + timer = setTimeout(() => { + fn(); + }, delay); + } +} + +const fetchWatchlist = (url) => { + const loading = document.createElement("div"); + loading.classList.add('loading'); + loading.innerHTML = 'Loading...'; + watchlists.innerHTML = "" + watchlists.appendChild(loading); + + const xhttp = new XMLHttpRequest(); + xhttp.onreadystatechange = function () { + if (this.readyState === 4) { + watchlists.innerHTML = ""; + if (!this.response) { + const noRes = document.createElement("div"); + noRes.classList.add("loading"); + noRes.innerHTML = "No Results Found."; + watchlists.appendChild(noRes); + } else { + watchlists.innerHTML = this.response; + like(); + save(); + modal(); + } + } + } + xhttp.open("GET", url, true); + xhttp.send(); +} + +let search = () => { + if (inputSearch.value.trim() !== "") { + const urlString = window.location.href; + const url = new URL(urlString); + url.pathname = '/api/watchlists'; + + const urlParams = new URLSearchParams(window.location.search); + urlParams.set("search", inputSearch.value.trim()); + urlParams.delete("page"); + + fetchWatchlist(`${url.origin}${url.pathname}?${urlParams.toString()}`); + } +} + +search = debounce(search, 500); + +inputSearch.addEventListener("keyup", search); + +like(); +save(); \ No newline at end of file diff --git a/src/public/js/profile.js b/src/public/js/profile.js new file mode 100644 index 0000000000000000000000000000000000000000..f1c3ebf7a57729d5e34ffcd01a336e912a7c224f --- /dev/null +++ b/src/public/js/profile.js @@ -0,0 +1,89 @@ +function deleteAccount() { + const xhttp = new XMLHttpRequest(); + xhttp.open("DELETE", `/api/auth/delete`, true); + xhttp.setRequestHeader("Content-Type", "application/json"); + + xhttp.onreadystatechange = function () { + if (xhttp.readyState === 4) { + if (xhttp.status === 200) { + showToast("Success", `User deleted`, "success"); + setTimeout(() => { + window.location.reload(); + }, 1000); + } else { + try { + const response = JSON.parse(xhttp.responseText); + showToast("Error", response.message, "error"); + } catch (error) { + showToast("Error", "Something went wrong", "error"); + } + } + } + }; + + xhttp.send(); +} + +const deleteTriggerButton = document.getElementById("delete-account"); +if (deleteTriggerButton) { + deleteTriggerButton.addEventListener("click", () => { + dialog( + "Delete Account", + `Are you sure you want to delete your account?`, + "delete-account", + "delete", + "Delete", + () => { + deleteAccount(); + } + ); + }); +} + +function updateAccount(form) { + const xhttp = new XMLHttpRequest(); + xhttp.open("PUT", `/api/auth/update`, true); + xhttp.setRequestHeader("Content-Type", "application/json"); + + xhttp.onreadystatechange = function () { + if (xhttp.readyState === 4) { + const response = JSON.parse(xhttp.responseText); + if (xhttp.status === 200 && response.status === 200) { + showToast("Success", `Profile updated`, "success"); + nameText = document.getElementById("name"); + nameText.innerText = response.name; + } else { + try { + const response = JSON.parse(xhttp.responseText); + showToast("Error", response.message, "error"); + } catch (error) { + showToast("Error", "Something went wrong", "error"); + } + } + } + }; + + xhttp.send( + JSON.stringify({ + name: form.name.value, + oldPassword: form.oldPassword.value, + newPassword: form.newPassword.value, + }) + ); +} + +const form = document.getElementById("profile-edit-form"); +form.addEventListener("submit", function (event) { + event.preventDefault(); + + dialog( + "Update Account", + `Are you sure you want to update your account?`, + "update", + "update", + "Confirm", + () => { + updateAccount(form); + } + ); +}); diff --git a/src/public/js/profile/bookmark.js b/src/public/js/profile/bookmark.js new file mode 100644 index 0000000000000000000000000000000000000000..cce092b5bcd048e8dfe1c8bb3be08b2e665f9fdd --- /dev/null +++ b/src/public/js/profile/bookmark.js @@ -0,0 +1,2 @@ +like(); +save(); diff --git a/src/public/js/profile/watchlist.js b/src/public/js/profile/watchlist.js new file mode 100644 index 0000000000000000000000000000000000000000..d8733fcc8190c1d625dd9c94cd829549498e900f --- /dev/null +++ b/src/public/js/profile/watchlist.js @@ -0,0 +1 @@ +like(); diff --git a/src/public/js/watchlist/createUpdate.js b/src/public/js/watchlist/createUpdate.js new file mode 100644 index 0000000000000000000000000000000000000000..1f848c0e167a00d31c6ba296f95115f843c74bf8 --- /dev/null +++ b/src/public/js/watchlist/createUpdate.js @@ -0,0 +1,171 @@ +const formUpdateWatchlist = document.querySelector("form#update-watchlist"); +const VISIBILITY = ["PUBLIC", "PRIVATE"]; + +function validateWatchlistCreateUpdateRequest(request) { + if (!request.title || request.title.trim() === "") { + return { + valid: false, + message: "Title is required." + }; + } + if (request.title.length > 40) { + return { + valid: false, + message: "Title is too long. Maximum 40 chars." + } + } + if (request.description && request.description.length > 255) { + return { + valid: false, + message: "Description is too long. Maximum 255 chars." + } + } + if (!request.visibility || !VISIBILITY.includes(request.visibility.trim())) { + return { + valid: false, + message: "Visibility is invalid." + } + } + if (!request.items || request.items.length === 0) { + return { + valid: false, + message: "Watchlist must contain 1 item." + } + } + if (request.items.length > 50) { + return { + valid: false, + message: "Too many items. Maximum 50 items." + } + } + + let maxDescExceeded = false; + let title; + for (let i = 0; i < request.items.length; i++) { + if (request.items[i].description.length > 255) { + title = request.items[i].title; + maxDescExceeded = true; + break; + } + } + if (maxDescExceeded) { + return { + valid: false, + message: `Description is too long for item ${title}. Maximum 255 chars.` + } + } + return { + valid: true + } +} + +function createEditWatchlist() { + // Parse url + const currentUrl = window.location.href; + const url = new URL(currentUrl); + const urlPathname = url.pathname.split("/"); + const action = urlPathname[urlPathname.length - 1]; + + const titleEl = document.querySelector("input#title"); + const descriptionEl = document.querySelector("textarea#description"); + const visibilityEl = document.querySelector("input#visibility"); + const itemsEl = document.querySelectorAll("div.watchlist-item"); + const tagsEl = document.querySelectorAll("input.watchlist-tag"); + + const title = titleEl.value; + const description = descriptionEl.value; + const visibility = visibilityEl.value; + const items = []; + const tags = []; + + itemsEl.forEach(item => { + const itemDescEl = item.querySelector("textarea.watchlist-item__description"); + const titleEl = item.querySelector(".watchlist-item__title"); + + const catalogTitle = titleEl.textContent; + const catalogId = itemDescEl.name.split("__")[0].split("[")[1]; + const catalogUUID = itemDescEl.name.split("__")[1]; + const catalogCategory = itemDescEl.name.split("__")[2].split("]")[0]; + const description = itemDescEl.value; + + items.push({ + "id": catalogId, + "uuid": catalogUUID, + "category": catalogCategory, + "title": catalogTitle, + description + }) + }) + + tagsEl.forEach(item => { + if (item.checked) { + tags.push({ + id: item.value + }) + } + }) + + // validate request + let data = { + title, + description, + visibility, + items, + tags + } + + let validationResult = validateWatchlistCreateUpdateRequest(data); + if (!validationResult.valid) { + showToast("Invalid Request", validationResult.message); + return; + } + + // ajax request + if (action === "edit") { + data["watchlistUUID"] = url.pathname.split("/")[2]; + } + + data = JSON.stringify(data); + + const xhttp = new XMLHttpRequest(); + + xhttp.open(action === "create" ? "POST" : "PUT", "/api/watchlist", true); + xhttp.setRequestHeader("Content-Type", "application/json"); + + xhttp.onreadystatechange = function () { + if (xhttp.readyState === 4) { + const response = JSON.parse(xhttp.response); + if (xhttp.status !== 200) { + showToast("Invalid Request", response.message) + } else { + showToast("Success", response.message, "success"); + setTimeout(() => { + if (response.redirectTo !== null && response.redirectTo !== undefined) { + window.location.href = response.redirectTo; + window.history.pushState({}, "", response.redirectTo); + window.location.reload(); + } + }, 1000) + } + } + } + + xhttp.send(data); +} + +formUpdateWatchlist.addEventListener("submit", e => { + e.preventDefault(); + + dialog( + "Update Watchlist", + `Are you sure you want to update this watchlist?`, + "update", + "update", + "Confirm", + () => { + createEditWatchlist(); + } + ); + + +}) \ No newline at end of file diff --git a/src/public/js/watchlist/delete.js b/src/public/js/watchlist/delete.js new file mode 100644 index 0000000000000000000000000000000000000000..45483abab2f6d96e42d034b88ad1dda6d10f7f7c --- /dev/null +++ b/src/public/js/watchlist/delete.js @@ -0,0 +1,49 @@ +const btnDelete = document.querySelector("button.btn__delete"); + +function deleteWatchlist() { + let data = { + watchlistUUID: btnDelete.dataset.id, + } + data = JSON.stringify(data); + + const xhttp = new XMLHttpRequest(); + + xhttp.open("DELETE", "/api/watchlist", true); + xhttp.setRequestHeader("Content-Type", "application/json"); + + xhttp.onreadystatechange = function () { + if (xhttp.readyState === 4) { + const response = JSON.parse(xhttp.response); + if (xhttp.status !== 200) { + showToast("Failed to Delete Watchlist", response.message); + } else { + showToast("Success", response.message, "success"); + setTimeout(() => { + if (response.redirectTo !== null && response.redirectTo !== undefined) { + window.location.href = response.redirectTo; + window.history.pushState({}, "", response.redirectTo); + window.location.reload(); + } + }, 1000) + } + } + } + + xhttp.send(data); +} + +if (btnDelete) { + btnDelete.addEventListener("click", () => { + dialog( + "Delete Watchlist", + `Are you sure you want to delete this watchlist?`, + "delete", + "delete", + "Delete", + () => { + deleteWatchlist(); + } + ); + + }) +} diff --git a/src/public/js/watchlist/detail.js b/src/public/js/watchlist/detail.js new file mode 100644 index 0000000000000000000000000000000000000000..71a4d25cd6fa850104a300ddf893d2938534ec3d --- /dev/null +++ b/src/public/js/watchlist/detail.js @@ -0,0 +1,2 @@ +like(); +save(); \ No newline at end of file diff --git a/src/public/js/watchlistCreate.js b/src/public/js/watchlistCreate.js new file mode 100644 index 0000000000000000000000000000000000000000..b60b7e10d24ab3aeb51cad78cf687bf8391ad84c --- /dev/null +++ b/src/public/js/watchlistCreate.js @@ -0,0 +1,16 @@ +const btnAddItem = document.querySelector('.btn__add-item'); + +// function addItem () { +// const xhttp = new XMLHttpRequest(); +// xhttp.onreadystatechange = function () { +// if (this.readyState === 4) { +// const div = document.createElement('div'); +// div.innerHTML = this.response; +// watchlistItems.appendChild(div); +// } +// } +// xhttp.open("POST", "/cc/watchlist-item", true); +// xhttp.send(); +// } + +// btnAddItem.addEventListener('click', addItem); \ No newline at end of file diff --git a/src/seed/seed.sql b/src/seed/seed.sql new file mode 100644 index 0000000000000000000000000000000000000000..8b62583090dd7f402747b70f44c583abb704cafd --- /dev/null +++ b/src/seed/seed.sql @@ -0,0 +1,19 @@ +INSERT INTO catalogs (uuid, title, description, poster, trailer, category) +SELECT + md5(random()::text || clock_timestamp()::text)::uuid, + 'Anime Title ' || num, + 'Anime Description ' || num, + '5a5ac4ad0c3a5e7c.webp', + 'a3e992b0d939a896.mp4', + 'ANIME' +FROM generate_series(1, 100) AS num; + +INSERT INTO catalogs (uuid, title, description, poster, trailer, category) +SELECT + md5(random()::text || clock_timestamp()::text)::uuid, + 'Drama Title ' || num, + 'Drama Description ' || num, + '5a6b36907cd0f469.webp', + 'a3e992b0d939a896.mp4', + 'DRAMA' +FROM generate_series(1, 100) AS num; diff --git a/src/server/.env.example b/src/server/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..e22355013b0cb5006a33161d86fb2a915497536c --- /dev/null +++ b/src/server/.env.example @@ -0,0 +1,5 @@ +DB_NAME= +DB_HOST=db +DB_PORT=5432 +DB_USER= +DB_PASSWORD= \ No newline at end of file diff --git a/src/server/app/App/Domain.php b/src/server/app/App/Domain.php new file mode 100644 index 0000000000000000000000000000000000000000..29ef6d0998deb38a8620eef658c1170c8eb6ecc3 --- /dev/null +++ b/src/server/app/App/Domain.php @@ -0,0 +1,14 @@ +<?php + +abstract class Domain +{ + public int|string $id; + public string $table; + public function __construct() + { + $this->table = get_class($this) . 's'; + } + + abstract public function toArray(): array; + abstract public function fromArray(array $data); +} \ No newline at end of file diff --git a/src/server/app/App/Middleware.php b/src/server/app/App/Middleware.php new file mode 100644 index 0000000000000000000000000000000000000000..10de9271143365a07e9f49692132943854cb5675 --- /dev/null +++ b/src/server/app/App/Middleware.php @@ -0,0 +1,6 @@ +<?php + +interface Middleware +{ + function run(): void; +} diff --git a/src/server/app/App/Repository.php b/src/server/app/App/Repository.php new file mode 100644 index 0000000000000000000000000000000000000000..039c7d7383d4da539a43f191aa43e84ab5fdbedd --- /dev/null +++ b/src/server/app/App/Repository.php @@ -0,0 +1,227 @@ +<?php +require_once __DIR__ . '/../Utils/QueryBuilder.php'; + +/** + * ABC for Repository + * + * Provides basic CRUD operations + */ +abstract class Repository +{ + protected \PDO $connection; + protected string $table; + protected QueryBuilder $queryBuilder; + protected string $currentQuery = ""; + + public function __construct(\PDO $connection) + { + $this->connection = $connection; + $this->queryBuilder = new QueryBuilder($this); + } + + public function query() + { + $this->queryBuilder->query = ""; + return $this->queryBuilder; + } + + public function getTable() + { + return $this->table; + } + + protected function reset() + { + $this->queryBuilder->query = ""; + $this->currentQuery = ""; + } + + public function save(Domain $domain) + { + $array = $domain->toArray(); + $domainKeyLength = count($array); + $this->currentQuery = "INSERT INTO {$this->table} ("; + + $countKey = 0; + foreach ($array as $key => $value) { + if ($key != 'id' || $this->table == "sessions") { + $this->currentQuery .= "$key"; + } + + if ($countKey < $domainKeyLength - 1) { + $this->currentQuery .= ", "; + } + + $countKey += 1; + } + + $this->currentQuery .= ") VALUES ("; + $countKey = 0; + foreach ($array as $key => $value) { + if ($key != 'id' || $this->table == "sessions") { + $this->currentQuery .= ":$key"; + } + + if ($countKey < $domainKeyLength - 1) { + $this->currentQuery .= ", "; + } + + $countKey += 1; + } + + $this->currentQuery .= ")"; + $statement = $this->connection->prepare($this->currentQuery); + foreach ($array as $key => $value) { + if ($key != 'id' || $this->table == "sessions") { + $statement->bindValue(":$key", $value); + } + } + + $statement->execute(); + + $this->reset(); + + try { + if ($this->table != "sessions") { + $domain->id = $this->connection->lastInsertId(); + } + return $domain; + } finally { + $statement->closeCursor(); + } + } + + public function findAll(array $projection = [], int|null $page = null, int|null $pageSize = null): array + { + $selectQuery = "SELECT "; + $pageCountQuery = "SELECT COUNT(*) "; + + if (count($projection) === 0) { + $selectQuery .= "*"; + } else { + $countProjection = 0; + foreach ($projection as $column) { + $selectQuery .= "$column AS " . str_replace(".", "_", $column); + if ($countProjection < count($projection) - 1) { + $selectQuery .= ", "; + } + $countProjection += 1; + } + } + + $this->currentQuery .= " FROM {$this->table}"; + + $this->currentQuery .= $this->queryBuilder->query; + + $pageCountStatement = $this->connection->prepare($pageCountQuery . $this->currentQuery); + $pageCountStatement->execute(); + + if ($pageSize) { + $this->currentQuery .= " LIMIT $pageSize"; + } + + if ($page) { + $offset = ($page - 1) * $pageSize; + $this->currentQuery .= " OFFSET $offset"; + } + + $selectStatement = $this->connection->prepare($selectQuery . $this->currentQuery); + $selectStatement->execute(); + + $this->reset(); + + try { + return [ + 'items' => $selectStatement->fetchAll(), + 'page' => $page ?? 1, + 'totalPage' => $pageSize ? ceil($pageCountStatement->fetchColumn() / $pageSize) : 1 + ]; + } finally { + $selectStatement->closeCursor(); + $pageCountStatement->closeCursor(); + } + } + + public function findOne($key, $value, $projection = []) + { + $this->currentQuery = "SELECT "; + + if (count($projection) === 0) { + $this->currentQuery .= "*"; + } else { + $countProjection = 0; + foreach ($projection as $column) { + $this->currentQuery .= "$column"; + if ($countProjection < count($projection) - 1) { + $this->currentQuery .= ", "; + } + $countProjection += 1; + } + } + + $this->currentQuery .= " FROM {$this->table} WHERE $key = :$key LIMIT 1"; + + $statement = $this->connection->prepare($this->currentQuery); + $statement->bindValue(":$key", $value); + + $statement->execute(); + + $this->reset(); + + try { + if ($row = $statement->fetch()) { + return $row; + } + return null; + } finally { + $statement->closeCursor(); + } + } + + public function update(Domain $domain) + { + $domainKeyLength = count($domain->toArray()); + $this->currentQuery = "UPDATE {$this->table} SET "; + $countKey = 0; + foreach ($domain->toArray() as $key => $value) { + $countKey += 1; + + $this->currentQuery .= "$key = :$key"; + if ($countKey < $domainKeyLength) { + $this->currentQuery .= ", "; + } + } + + $this->currentQuery .= " WHERE id = :id"; + + $statement = $this->connection->prepare($this->currentQuery); + $array = $domain->toArray(); + foreach ($array as $key => $value) { + $statement->bindValue(":$key", $value); + } + $statement->bindValue(":id", $array['id'], \PDO::PARAM_INT); + $statement->execute(); + $this->reset(); + try { + return $domain; + } finally { + $statement->closeCursor(); + } + } + + public function deleteAll(): void + { + $this->connection->exec("DELETE FROM {$this->table}"); + $this->reset(); + } + + public function deleteBy($key, $value): void + { + $statement = $this->connection->prepare("DELETE FROM {$this->table} WHERE $key = :$key"); + $statement->bindValue(":$key", $value); + $statement->execute(); + $this->reset(); + + $statement->closeCursor(); + } +} \ No newline at end of file diff --git a/src/server/app/App/Router.php b/src/server/app/App/Router.php new file mode 100644 index 0000000000000000000000000000000000000000..577789df93664c5eea5e95df9b6dbb7719e57f57 --- /dev/null +++ b/src/server/app/App/Router.php @@ -0,0 +1,52 @@ +<?php + +require_once __DIR__ . '/View.php'; + +class Router +{ + private static array $routes = []; + + public static function add(string $method, string $path, string $controller, string $function, array $middlewares = []): void + { + self::$routes[] = [ + 'method' => $method, + 'path' => $path, + 'controller' => $controller, + 'function' => $function, + 'middlewares' => $middlewares, + ]; + } + + public static function run(): void + { + $path = '/'; + if (isset($_SERVER['PATH_INFO'])) { + $path = $_SERVER['PATH_INFO']; + } + + $method = $_SERVER['REQUEST_METHOD']; + + foreach (self::$routes as $route) { + $pattern = "#^" . $route['path'] . "$#"; + if (preg_match($pattern, $path, $variables) && $method == $route['method']) { + // call middlewares + foreach ($route['middlewares'] as $middleware) { + $instance = new $middleware; + $instance->run(); + } + + $function = $route['function']; + $controller = new $route['controller']; + + array_shift($variables); + call_user_func_array([$controller, $function], $variables); + + return; + } + } + + http_response_code(404); + + View::redirect("/404"); + } +} diff --git a/src/server/app/App/View.php b/src/server/app/App/View.php new file mode 100644 index 0000000000000000000000000000000000000000..41c386e2540f66ed0cf3fde5b5f43084d4efc38e --- /dev/null +++ b/src/server/app/App/View.php @@ -0,0 +1,24 @@ +<?php + +require_once __DIR__ . '/../Service/SessionService.php'; + +class View +{ + public static function render(string $view, $model = [], SessionService $sessionService = null) + { + $user = $sessionService ? $sessionService->current() : null; + + require __DIR__ . '/../View/components/header.php'; + if (!isset($_SERVER['PATH_INFO']) || ($_SERVER['PATH_INFO'] != '/signin' && $_SERVER['PATH_INFO'] != '/signup')) { + require __DIR__ . '/../View/components/navbar.php'; + } + require __DIR__ . '/../View/' . $view . '.php'; + require __DIR__ . '/../View/components/footer.php'; + } + + public static function redirect(string $url) + { + header("Location: $url"); + exit(); + } +} \ No newline at end of file diff --git a/src/server/app/Config/Database.php b/src/server/app/Config/Database.php new file mode 100644 index 0000000000000000000000000000000000000000..d069393730a3a2e1f346cef091f6b8d2a008773d --- /dev/null +++ b/src/server/app/Config/Database.php @@ -0,0 +1,36 @@ +<?php + +class Database +{ + private static ?\PDO $pdo = null; + + public static function getConnection(string $env = 'dev'): \PDO + { + if (self::$pdo == null) { + // create new POD + require_once __DIR__ . '/../../config/database.php'; + $config = getDatabaseConfig(); + self::$pdo = new \PDO( + $config['database'][$env]['url'], + $config['database'][$env]['username'], + $config['database'][$env]['password'] + ); + } + return self::$pdo; + } + + public static function beginTransaction() + { + self::$pdo->beginTransaction(); + } + + public static function commitTransaction() + { + self::$pdo->commit(); + } + + public static function rollbackTransaction() + { + self::$pdo->rollback(); + } +} \ No newline at end of file diff --git a/src/server/app/Controller/BookmarkController.php b/src/server/app/Controller/BookmarkController.php new file mode 100644 index 0000000000000000000000000000000000000000..9b2c93f0bb57ae1b6c92386ef6377bdeb5659787 --- /dev/null +++ b/src/server/app/Controller/BookmarkController.php @@ -0,0 +1,75 @@ +<?php +require_once __DIR__ . '/../Config/Database.php'; +require_once __DIR__ . '/../App/View.php'; +require_once __DIR__ . '/../Exception/ValidationException.php'; + +require_once __DIR__ . '/../Repository/WatchlistSaveRepository.php'; + +require_once __DIR__ . '/../Service/BookmarkService.php'; +require_once __DIR__ . '/../Service/SessionService.php'; + +require_once __DIR__ . '/../Model/bookmark/BookmarkGetRequest.php'; + +class BookmarkController +{ + private BookmarkService $bookmarkService; + private SessionService $sessionService; + + public function __construct() + { + $connection = Database::getConnection(); + $watchlistSaveRepository = new WatchlistSaveRepository($connection); + $this->bookmarkService = new BookmarkService($watchlistSaveRepository); + + $sessionRepository = new SessionRepository($connection); + $userRepository = new UserRepository($connection); + $this->sessionService = new SessionService($sessionRepository, $userRepository); + } + + public function self() + { + $page = $_GET['page'] ?? 1; + $pageSize = $_GET['pageSize'] ?? 10; + + $user = $this->sessionService->current(); + $request = new BookmarkGetRequest(); + $request->userId = $user ? $user->id : null; + $request->page = $page; + $request->pageSize = $pageSize; + + $result = $this->bookmarkService->findByUser($request); + + function posterCompare($element1, $element2) + { + return $element1["rank"] - $element2["rank"]; + } + + $bookmarks = []; + + foreach ($result["items"] as $item) { + $posters = json_decode($item["posters"], true); + $tags = json_decode($item["tags"], true); + $tags = array_filter($tags, function ($value) { + return $value["id"] !== null; + }); + usort($posters, "posterCompare"); + $item["posters"] = $posters; + $item["tags"] = $tags; + + array_push($bookmarks, $item); + } + + $result["items"] = $bookmarks; + + View::render('profile/bookmark', [ + 'title' => 'Bookmark', + 'js' => [ + '/js/profile/bookmark.js' + ], + 'data' => [ + 'bookmarks' => $result, + 'userUUID' => $user ? $user->uuid : null, + ], + ], $this->sessionService); + } +} \ No newline at end of file diff --git a/src/server/app/Controller/CatalogController.php b/src/server/app/Controller/CatalogController.php new file mode 100644 index 0000000000000000000000000000000000000000..fca0b5899ec27f13fd25175a97ecb220540b3bbd --- /dev/null +++ b/src/server/app/Controller/CatalogController.php @@ -0,0 +1,275 @@ +<?php + +require_once __DIR__ . '/../App/View.php'; +require_once __DIR__ . '/../Config/Database.php'; +require_once __DIR__ . '/../Exception/ValidationException.php'; + +require_once __DIR__ . '/../Service/CatalogService.php'; +require_once __DIR__ . '/../Service/SessionService.php'; + +require_once __DIR__ . '/../Repository/CatalogRepository.php'; +require_once __DIR__ . '/../Repository/UserRepository.php'; +require_once __DIR__ . '/../Repository/SessionRepository.php'; + +require_once __DIR__ . '/../Model/CatalogCreateRequest.php'; +require_once __DIR__ . '/../Model/catalog/CatalogUpdateRequest.php'; +require_once __DIR__ . '/../Model/CatalogSearchRequest.php'; + +class CatalogController +{ + private CatalogService $catalogService; + private SessionService $sessionService; + + public function __construct() + { + $connection = Database::getConnection(); + + $catalogRepository = new CatalogRepository($connection); + $this->catalogService = new CatalogService($catalogRepository); + + $sessionRepository = new SessionRepository($connection); + $userRepository = new UserRepository($connection); + $this->sessionService = new SessionService($sessionRepository, $userRepository); + } + + public function index(): void + { + $page = $_GET['page'] ?? 1; + $category = $_GET['category'] ?? "MIXED"; + + $user = $this->sessionService->current(); + + View::render('catalog/index', [ + 'title' => 'Catalog', + 'styles' => [ + '/css/catalog.css', + ], + 'js' => [ + '/js/catalog/delete.js' + ], + 'data' => [ + 'catalogs' => $this->catalogService->findAll($page, $category), + 'category' => strtoupper(trim($category)), + 'userRole' => $user ? $user->role : null + ] + ], $this->sessionService); + } + + public function create(): void + { + View::render('catalog/form', [ + 'title' => 'Add Catalog', + 'styles' => [ + '/css/catalog-form.css', + ], + 'js' => [ + '/js/catalog/createUpdate.js' + ], + 'type' => 'create' + ], $this->sessionService); + } + + public function edit($uuid): void + { + $catalog = $this->catalogService->findByUUID($uuid); + + if (!$catalog) { + View::redirect('/404'); + } + + View::render('catalog/form', [ + 'title' => 'Edit Catalog', + 'styles' => [ + '/css/catalog-form.css', + ], + 'js' => [ + '/js/catalog/createUpdate.js' + ], + 'type' => 'edit', + 'data' => $catalog->toArray() + ], $this->sessionService); + } + + public function detail($uuid): void + { + $catalog = $this->catalogService->findByUUID($uuid); + + + if (!$catalog) { + View::redirect('/404'); + } + + $user = $this->sessionService->current(); + View::render('catalog/detail', [ + 'title' => 'Catalog Detail', + 'styles' => [ + '/css/catalog-detail.css', + ], + 'js' => [ + '/js/catalog/delete.js' + ], + 'data' => [ + 'item' => $catalog->toArray(), + 'userRole' => $user ? $user->role : null + ] + ], $this->sessionService); + } + + public function postCreate(): void + { + $request = new CatalogCreateRequest(); + if (isset($_POST['category'])) { + $request->category = $_POST['category']; + } + + $request->title = $_POST['title']; + $request->description = $_POST['description']; + + if (isset($_FILES['poster'])) { + $request->poster = $_FILES['poster']; + } + + if (isset($_FILES['trailer'])) { + $request->trailer = $_FILES['trailer']; + } + + try { + $response = $this->catalogService->create($request); + http_response_code(200); + $response = [ + "status" => 200, + "message" => "Successfully created catalog", + "data" => [ + "uuid" => $response->catalog->uuid, + "title" => $response->catalog->title, + ] + ]; + + echo json_encode($response); + } catch (ValidationException $exception) { + http_response_code(400); + $response = [ + "status" => 400, + "message" => $exception->getMessage(), + ]; + + echo json_encode($response); + } catch (\Exception $exception) { + http_response_code(500); + $response = [ + "status" => 500, + "message" => "Something went wrong.", + ]; + + echo json_encode($response); + } + } + + public function search() + { + $request = new CatalogSearchRequest(); + $request->title = $_GET["title"]; + $request->page = $_GET["page"]; + $request->pageSize = $_GET["pageSize"]; + + $catalogs = $this->catalogService->search($request); + + foreach ($catalogs->catalogs['items'] as $item) { + $title = $item->title; + $poster = $item->poster; + $uuid = $item->uuid; + $description = $item->description; + $category = $item->category; + $page = $catalogs->catalogs['page']; + require __DIR__ . '/../View/components/modal/watchlistAddSearchItem.php'; + } + } + + public function update($uuid): void + { + $user = $this->sessionService->current(); + try { + if (!$user || $user->role !== 'ADMIN') { + throw new ValidationException("You are not authorized to update this catalog."); + } + + $request = new CatalogUpdateRequest(); + + $request->uuid = $uuid; + $request->title = $_POST['title']; + $request->description = $_POST['description']; + $request->category = $_POST['category']; + + if (isset($_FILES['poster'])) { + $request->poster = $_FILES['poster']; + } + + if (isset($_FILES['trailer'])) { + $request->trailer = $_FILES['trailer']; + } + + $this->catalogService->update($request); + http_response_code(200); + $response = [ + "status" => 200, + "message" => "Successfully update catalog", + ]; + + echo json_encode($response); + } catch (ValidationException $exception) { + http_response_code(400); + $response = [ + "status" => 400, + "message" => $exception->getMessage(), + ]; + + echo json_encode($response); + } catch (\Exception $exception) { + http_response_code(500); + $response = [ + "status" => 500, + "message" => "Something went wrong.", + ]; + + echo json_encode($response); + } + } + + public function delete(string $uuid) + { + $user = $this->sessionService->current(); + + try { + if ($user && $user->role === 'ADMIN') { + $this->catalogService->deleteByUUID($uuid); + http_response_code(200); + + $response = [ + "status" => 200, + "message" => "Successfully delete catalog", + ]; + + echo json_encode($response); + } else { + throw new ValidationException("You are not authorized to delete this catalog."); + } + } catch (ValidationException $exception) { + http_response_code(400); + + $response = [ + "status" => 400, + "message" => $exception->getMessage(), + ]; + + echo json_encode($response); + } catch (\Exception $exception) { + http_response_code(500); + $response = [ + "status" => 500, + "message" => "Something went wrong.", + ]; + + echo json_encode($response); + } + } +} \ No newline at end of file diff --git a/src/server/app/Controller/ErrorPageController.php b/src/server/app/Controller/ErrorPageController.php new file mode 100644 index 0000000000000000000000000000000000000000..139ea2f3a1c3d36ea67faf32f0a5c08c1c59d1f1 --- /dev/null +++ b/src/server/app/Controller/ErrorPageController.php @@ -0,0 +1,42 @@ +<?php +require_once __DIR__ . '/../App/View.php'; +require_once __DIR__ . '/../Config/Database.php'; + +require_once __DIR__ . '/../Service/SessionService.php'; + +require_once __DIR__ . '/../Repository/UserRepository.php'; +require_once __DIR__ . '/../Repository/SessionRepository.php'; + +class ErrorPageController +{ + private SessionService $sessionService; + + public function __construct() + { + $connection = Database::getConnection(); + + $sessionRepository = new SessionRepository($connection); + $userRepository = new UserRepository($connection); + $this->sessionService = new SessionService($sessionRepository, $userRepository); + } + + public function fourohfour(): void + { + View::render('404', [ + 'title' => '404', + 'styles' => [ + '/css/error-page.css', + ], + ], $this->sessionService); + } + + public function fivehundred(): void + { + View::render('500', [ + 'title' => '500', + 'styles' => [ + '/css/error-page.css', + ], + ], $this->sessionService); + } +} \ No newline at end of file diff --git a/src/server/app/Controller/HomeController.php b/src/server/app/Controller/HomeController.php new file mode 100644 index 0000000000000000000000000000000000000000..3cbc1151903c1805988c8665c318ba0e7a6ba279 --- /dev/null +++ b/src/server/app/Controller/HomeController.php @@ -0,0 +1,143 @@ +<?php +require_once __DIR__ . '/../App/View.php'; +require_once __DIR__ . '/../Config/Database.php'; + +require_once __DIR__ . '/../Service/SessionService.php'; +require_once __DIR__ . '/../Service/TagService.php'; + +require_once __DIR__ . '/../Repository/UserRepository.php'; +require_once __DIR__ . '/../Repository/SessionRepository.php'; +require_once __DIR__ . '/../Repository/WatchlistRepository.php'; +require_once __DIR__ . '/../Repository/WatchlistLikeRepository.php'; +require_once __DIR__ . '/../Repository/WatchlistSaveRepository.php'; +require_once __DIR__ . '/../Repository/WatchlistTagRepository.php'; + +require_once __DIR__ . '/../Model/WatchlistsGetRequest.php'; + +class HomeController +{ + private SessionService $sessionService; + private WatchlistService $watchlistService; + private TagService $tagService; + + public function __construct() + { + $connection = Database::getConnection(); + + $sessionRepository = new SessionRepository($connection); + $userRepository = new UserRepository($connection); + $this->sessionService = new SessionService($sessionRepository, $userRepository); + + $watchlistRepository = new WatchlistRepository($connection); + $watchlistItemRepository = new WatchlistItemRepository($connection); + $watchlistLikeRepository = new WatchlistLikeRepository($connection); + $watchlistSaveRepository = new WatchlistSaveRepository($connection); + $watchlistTagRepository = new WatchlistTagRepository($connection); + $this->watchlistService = new WatchlistService($watchlistRepository, $watchlistItemRepository, $watchlistLikeRepository, $watchlistSaveRepository, $watchlistTagRepository); + + $tagRepository = new TagRepository($connection); + $this->tagService = new TagService($tagRepository); + } + + public function index(): void + { + $data = $this->getWatchlist(); + + View::render('home/index', [ + 'title' => 'Homepage', + 'data' => $data, + 'styles' => [ + '/css/home.css', + ], + 'js' => [ + '/js/home.js', + ], + ], $this->sessionService); + } + + public function watchlists(): void + { + $data = $this->getWatchlist(); + + foreach ($data["items"] as $item) { + $uuid = $item["watchlist_uuid"]; + $posters = $item["posters"]; + $visibility = $item["visibility"]; + $title = $item["title"]; + $category = $item["category"]; + $creator = $item["creator"]; + $createdAt = $item["created_at"]; + $description = $item["description"]; + $itemCount = $item["item_count"]; + $loveCount = $item["love_count"]; + $loved = $item["loved"]; + $saved = $item["saved"]; + $self = ($data["userUUID"] == $item["creator_uuid"]); + $userUUID = $data["userUUID"]; + + require __DIR__ . '/../View/components/card/watchlistCard.php'; + } + + if (count($data["items"]) > 0) { + $currentPage = $data["page"]; + $totalPage = $data["pageTotal"]; + require __DIR__ . '/../View/components/pagination.php'; + } + } + + private function getWatchlist(): array + { + // Get current user + $user = $this->sessionService->current(); + + $tags = $this->tagService->findAll(); + $tagsInit = []; + + foreach ($tags["items"] as $tag) { + array_push($tagsInit, $tag->name); + } + + // Get watchlists + $request = new WatchlistsGetRequest(); + $request->category = $_GET["category"] ?? ""; + $request->tags = $_GET["tags"] ?? ""; + $request->sortBy = $_GET["sortBy"] ?? ""; + $request->order = $_GET["order"] ?? ""; + $request->page = $_GET["page"] ?? 1; + $request->tag = $_GET["tag"] ?? ""; + $request->tagsInit = $tagsInit; + $request->search = isset($_GET["search"]) ? strtolower($_GET["search"]) : ""; + $request->userId = $user->id ?? -1; + + + $result = $this->watchlistService->findAll($request); + + function posterCompare($element1, $element2) + { + return $element1["rank"] - $element2["rank"]; + } + + $data = [ + "items" => [], + "page" => $result["page"], + "pageTotal" => $result["pageTotal"], + "userUUID" => $user->uuid ?? "", + "tags" => $tagsInit + ]; + + foreach ($result["items"] as $item) { + $posters = json_decode($item["posters"], true); + $tags = json_decode($item["tags"], true); + $tags = array_filter($tags, function ($value) { + return $value["id"] !== null; + }); + usort($posters, "posterCompare"); + $item["posters"] = $posters; + $item["tags"] = $tags; + + array_push($data["items"], $item); + } + + return $data; + } +} diff --git a/src/server/app/Controller/UserController.php b/src/server/app/Controller/UserController.php new file mode 100644 index 0000000000000000000000000000000000000000..54a9f7361f2e000b3a8428d449690ca104ac3609 --- /dev/null +++ b/src/server/app/Controller/UserController.php @@ -0,0 +1,285 @@ +<?php + +require_once __DIR__ . '/../App/View.php'; +require_once __DIR__ . '/../Config/Database.php'; +require_once __DIR__ . '/../Exception/ValidationException.php'; + +require_once __DIR__ . '/../Repository/UserRepository.php'; +require_once __DIR__ . '/../Repository/SessionRepository.php'; + +require_once __DIR__ . '/../Service/UserService.php'; +require_once __DIR__ . '/../Service/SessionService.php'; + +require_once __DIR__ . '/../Model/UserSignUpRequest.php'; +require_once __DIR__ . '/../Model/UserSignInRequest.php'; +require_once __DIR__ . '/../Model/UserEditRequest.php'; +require_once __DIR__ . '/../Model/session/SessionCreateRequest.php'; + +class UserController +{ + private UserService $userService; + private SessionService $sessionService; + + public function __construct() + { + $connection = Database::getConnection(); + $userRepository = new UserRepository($connection); + $this->userService = new UserService($userRepository); + + $sessionRepository = new SessionRepository($connection); + $this->sessionService = new SessionService($sessionRepository, $userRepository); + } + + public function signUp(): void + { + View::render('user/signUp', [ + 'title' => 'Sign Up', + 'styles' => [ + '/css/signUp.css', + ], + ], $this->sessionService); + } + + public function signIn(): void + { + View::render('user/signIn', [ + "title" => "Sign In", + "styles" => [ + "/css/signIn.css", + ], + ], $this->sessionService); + } + + public function profile(): void + { + View::render('user/editProfile', [ + 'title' => 'Profile', + 'styles' => [ + '/css/editProfile.css', + ], + 'data' => [ + 'name' => 'Breezy', + 'email' => 'sampleemail@gmail.com' + ], + ], $this->sessionService); + } + + public function postSignUp(): void + { + $request = new UserSignUpRequest(); + + $request->email = $_POST['email']; + $request->password = $_POST['password']; + $request->confirmPassword = $_POST['passwordConfirm']; + + try { + $this->userService->signUp($request); + + View::redirect('/signin'); + } catch (ValidationException $exception) { + View::render('user/signUp', [ + 'title' => 'Sign Up', + 'error' => $exception->getMessage(), + 'styles' => [ + '/css/signUp.css', + ], + ], $this->sessionService); + } + } + + + public function postSignIn(): void + { + $request = new UserSignInRequest(); + $request->email = $_POST['email']; + $request->password = $_POST['password']; + + try { + $response = $this->userService->signIn($request); + + $sessionCreateRequest = new SessionCreateRequest(); + $sessionCreateRequest->userId = $response->user->id; + + $this->sessionService->create($sessionCreateRequest); + + View::redirect('/'); + } catch (ValidationException $exception) { + View::render('user/signIn', [ + "title" => "Sign In", + "data" => [ + "email" => $request->email, + ], + "error" => $exception->getMessage(), + "styles" => [ + "/css/signIn.css", + ], + ], $this->sessionService); + } + } + + + public function showEditProfile(): void + { + $currentUser = $this->sessionService->current(); + View::render('user/editProfile', [ + 'title' => 'Drawl | Edit Profile', + 'styles' => [ + '/css/editProfile.css', + ], + 'js' => [ + '/js/profile.js' + ], + 'data' => ['name' => $currentUser->name, 'email' => $currentUser->email] + ], $this->sessionService); + } + + public function logout(): void + { + try { + $this->sessionService->destroy(); + http_response_code(200); + $response = [ + "status" => 200, + "message" => "Logout success.", + ]; + } catch (ValidationException $exception) { + http_response_code(400); + $response = [ + "status" => 400, + "message" => $exception->getMessage(), + ]; + + echo json_encode($response); + } catch (\Exception $exception) { + http_response_code(500); + $response = [ + "status" => 500, + "message" => "Something went wrong.", + ]; + + echo json_encode($response); + } + } + + public function postEditProfile(): void + { + $request = new UserEditRequest(); + $request->name = $_POST['name']; + $request->email = $_POST["email"] ?? null; // or from session + $request->oldPassword = $_POST['oldPassword']; + $request->newPassword = $_POST['newPassword']; + + $currentUser = $this->sessionService->current(); + + try { + $this->userService->update($currentUser, $request); + View::redirect('/profile'); + } catch (ValidationException $exception) { + View::render('user/editProfile', [ + 'title' => 'Drawl | Edit Profile', + 'error' => $exception->getMessage(), + 'styles' => [ + '/css/editProfile.css', + ], + 'data' => [ + 'name' => $currentUser->name, + 'email' => $currentUser->email + ], + ], $this->sessionService); + } + } + + public function update(): void + { + $request = new UserEditRequest(); + + $json = file_get_contents('php://input'); + $data = json_decode($json); + + if ($data === null) { + http_response_code(400); + $response = [ + "status" => 400, + "message" => "Invalid request.", + ]; + + echo json_encode($response); + return; + } + + + $request->name = $data->name; + $request->oldPassword = $data->oldPassword; + $request->newPassword = $data->newPassword; + + $currentUser = $this->sessionService->current(); + + try { + $this->userService->update($currentUser, $request); + http_response_code(200); + $response = [ + "status" => 200, + "message" => "Successfully update user", + "name" => $request->name, + ]; + + echo json_encode($response); + } catch (ValidationException $exception) { + http_response_code($exception->getCode() ?? 400); + + $response = [ + "status" => $exception->getCode() ?? 400, + "message" => $exception->getMessage(), + ]; + + echo json_encode($response); + } catch (\Exception $exception) { + http_response_code(500); + $response = [ + "status" => 500, + "message" => "Something went wrong.", + ]; + + echo json_encode($response); + } + } + + public function delete(): void + { + $currentUser = $this->sessionService->current(); + + try { + + if (!$currentUser) { + throw new ValidationException("Unauthorized.", 401); + } + $this->userService->deleteBySession($currentUser->email); + $this->userService->deleteByEmail($currentUser->email); + http_response_code(200); + + $response = [ + "status" => 200, + "message" => "Successfully delete user", + ]; + + echo json_encode($response); + } catch (ValidationException $exception) { + http_response_code($exception->getCode() ?? 400); + + $response = [ + "status" => $exception->getCode() ?? 400, + "message" => $exception->getMessage(), + ]; + + echo json_encode($response); + } catch (\Exception $exception) { + http_response_code(500); + $response = [ + "status" => 500, + "message" => "Something went wrong.", + ]; + + echo json_encode($response); + } + } +} \ No newline at end of file diff --git a/src/server/app/Controller/WatchlistController.php b/src/server/app/Controller/WatchlistController.php new file mode 100644 index 0000000000000000000000000000000000000000..adcee4eb8996fc3c7b2640487c89f0c34642e4cc --- /dev/null +++ b/src/server/app/Controller/WatchlistController.php @@ -0,0 +1,443 @@ +<?php +require_once __DIR__ . '/../Config/Database.php'; +require_once __DIR__ . '/../App/View.php'; +require_once __DIR__ . '/../Exception/ValidationException.php'; + +require_once __DIR__ . '/../Repository/CatalogRepository.php'; +require_once __DIR__ . '/../Repository/WatchlistRepository.php'; +require_once __DIR__ . '/../Repository/WatchlistItemRepository.php'; +require_once __DIR__ . '/../Repository/WatchlistLikeRepository.php'; +require_once __DIR__ . '/../Repository/WatchlistSaveRepository.php'; +require_once __DIR__ . '/../Repository/TagRepository.php'; +require_once __DIR__ . '/../Repository/WatchlistTagRepository.php'; + +require_once __DIR__ . '/../Service/CatalogService.php'; +require_once __DIR__ . '/../Service/WatchlistService.php'; +require_once __DIR__ . '/../Service/SessionService.php'; +require_once __DIR__ . '/../Service/TagService.php'; + +require_once __DIR__ . '/../Model/CatalogCreateRequest.php'; +require_once __DIR__ . '/../Model/WatchlistAddItemRequest.php'; +require_once __DIR__ . '/../Model/WatchlistCreateRequest.php'; +require_once __DIR__ . '/../Model/watchlist/WatchlistGetOneByUserRequest.php'; +require_once __DIR__ . '/../Model/watchlist/WatchlistGetOneRequest.php'; +require_once __DIR__ . '/../Model/watchlist/WatchlistLikeRequest.php'; +require_once __DIR__ . '/../Model/watchlist/WatchlistSaveRequest.php'; +require_once __DIR__ . '/../Model/watchlist/WatchlistEditRequest.php'; +require_once __DIR__ . '/../Model/watchlist/WatchlistDeleteRequest.php'; + +class WatchlistController +{ + private CatalogService $catalogService; + private WatchlistService $watchlistService; + private SessionService $sessionService; + private TagService $tagService; + + public function __construct() + { + $connection = Database::getConnection(); + $catalogRepository = new CatalogRepository($connection); + $this->catalogService = new CatalogService($catalogRepository); + + $watchlistRepository = new WatchlistRepository($connection); + $watchlistItemRepository = new WatchlistItemRepository($connection); + $watchlistLikeRepository = new WatchlistLikeRepository($connection); + $watchlistSaveRepository = new WatchlistSaveRepository($connection); + $watchlistTagRepository = new WatchlistTagRepository($connection); + $this->watchlistService = new WatchlistService($watchlistRepository, $watchlistItemRepository, $watchlistLikeRepository, $watchlistSaveRepository, $watchlistTagRepository); + + $sessionRepository = new SessionRepository($connection); + $userRepository = new UserRepository($connection); + $this->sessionService = new SessionService($sessionRepository, $userRepository); + + $tagRepository = new TagRepository($connection); + $this->tagService = new TagService($tagRepository); + } + + public function create(): void + { + $tags = $this->tagService->findAll(); + + View::render('watchlist/createUpdate', [ + 'title' => 'Create Watchlist', + 'description' => 'Create new watchlist', + "data" => [ + "tags" => $tags["items"], + ], + 'styles' => [ + '/css/watchlistCreate.css', + '/css/components/watchlist/watchlistItem.css', + '/css/components/modal/watchlistAddItem.css', + '/css/components/modal/watchlistAddSearchItem.css', + ], + 'js' => [ + '/js/watchlistCreate.js', + '/js/watchlist/createUpdate.js', + '/js/components/modal/watchlistAddItem.js', + '/js/components/watchlist/watchlistItem.js', + ] + ], $this->sessionService); + } + + public function postCreate(): void + { + $user = $this->sessionService->current(); + + $dataRaw = file_get_contents("php://input"); + $data = json_decode($dataRaw, true); + + $tags = $this->tagService->findAll(); + + $request = new WatchlistCreateRequest(); + $request->title = $data["title"]; + $request->description = $data["description"]; + $request->visibility = $data["visibility"]; + $request->items = $data["items"]; + $request->tags = $data["tags"]; + $request->userId = $user->id; + $request->initialTags = $tags["items"]; + + try { + $this->watchlistService->create($request); + + $response = [ + "status" => 200, + "message" => "Watchlist successfully created", + "redirectTo" => "/profile/watchlist", + ]; + + print_r(json_encode($response)); + } catch (ValidationException $exception) { + http_response_code(500); + + $response = [ + "status" => 500, + "message" => $exception->getMessage() + ]; + + print_r(json_encode($response)); + } + } + + public function edit(string $uuid): void + { + $user = $this->sessionService->current(); + + $tags = $this->tagService->findAll(); + + $getRequest = new WatchlistsGetOneRequest(); + $getRequest->uuid = $uuid; + $getRequest->page = 1; + $getRequest->pageSize = 100; + + $watchlist = $this->watchlistService->findByUUID($getRequest); + + if ($watchlist == null || $user->uuid !== $watchlist["creator_uuid"]) { + View::redirect("/404"); + } + + View::render('watchlist/createUpdate', [ + 'title' => 'Edit Watchlist', + 'description' => 'Edit watchlist', + 'edit' => true, + 'data' => [ + "title" => $watchlist["title"], + "description" => $watchlist["description"], + "visibility" => $watchlist["visibility"], + "catalogs" => $watchlist["catalogs"], + "tagsSelected" => $watchlist["tags"], + "tags" => $tags["items"], + ], + 'styles' => [ + '/css/watchlistCreate.css', + '/css/components/watchlist/watchlistItem.css', + '/css/components/modal/watchlistAddItem.css', + '/css/components/modal/watchlistAddSearchItem.css', + ], + 'js' => [ + '/js/watchlistCreate.js', + '/js/watchlist/createUpdate.js', + '/js/components/modal/watchlistAddItem.js', + '/js/components/watchlist/watchlistItem.js', + ] + ], $this->sessionService); + } + + public function putEdit(): void + { + $user = $this->sessionService->current(); + + $dataRaw = file_get_contents("php://input"); + $data = json_decode($dataRaw, true); + + $getRequest = new WatchlistsGetOneRequest(); + $getRequest->uuid = $data["watchlistUUID"]; + $getRequest->page = 1; + $getRequest->pageSize = 100; + + $watchlist = $this->watchlistService->findByUUID($getRequest); + + if ($watchlist == null || $user->uuid !== $watchlist["creator_uuid"]) { + http_response_code(400); + + $response = [ + "status" => 400, + "message" => "Watchlist not found.", + ]; + + print_r(json_encode($response)); + } + + $tags = $this->tagService->findAll(); + + $request = new WatchlistEditRequest(); + $request->watchlist = $watchlist; + $request->userId = $user->id; + $request->title = $data["title"]; + $request->description = $data["description"]; + $request->visibility = $data["visibility"]; + $request->items = $data["items"]; + $request->tags = $data["tags"]; + $request->initialTags = $tags["items"]; + + try { + $this->watchlistService->edit($request); + + $response = [ + "status" => 200, + "message" => "Watchlist edited successfully", + "redirectTo" => "/watchlist/{$watchlist["watchlist_uuid"]}", + ]; + + print_r(json_encode($response)); + } catch (Exception $exception) { + http_response_code(500); + + $response = [ + "status" => 500, + "message" => "Internal server error. Please try again later." + ]; + + print_r(json_encode($response)); + } + } + + public function detail(string $uuid): void + { + $user = $this->sessionService->current(); + $request = new WatchlistsGetOneRequest(); + $request->uuid = $uuid; + $request->page = $_GET["page"] ?? 1; + $request->userId = $user ? $user->id : -1; + + $result = $this->watchlistService->findByUUID($request); + if ($result == null) { + View::redirect('/404'); + } + + View::render('watchlist/detail', [ + 'title' => 'Watchlist', + 'styles' => [ + '/css/watchlist-detail.css', + ], + 'js' => [ + '/js/watchlist/detail.js', + '/js/watchlist/delete.js', + ], + 'data' => [ + 'item' => $result, + 'userUUID' => $user ? $user->uuid : null + ] + ], $this->sessionService); + } + + public function item(): void + { + $request = new WatchlistAddItemRequest(); + $request->id = $_GET["id"]; + + $response = $this->catalogService->findByUUID($request->id); + if (isset($response)) { + $id = $response->id; + $title = $response->title; + $poster = $response->poster; + $uuid = $response->uuid; + $category = $response->category; + require __DIR__ . '/../View/components/watchlist/watchlistItem.php'; + } + } + + public function self() + { + $user = $this->sessionService->current(); + + $request = new WatchlistGetOneByUserRequest(); + $request->visibility = $_GET["visibility"] ?? ""; + $request->userId = $user->id; + + $result = $this->watchlistService->findByUser($request); + + function posterCompare($element1, $element2) + { + return $element1["rank"] - $element2["rank"]; + } + + $watchlists = []; + + foreach ($result["items"] as $item) { + $posters = json_decode($item["posters"], true); + $tags = json_decode($item["tags"], true); + $tags = array_filter($tags, function ($value) { + return $value["id"] !== null; + }); + usort($posters, "posterCompare"); + $item["posters"] = $posters; + $item["tags"] = $tags; + + array_push($watchlists, $item); + } + + $result["items"] = $watchlists; + + View::render('profile/watchlist', [ + 'title' => 'My Watchlist', + 'description' => 'My watchlist', + 'styles' => [ + '/css/watchlist-self.css', + ], + 'js' => [ + '/js/profile/watchlist.js', + ], + 'data' => [ + 'visibility' => strtolower($_GET['visibility'] ?? 'all'), + 'watchlists' => $result, + 'userUUID' => $user->uuid + ] + ], $this->sessionService); + } + + public function like(): void + { + $user = $this->sessionService->current(); + + if ($user == null) { + http_response_code(400); + $response = [ + "message" => "Please login before liking this watchlist.", + ]; + print_r(json_encode($response)); + return; + } + + $dataRaw = file_get_contents("php://input"); + $data = json_decode($dataRaw, true); + + $watchlistLikeRequest = new WatchlistLikeRequest(); + $watchlistLikeRequest->watchlistUUID = $data["watchlistUUID"] ?? ""; + $watchlistLikeRequest->userId = $user->id; + + try { + $this->watchlistService->like($watchlistLikeRequest); + http_response_code(200); + $response = [ + "status" => 200, + "message" => "Success", + ]; + + print_r(json_encode($response)); + } catch (ValidationException $exception) { + http_response_code(500); + + $response = [ + "status" => 500, + "message" => $exception->getMessage(), + ]; + + print_r(json_encode($response)); + } + + } + + public function bookmark(): void + { + $user = $this->sessionService->current(); + + $dataRaw = file_get_contents("php://input"); + $data = json_decode($dataRaw, true); + + $watchlistSaveRequest = new WatchlistSaveRequest(); + $watchlistSaveRequest->watchlistUUID = $data["watchlistUUID"] ?? ""; + $watchlistSaveRequest->userId = $user->id; + + try { + $this->watchlistService->bookmark($watchlistSaveRequest); + http_response_code(200); + + $response = [ + "status" => 200, + "message" => "Success", + ]; + + print_r(json_encode($response)); + } catch (ValidationException $exception) { + http_response_code(500); + + $response = [ + "status" => 500, + "message" => $exception->getMessage(), + ]; + + print_r(json_encode($response)); + } + } + + public function delete() + { + $user = $this->sessionService->current(); + + $dataRaw = file_get_contents("php://input"); + $data = json_decode($dataRaw, true); + + $getRequest = new WatchlistsGetOneRequest(); + $getRequest->uuid = $data["watchlistUUID"]; + $getRequest->page = 1; + $getRequest->pageSize = 100; + + $watchlist = $this->watchlistService->findByUUID($getRequest); + + if ($watchlist == null || $user->uuid !== $watchlist["creator_uuid"]) { + http_response_code(400); + + $response = [ + "status" => 400, + "message" => "Watchlist not found.", + ]; + + print_r(json_encode($response)); + } + + $request = new WatchlistDeleteRequest(); + $request->watchlistUUID = $data["watchlistUUID"]; + + try { + $this->watchlistService->deleteByUUID($request); + + $response = [ + "status" => 200, + "message" => "Watchlist deleted successfully", + "redirectTo" => "/profile/watchlist", + ]; + + print_r(json_encode($response)); + } catch (Exception $exception) { + http_response_code(500); + + $response = [ + "status" => 500, + "message" => "Failed to delete watchlist. " . $exception->getMessage(), + ]; + + print_r(json_encode($response)); + } + } +} \ No newline at end of file diff --git a/src/server/app/Domain/Catalog.php b/src/server/app/Domain/Catalog.php new file mode 100644 index 0000000000000000000000000000000000000000..29fee4dd252d8cc3c391f1dfc45f6b01d6001801 --- /dev/null +++ b/src/server/app/Domain/Catalog.php @@ -0,0 +1,86 @@ +<?php + +require_once __DIR__ . '/../App/Domain.php'; + +class Catalog extends Domain +{ + public int|string $id; + public string $uuid; + public string $title; + public ?string $description = null; + public string $poster; + public ?string $trailer = null; + public string $category; + + public string $createdAt; + public string $updatedAt; + + public function toArray(): array + { + $array = [ + 'uuid' => $this->uuid, + 'title' => $this->title, + 'description' => $this->description, + 'poster' => $this->poster, + 'trailer' => $this->trailer, + 'category' => $this->category + ]; + + if (isset($this->id)) { + $array['id'] = $this->id; + } + + if (isset($this->createdAt)) { + $array['created_at'] = $this->createdAt; + } + + if (isset($this->updatedAt)) { + $array['updated_at'] = $this->updatedAt; + } + + return $array; + } + + public function fromArray(array $data) + { + if (isset($data['id'])) { + $this->id = $data['id']; + } + + if (isset($data['uuid'])) { + $this->uuid = $data['uuid']; + } + + if (isset($data['title'])) { + $this->title = $data['title']; + } + + if (isset($data['description'])) { + $this->description = $data['description']; + } + + if (isset($data['poster'])) { + if (file_exists('assets/images/catalogs/posters/' . $data['poster'])) { + $this->poster = $data['poster']; + } else { + $this->poster = 'no-poster.webp'; + } + } + + if (isset($data['trailer'])) { + $this->trailer = $data['trailer']; + } + + if (isset($data['category'])) { + $this->category = $data['category']; + } + + if (isset($data['created_at'])) { + $this->createdAt = $data['created_at']; + } + + if (isset($data['updated_at'])) { + $this->updatedAt = $data['updated_at']; + } + } +} \ No newline at end of file diff --git a/src/server/app/Domain/Session.php b/src/server/app/Domain/Session.php new file mode 100644 index 0000000000000000000000000000000000000000..8120983b55a5fd92e51d558d680c24b29bad9ec9 --- /dev/null +++ b/src/server/app/Domain/Session.php @@ -0,0 +1,54 @@ +<?php + +require_once __DIR__ . '/../App/Domain.php'; + +class Session extends Domain +{ + public int|string $id; + public string $userId; + public string $expired; + public string $createdAt; + public string $updatedAt; + + public function toArray(): array + { + $array = [ + "id" => $this->id, + "user_id" => $this->userId, + "expired" => $this->expired + ]; + + if (isset($this->createdAt)) { + $array["created_at"] = $this->createdAt; + } + + if (isset($this->updatedAt)) { + $array["updated_at"] = $this->updatedAt; + } + + return $array; + } + + public function fromArray(array $data) + { + if (isset($data["id"])) { + $this->id = $data["id"]; + } + + if (isset($data["user_id"])) { + $this->userId = $data["user_id"]; + } + + if (isset($data["expired"])) { + $this->expired = $data["expired"]; + } + + if (isset($data["created_at"])) { + $this->createdAt = $data["created_at"]; + } + + if (isset($data["updated_at"])) { + $this->updatedAt = $data["updated_at"]; + } + } +} diff --git a/src/server/app/Domain/Tag.php b/src/server/app/Domain/Tag.php new file mode 100644 index 0000000000000000000000000000000000000000..f48a5e42741c754d928de95c2d5eb527aa9ac221 --- /dev/null +++ b/src/server/app/Domain/Tag.php @@ -0,0 +1,42 @@ +<?php + +require_once __DIR__ . '/../App/Domain.php'; + +class Tag extends Domain +{ + public int|string $id; + public string $name; + public string $createdAt; + + public function toArray(): array + { + $array = [ + "name" => $this->name, + ]; + + if (isset($this->id)) { + $array["id"] = $this->id; + } + + if (isset($this->createdAt)) { + $array["created_at"] = $this->createdAt; + } + + return $array; + } + + public function fromArray(array $data) + { + if (isset($data["id"])) { + $this->id = $data["id"]; + } + + if (isset($data["name"])) { + $this->name = $data["name"]; + } + + if (isset($data["created_at"])) { + $this->createdAt = $data["created_at"]; + } + } +} \ No newline at end of file diff --git a/src/server/app/Domain/User.php b/src/server/app/Domain/User.php new file mode 100644 index 0000000000000000000000000000000000000000..68ea518b8760ed50443c8a5e9fd6622918168662 --- /dev/null +++ b/src/server/app/Domain/User.php @@ -0,0 +1,78 @@ +<?php + +require_once __DIR__ . '/../App/Domain.php'; + +class User extends Domain +{ + public int|string $id; + public string $uuid; + public string $name; + public string $password; + public string $email; + public string $role; + public string $createdAt; + public string $updatedAt; + + public function toArray(): array + { + $array = [ + "uuid" => $this->uuid, + "name" => $this->name, + "password" => $this->password, + "email" => $this->email, + ]; + + if (isset($this->id)) { + $array["id"] = $this->id; + } + + if (isset($this->role)) { + $array["role"] = $this->role; + } + + if (isset($this->createdAt)) { + $array["created_at"] = $this->createdAt; + } + + if (isset($this->updatedAt)) { + $array["updated_at"] = $this->updatedAt; + } + + return $array; + } + + public function fromArray(array $data) + { + if (isset($data["id"])) { + $this->id = $data["id"]; + } + + if (isset($data["uuid"])) { + $this->uuid = $data["uuid"]; + } + + if (isset($data["name"])) { + $this->name = $data["name"]; + } + + if (isset($data["password"])) { + $this->password = $data["password"]; + } + + if (isset($data["email"])) { + $this->email = $data["email"]; + } + + if (isset($data["role"])) { + $this->role = $data["role"]; + } + + if (isset($data["created_at"])) { + $this->createdAt = $data["created_at"]; + } + + if (isset($data["updated_at"])) { + $this->updatedAt = $data["updated_at"]; + } + } +} \ No newline at end of file diff --git a/src/server/app/Domain/Watchlist.php b/src/server/app/Domain/Watchlist.php new file mode 100644 index 0000000000000000000000000000000000000000..1b8701c41de1d16e1ca1be921dbff60083613e68 --- /dev/null +++ b/src/server/app/Domain/Watchlist.php @@ -0,0 +1,117 @@ +<?php + +require_once __DIR__ . '/../App/Domain.php'; + +class Watchlist extends Domain +{ + public int|string $id; + public string $uuid; + public string $title; + public ?string $description; + public string $category; + public int $userId; + public int $itemCount; + public int $likeCount; + public string $visibility; + public string $createdAt; + public string $updatedAt; + public array $items; + public User $user; + + public function toArray(): array + { + $array = [ + "title" => $this->title, + "uuid" => $this->uuid, + "description" => $this->description, + "category" => $this->category, + "user_id" => $this->userId, + "visibility" => $this->visibility, + ]; + + if (isset($this->id)) { + $array["id"] = $this->id; + } + + if (isset($this->itemCount)) { + $array["item_count"] = $this->itemCount; + } + + if (isset($this->likeCount)) { + $array["like_count"] = $this->likeCount; + } + + if (isset($this->createdAt)) { + $array["created_at"] = $this->createdAt; + } + + if (isset($this->updatedAt)) { + $array["updated_at"] = $this->updatedAt; + } + + if (isset($this->items)) { + $array["items"] = $this->items; + } + + if (isset($this->user)) { + $array["user"] = $this->user; + } + + return $array; + } + + public function fromArray(array $data) + { + if (isset($data["id"]) || isset($data["watchlists_id"])) { + $this->id = $data["id"] ?? $data["watchlists_id"]; + } + + if (isset($data["uuid"]) || isset($data["watchlists_uuid"])) { + $this->uuid = $data["uuid"] ?? $data["watchlists_uuid"]; + } + + if (isset($data["title"]) || isset($data["watchlists_title"])) { + $this->title = $data["title"] ?? $data["watchlists_title"]; + } + + if (isset($data["description"])) { + $this->description = $data["description"]; + } + + if (isset($data["category"])) { + $this->category = $data["category"]; + } + + if (isset($data["user_id"])) { + $this->userId = $data["user_id"]; + } + + if (isset($data["item_count"])) { + $this->itemCount = $data["item_count"]; + } + + if (isset($data["like_count"])) { + $this->likeCount = $data["like_count"]; + } + + if (isset($data["visibility"])) { + $this->visibility = $data["visibility"]; + } + + if (isset($data["created_at"])) { + $this->createdAt = $data["created_at"]; + } + + if (isset($data["updated_at"])) { + $this->updatedAt = $data["updated_at"]; + } + + if (isset($data["items"])) { + $this->items = $data["items"]; + } + + if (isset($data["user"])) { + $this->user = $data["user"]; + } + } +} \ No newline at end of file diff --git a/src/server/app/Domain/WatchlistItem.php b/src/server/app/Domain/WatchlistItem.php new file mode 100644 index 0000000000000000000000000000000000000000..b2aaef304f661e0c3e744ae891fafe02e1cefa7e --- /dev/null +++ b/src/server/app/Domain/WatchlistItem.php @@ -0,0 +1,63 @@ +<?php + +require_once __DIR__ . '/../App/Domain.php'; + +class WatchlistItem extends Domain +{ + public int|string $id; + public string $uuid; + public int $rank; + public ?string $description; + public int $watchlistId; + public int $catalogId; + public string $createdAt; + public string $updatedAt; + + public function toArray(): array + { + $array = [ + 'uuid' => $this->uuid, + 'rank' => $this->rank, + 'description' => $this->description, + 'watchlist_id' => $this->watchlistId, + 'catalog_id' => $this->catalogId + ]; + + return $array; + } + + public function fromArray(array $data) + { + if (isset($data["id"])) { + $this->id = $data["id"]; + } + + if (isset($data["uuid"])) { + $this->uuid = $data["uuid"]; + } + + if (isset($data["rank"])) { + $this->rank = $data["rank"]; + } + + if (isset($data["description"])) { + $this->description = $data["description"]; + } + + if (isset($data["watchlist_id"])) { + $this->watchlistId = $data["watchlist_id"]; + } + + if (isset($data["catalog_id"])) { + $this->catalogId = $data["catalog_id"]; + } + + if (isset($data["created_at"])) { + $this->createdAt = $data["created_at"]; + } + + if (isset($data["updated_at"])) { + $this->updatedAt = $data["updated_at"]; + } + } +} \ No newline at end of file diff --git a/src/server/app/Domain/WatchlistLike.php b/src/server/app/Domain/WatchlistLike.php new file mode 100644 index 0000000000000000000000000000000000000000..19ed04f8eb83cbbd5480a70878e06419ab5b1d72 --- /dev/null +++ b/src/server/app/Domain/WatchlistLike.php @@ -0,0 +1,33 @@ +<?php + +class WatchlistLike extends Domain +{ + public int|string $watchlistId; + public int|string $userId; + + public function toArray(): array + { + $array = []; + + if (isset($this->watchlistId)) { + $array["watchlist_id"] = $this->watchlistId; + } + + if (isset($this->userId)) { + $array["user_id"] = $this->userId; + } + + return $array; + } + + public function fromArray(array $data) + { + if (isset($data["watchlist_id"])) { + $this->watchlistId = $data["watchlist_id"]; + } + + if (isset($data["user_id"])) { + $this->userId = $data["user_id"]; + } + } +} \ No newline at end of file diff --git a/src/server/app/Domain/WatchlistSave.php b/src/server/app/Domain/WatchlistSave.php new file mode 100644 index 0000000000000000000000000000000000000000..4a631e6217e71cd6a4159df6c2b74c71fa1f5933 --- /dev/null +++ b/src/server/app/Domain/WatchlistSave.php @@ -0,0 +1,39 @@ +<?php + +require_once __DIR__ . '/../App/Domain.php'; + +class WatchlistSave extends Domain +{ + public int|string $id; + public int $user_id; + public int $watchlist_id; + + public function toArray(): array + { + $array = [ + 'user_id' => $this->user_id, + 'watchlist_id' => $this->watchlist_id, + ]; + + if (isset($this->id)) { + $array['id'] = $this->id; + } + + return $array; + } + + public function fromArray(array $data) + { + if (isset($data["id"])) { + $this->id = $data["id"]; + } + + if (isset($data["user_id"])) { + $this->user_id = $data["user_id"]; + } + + if (isset($data["watchlist_id"])) { + $this->watchlist_id = $data["watchlist_id"]; + } + } +} \ No newline at end of file diff --git a/src/server/app/Domain/WatchlistTag.php b/src/server/app/Domain/WatchlistTag.php new file mode 100644 index 0000000000000000000000000000000000000000..989ddda1e3fb28227d46de65b9b08e55c67840f2 --- /dev/null +++ b/src/server/app/Domain/WatchlistTag.php @@ -0,0 +1,39 @@ +<?php + +require_once __DIR__ . '/../App/Domain.php'; + +class WatchlistTag extends Domain +{ + public int|string $id; + public int $tagId; + public int $watchlistId; + + public function toArray(): array + { + $array = [ + "tag_id" => $this->tagId, + "watchlist_id" => $this->watchlistId + ]; + + if (isset($this->id)) { + $array["id"] = $this->id; + } + + return $array; + } + + public function fromArray(array $data) + { + if (isset($data["id"])) { + $this->id = $data["id"]; + } + + if (isset($data["tag_id"])) { + $this->tagId = $data["tag_id"]; + } + + if (isset($data["watchlist_id"])) { + $this->watchlistId = $data["watchlist_id"]; + } + } +} \ No newline at end of file diff --git a/src/server/app/Exception/FileUploaderException.php b/src/server/app/Exception/FileUploaderException.php new file mode 100644 index 0000000000000000000000000000000000000000..efe6106f0dc538ff63d643f509143d88fd545c43 --- /dev/null +++ b/src/server/app/Exception/FileUploaderException.php @@ -0,0 +1,6 @@ +<?php + +class FileUploaderException extends \Exception +{ + +} \ No newline at end of file diff --git a/src/server/app/Exception/ValidationException.php b/src/server/app/Exception/ValidationException.php new file mode 100644 index 0000000000000000000000000000000000000000..94f0c43539b3f2327b041a5a980bef130da92745 --- /dev/null +++ b/src/server/app/Exception/ValidationException.php @@ -0,0 +1,13 @@ +<?php + +class ValidationException extends \Exception +{ + public function __construct( + $message, + int|null $code = 0, + Throwable|null $previous = null + ) { + parent::__construct($message, $code, $previous); + $this->code = 400; + } +} \ No newline at end of file diff --git a/src/server/app/Middleware/AdminAuthApiMiddleware.php b/src/server/app/Middleware/AdminAuthApiMiddleware.php new file mode 100644 index 0000000000000000000000000000000000000000..0bd10075be79260671335a66b1302f3f53da61f6 --- /dev/null +++ b/src/server/app/Middleware/AdminAuthApiMiddleware.php @@ -0,0 +1,37 @@ +<?php + +require_once __DIR__ . '/../App/Middleware.php'; +require_once __DIR__ . '/../Service/SessionService.php'; +require_once __DIR__ . '/../Config/Database.php'; + +require_once __DIR__ . '/../Repository/UserRepository.php'; +require_once __DIR__ . '/../Repository/SessionRepository.php'; + +class AdminAuthApiMiddleware implements Middleware +{ + private SessionService $sessionService; + + public function __construct() + { + $connection = Database::getConnection(); + + $userRepository = new UserRepository($connection); + $sessionRepository = new SessionRepository($connection); + $this->sessionService = new SessionService($sessionRepository, $userRepository); + } + + public function run(): void + { + $user = $this->sessionService->current(); + + if ($user == null || $user->role != "ADMIN") { + http_response_code(401); + $array = [ + "status" => 401, + "message" => "Unauthorized", + ]; + echo json_encode($array); + exit(); + } + } +} \ No newline at end of file diff --git a/src/server/app/Middleware/AdminAuthMiddleware.php b/src/server/app/Middleware/AdminAuthMiddleware.php new file mode 100644 index 0000000000000000000000000000000000000000..656410d7ccbdc0643ddb67a63bb0897dd6d6a28d --- /dev/null +++ b/src/server/app/Middleware/AdminAuthMiddleware.php @@ -0,0 +1,31 @@ +<?php + +require_once __DIR__ . '/../App/Middleware.php'; + +require_once __DIR__ . '/../Service/SessionService.php'; + +require_once __DIR__ . '/../Repository/UserRepository.php'; +require_once __DIR__ . '/../Repository/SessionRepository.php'; + +class AdminAuthMiddleware implements Middleware +{ + private SessionService $sessionService; + + public function __construct() + { + $connection = Database::getConnection(); + + $userRepository = new UserRepository($connection); + $sessionRepository = new SessionRepository($connection); + $this->sessionService = new SessionService($sessionRepository, $userRepository); + } + + public function run(): void + { + $user = $this->sessionService->current(); + if ($user == null || $user->role != "ADMIN") { + header("Location: /signin"); + exit(); + } + } +} \ No newline at end of file diff --git a/src/server/app/Middleware/UserAuthApiMiddleware.php b/src/server/app/Middleware/UserAuthApiMiddleware.php new file mode 100644 index 0000000000000000000000000000000000000000..b809c9412fba7ebdcc43b5c9476b7aa767808fb5 --- /dev/null +++ b/src/server/app/Middleware/UserAuthApiMiddleware.php @@ -0,0 +1,37 @@ +<?php + +require_once __DIR__ . '/../App/Middleware.php'; +require_once __DIR__ . '/../Service/SessionService.php'; +require_once __DIR__ . '/../Config/Database.php'; + +require_once __DIR__ . '/../Repository/UserRepository.php'; +require_once __DIR__ . '/../Repository/SessionRepository.php'; + +class UserAuthApiMiddleware +{ + private SessionService $sessionService; + + public function __construct() + { + $connection = Database::getConnection(); + + $userRepository = new UserRepository($connection); + $sessionRepository = new SessionRepository($connection); + $this->sessionService = new SessionService($sessionRepository, $userRepository); + } + + public function run(): void + { + $user = $this->sessionService->current(); + + if ($user == null) { + http_response_code(401); + $array = [ + "status" => 401, + "message" => "Unauthorized", + ]; + echo json_encode($array); + exit(); + } + } +} \ No newline at end of file diff --git a/src/server/app/Middleware/UserAuthMiddleware.php b/src/server/app/Middleware/UserAuthMiddleware.php new file mode 100644 index 0000000000000000000000000000000000000000..5159fa72cd300ff57269b2e9be8dfcdf1010e0bc --- /dev/null +++ b/src/server/app/Middleware/UserAuthMiddleware.php @@ -0,0 +1,31 @@ +<?php + +require_once __DIR__ . '/../App/Middleware.php'; +require_once __DIR__ . '/../Service/SessionService.php'; +require_once __DIR__ . '/../Config/Database.php'; + +require_once __DIR__ . '/../Repository/UserRepository.php'; +require_once __DIR__ . '/../Repository/SessionRepository.php'; + +class UserAuthMiddleware implements Middleware +{ + private SessionService $sessionService; + + public function __construct() + { + $connection = Database::getConnection(); + + $userRepository = new UserRepository($connection); + $sessionRepository = new SessionRepository($connection); + $this->sessionService = new SessionService($sessionRepository, $userRepository); + } + + public function run(): void + { + $user = $this->sessionService->current(); + if ($user == null) { + header("Location: /signin"); + exit(); + } + } +} diff --git a/src/server/app/Model/CatalogCreateRequest.php b/src/server/app/Model/CatalogCreateRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..13ccdaaa0c8144b8a6bbf65bf64d56bee001b159 --- /dev/null +++ b/src/server/app/Model/CatalogCreateRequest.php @@ -0,0 +1,10 @@ +<?php + +class CatalogCreateRequest +{ + public string $title; + public string $description; + public $poster = null; + public $trailer = null; + public string $category; +} \ No newline at end of file diff --git a/src/server/app/Model/CatalogCreateResponse.php b/src/server/app/Model/CatalogCreateResponse.php new file mode 100644 index 0000000000000000000000000000000000000000..1752af5a9e52b5eb30ec5ecb0bc6583496deda25 --- /dev/null +++ b/src/server/app/Model/CatalogCreateResponse.php @@ -0,0 +1,8 @@ +<?php + +require_once __DIR__ . '/../Domain/Catalog.php'; + +class CatalogCreateResponse +{ + public Catalog $catalog; +} \ No newline at end of file diff --git a/src/server/app/Model/CatalogSearchRequest.php b/src/server/app/Model/CatalogSearchRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..15bb2b8e2e91ac340e8f2fdd8b3643b4557b8d85 --- /dev/null +++ b/src/server/app/Model/CatalogSearchRequest.php @@ -0,0 +1,8 @@ +<?php + +class CatalogSearchRequest +{ + public ?string $title; + public ?int $page; + public ?int $pageSize; +} diff --git a/src/server/app/Model/CatalogSearchResponse.php b/src/server/app/Model/CatalogSearchResponse.php new file mode 100644 index 0000000000000000000000000000000000000000..19492b3480ee8d2de68cb0e71dbaaf77da789df8 --- /dev/null +++ b/src/server/app/Model/CatalogSearchResponse.php @@ -0,0 +1,8 @@ +<?php + +require_once __DIR__ . '/../Domain/Catalog.php'; + +class CatalogSearchResponse +{ + public array $catalogs; +} diff --git a/src/server/app/Model/UserEditRequest.php b/src/server/app/Model/UserEditRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..fd5c7afc46a4b5c55e248f7f9ffb9d81689de3fc --- /dev/null +++ b/src/server/app/Model/UserEditRequest.php @@ -0,0 +1,9 @@ +<?php + +class UserEditRequest +{ + public ?string $email = null; + public ?string $name = null; + public ?string $oldPassword = null; + public ?string $newPassword = null; +} diff --git a/src/server/app/Model/UserEditResponse.php b/src/server/app/Model/UserEditResponse.php new file mode 100644 index 0000000000000000000000000000000000000000..f7441cbdeeb4326fe1151c7871e92768cbdc0c6a --- /dev/null +++ b/src/server/app/Model/UserEditResponse.php @@ -0,0 +1,8 @@ +<?php + +require_once __DIR__ . '/../Domain/User.php'; + +class UserEditResponse +{ + public User $user; +} diff --git a/src/server/app/Model/UserSignInRequest.php b/src/server/app/Model/UserSignInRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..a1098ddd5275d1426724c36933e74ac3b2ea81e2 --- /dev/null +++ b/src/server/app/Model/UserSignInRequest.php @@ -0,0 +1,7 @@ +<?php + +class UserSignInRequest +{ + public ?string $email = null; + public ?string $password = null; +} diff --git a/src/server/app/Model/UserSignInResponse.php b/src/server/app/Model/UserSignInResponse.php new file mode 100644 index 0000000000000000000000000000000000000000..145017eb6b50600d93dcd41ae8255adbf8a8ea8c --- /dev/null +++ b/src/server/app/Model/UserSignInResponse.php @@ -0,0 +1,8 @@ +<?php + +require_once __DIR__ . '/../Domain/User.php'; + +class UserSignInResponse +{ + public User $user; +} diff --git a/src/server/app/Model/UserSignUpRequest.php b/src/server/app/Model/UserSignUpRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..1aae8f46164d2ac211c125ca66115be241538a62 --- /dev/null +++ b/src/server/app/Model/UserSignUpRequest.php @@ -0,0 +1,8 @@ +<?php + +class UserSignUpRequest +{ + public ?string $email = null; + public ?string $password = null; + public ?string $confirmPassword = null; +} diff --git a/src/server/app/Model/UserSignUpResponse.php b/src/server/app/Model/UserSignUpResponse.php new file mode 100644 index 0000000000000000000000000000000000000000..443eb03643076dfc2ad352332b4eb834bb9ce755 --- /dev/null +++ b/src/server/app/Model/UserSignUpResponse.php @@ -0,0 +1,8 @@ +<?php + +require_once __DIR__ . '/../Domain/User.php'; + +class UserSignUpResponse +{ + public User $user; +} diff --git a/src/server/app/Model/WatchlistAddItemRequest.php b/src/server/app/Model/WatchlistAddItemRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..0bd24ad714cbc2cfd3182eb47796b7a515032a49 --- /dev/null +++ b/src/server/app/Model/WatchlistAddItemRequest.php @@ -0,0 +1,6 @@ +<?php + +class WatchlistAddItemRequest +{ + public string $id; +} diff --git a/src/server/app/Model/WatchlistAddItemResponse.php b/src/server/app/Model/WatchlistAddItemResponse.php new file mode 100644 index 0000000000000000000000000000000000000000..aed6c8d744471d6e236fe74db823c2725f9723cf --- /dev/null +++ b/src/server/app/Model/WatchlistAddItemResponse.php @@ -0,0 +1,6 @@ +<?php + +class WatchlistAdditemResponse +{ + public ?Catalog $catalog; +} diff --git a/src/server/app/Model/WatchlistCreateRequest.php b/src/server/app/Model/WatchlistCreateRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..66e93e891de398931dec9805c6f7427ee3aacb03 --- /dev/null +++ b/src/server/app/Model/WatchlistCreateRequest.php @@ -0,0 +1,12 @@ +<?php + +class WatchlistCreateRequest +{ + public ?int $userId; + public ?string $title; + public ?string $description; + public ?string $visibility; + public ?array $items; + public ?array $tags; + public ?array $initialTags; +} diff --git a/src/server/app/Model/WatchlistsGetRequest.php b/src/server/app/Model/WatchlistsGetRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..47471033e758e583a56fbf2af3a8316c9bb77023 --- /dev/null +++ b/src/server/app/Model/WatchlistsGetRequest.php @@ -0,0 +1,13 @@ +<?php + +class WatchlistsGetRequest +{ + public ?int $userId; + public ?string $search; + public ?string $category; + public ?string $tag; + public ?array $tagsInit; + public ?string $sortBy; + public ?string $order; + public ?int $page; +} diff --git a/src/server/app/Model/bookmark/BookmarkGetRequest.php b/src/server/app/Model/bookmark/BookmarkGetRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..971d81536ca19d4216a6fe01d869c3cc31370aad --- /dev/null +++ b/src/server/app/Model/bookmark/BookmarkGetRequest.php @@ -0,0 +1,8 @@ +<?php + +class BookmarkGetRequest +{ + public int $userId; + public int $page = 1; + public int $pageSize = 10; +} \ No newline at end of file diff --git a/src/server/app/Model/catalog/CatalogUpdateRequest.php b/src/server/app/Model/catalog/CatalogUpdateRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..2f07d18d5988950bfca1742ce1d20eb4ad253b92 --- /dev/null +++ b/src/server/app/Model/catalog/CatalogUpdateRequest.php @@ -0,0 +1,11 @@ +<?php + +class CatalogUpdateRequest +{ + public string $uuid; + public string $title; + public string $description; + public $poster = null; + public $trailer = null; + public string $category; +} \ No newline at end of file diff --git a/src/server/app/Model/session/SessionCreateRequest.php b/src/server/app/Model/session/SessionCreateRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..11f16689ce28ddd6e48d6d33eeed05b40c13a2b5 --- /dev/null +++ b/src/server/app/Model/session/SessionCreateRequest.php @@ -0,0 +1,6 @@ +<?php + +class SessionCreateRequest +{ + public int $userId; +} \ No newline at end of file diff --git a/src/server/app/Model/watchlist/WatchlistDeleteRequest.php b/src/server/app/Model/watchlist/WatchlistDeleteRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..723cbf52bf9198c5097c42a4165f50013be89b73 --- /dev/null +++ b/src/server/app/Model/watchlist/WatchlistDeleteRequest.php @@ -0,0 +1,6 @@ +<?php + +class WatchlistDeleteRequest +{ + public ?string $watchlistUUID; +} \ No newline at end of file diff --git a/src/server/app/Model/watchlist/WatchlistEditRequest.php b/src/server/app/Model/watchlist/WatchlistEditRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..d5caaeddc2fc321a6716087a800d84955f05d52e --- /dev/null +++ b/src/server/app/Model/watchlist/WatchlistEditRequest.php @@ -0,0 +1,13 @@ +<?php + +class WatchlistEditRequest +{ + public $watchlist; + public ?int $userId; + public ?string $title; + public ?string $description; + public ?string $visibility; + public ?array $items; + public ?array $tags; + public ?array $initialTags; +} \ No newline at end of file diff --git a/src/server/app/Model/watchlist/WatchlistGetOneByUserRequest.php b/src/server/app/Model/watchlist/WatchlistGetOneByUserRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..908494ee4f54842871813c499948d383d8a8a69d --- /dev/null +++ b/src/server/app/Model/watchlist/WatchlistGetOneByUserRequest.php @@ -0,0 +1,7 @@ +<?php + +class WatchlistGetOneByUserRequest +{ + public int $userId; + public ?string $visibility; +} \ No newline at end of file diff --git a/src/server/app/Model/watchlist/WatchlistGetOneRequest.php b/src/server/app/Model/watchlist/WatchlistGetOneRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..15e04b8876d921294b5069915ea59fd9bddb695f --- /dev/null +++ b/src/server/app/Model/watchlist/WatchlistGetOneRequest.php @@ -0,0 +1,9 @@ +<?php + +class WatchlistsGetOneRequest +{ + public ?int $userId = null; + public string $uuid; + public int $page = 1; + public int $pageSize = 10; +} \ No newline at end of file diff --git a/src/server/app/Model/watchlist/WatchlistLikeRequest.php b/src/server/app/Model/watchlist/WatchlistLikeRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..d254c97b84224860c1299d8f01331a3fe9351fab --- /dev/null +++ b/src/server/app/Model/watchlist/WatchlistLikeRequest.php @@ -0,0 +1,7 @@ +<?php + +class WatchlistLikeRequest +{ + public ?string $watchlistUUID; + public ?string $userId; +} \ No newline at end of file diff --git a/src/server/app/Model/watchlist/WatchlistSaveRequest.php b/src/server/app/Model/watchlist/WatchlistSaveRequest.php new file mode 100644 index 0000000000000000000000000000000000000000..ab14182a5796256b542c489bcf3317efa2451fda --- /dev/null +++ b/src/server/app/Model/watchlist/WatchlistSaveRequest.php @@ -0,0 +1,7 @@ +<?php + +class WatchlistSaveRequest +{ + public ?string $watchlistUUID; + public ?string $userId; +} \ No newline at end of file diff --git a/src/server/app/Repository/CatalogRepository.php b/src/server/app/Repository/CatalogRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..340bac22f5485d53d686957307fa20618e35825d --- /dev/null +++ b/src/server/app/Repository/CatalogRepository.php @@ -0,0 +1,43 @@ +<?php + +require_once __DIR__ . '/../App/Repository.php'; +require_once __DIR__ . '/../Domain/Catalog.php'; +require_once __DIR__ . '/../Utils/FilterBuilder.php'; + +class CatalogRepository extends Repository +{ + protected string $table = 'catalogs'; + + public function __construct(PDO $connection) + { + parent::__construct($connection); + } + + public function findAll(array $projection = [], int|null $page = null, int|null $pageSize = null): array + { + $result = parent::findAll($projection, $page, $pageSize); + + $result['items'] = array_map( + function ($row) { + $catalog = new Catalog(); + $catalog->fromArray($row); + return $catalog; + }, + $result['items'] + ); + return $result; + } + + public function findOne($key, $value, $projection = []): ?Catalog + { + $result = parent::findOne($key, $value, $projection); + + if ($result) { + $catalog = new Catalog(); + $catalog->fromArray($result); + return $catalog; + } else { + return null; + } + } +} \ No newline at end of file diff --git a/src/server/app/Repository/SessionRepository.php b/src/server/app/Repository/SessionRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..8fe54ac8b3dd657cf3db57f0292056f448d0d31f --- /dev/null +++ b/src/server/app/Repository/SessionRepository.php @@ -0,0 +1,29 @@ +<?php +require_once __DIR__ . '/../App/Repository.php'; +require_once __DIR__ . '/../App/Domain.php'; + +require_once __DIR__ . '/../Domain/Session.php'; + +class SessionRepository extends Repository +{ + protected string $table = "sessions"; + + public function __construct(PDO $connection) + { + parent::__construct($connection); + } + + public function findOne($key, $value, $projection = []): ?Session + { + $result = parent::findOne($key, $value, $projection); + + if ($result != null) { + $session = new Session(); + $session->fromArray($result); + + return $session; + } else { + return null; + } + } +} \ No newline at end of file diff --git a/src/server/app/Repository/TagRepository.php b/src/server/app/Repository/TagRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..b2be3393966503e918e8664e1690eb3e1628867f --- /dev/null +++ b/src/server/app/Repository/TagRepository.php @@ -0,0 +1,44 @@ +<?php + +require_once __DIR__ . '/../App/Repository.php'; + +require_once __DIR__ . '/../Domain/Tag.php'; + +class TagRepository extends Repository +{ + protected string $table = "tags"; + + public function __construct(PDO $connection) + { + parent::__construct($connection); + } + + public function findAll(array $projection = [], int|null $page = null, int|null $pageSize = null): array + { + $result = parent::findAll($projection, $page, $pageSize); + + $result['items'] = array_map( + function ($row) { + $tags = new Tag(); + $tags->fromArray($row); + return $tags; + }, + $result['items'] + ); + return $result; + } + + public function findOne($key, $value, $projection = []) + { + $result = parent::findOne($key, $value, $projection); + + if ($result != null) { + $user = new User(); + $user->fromArray($result); + + return $user; + } else { + return null; + } + } +} \ No newline at end of file diff --git a/src/server/app/Repository/UserRepository.php b/src/server/app/Repository/UserRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..a29dc04a6e938c8eb7dd407a9a17860d6088284e --- /dev/null +++ b/src/server/app/Repository/UserRepository.php @@ -0,0 +1,59 @@ +<?php + +require_once __DIR__ . '/../App/Repository.php'; +require_once __DIR__ . '/../Utils/UUIDGenerator.php'; + +require_once __DIR__ . '/../Domain/User.php'; + +class UserRepository extends Repository +{ + protected string $table = "users"; + + public function __construct(PDO $connection) + { + parent::__construct($connection); + } + + public function findOne($key, $value, $projection = []): ?User + { + $result = parent::findOne($key, $value, $projection); + + if ($result != null) { + $user = new User(); + $user->fromArray($result); + + return $user; + } else { + return null; + } + } + + public function updateName(User $user): User + { + $statement = $this->connection->prepare("UPDATE users SET name = ? WHERE email = ?"); + $statement->execute([ + $user->name, + $user->email, + ]); + return $user; + } + + public function updatePassword(User $user): User + { + $statement = $this->connection->prepare("UPDATE users SET password = ? WHERE email = ?"); + $statement->execute([ + $user->password, + $user->email, + ]); + return $user; + } + + + public function deleteBySession(string $email) + { + $statement = $this->connection->prepare("DELETE FROM sessions WHERE user_id IN + (SELECT id FROM users WHERE email = ?)"); + $statement->execute([$email]); + $statement->closeCursor(); + } +} diff --git a/src/server/app/Repository/WatchlistItemRepository.php b/src/server/app/Repository/WatchlistItemRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..f1751b23c67e9bbdc1ee7e00857a4b65791e9795 --- /dev/null +++ b/src/server/app/Repository/WatchlistItemRepository.php @@ -0,0 +1,22 @@ +<?php + +class WatchlistItemRepository extends Repository +{ + protected string $table = 'watchlist_items'; + + public function __construct(\PDO $connection, ) + { + parent::__construct($connection); + } + + public function findAll(array $projection = [], int|null $page = 1, int|null $pageSize = 10): array + { + // TO DO: Implemented soon + return []; + } + + public function findOne($key, $value, $projection = []) + { + // TO DO: Implemented soon + } +} \ No newline at end of file diff --git a/src/server/app/Repository/WatchlistLikeRepository.php b/src/server/app/Repository/WatchlistLikeRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..fab36084bc8a1d82b7808de292401f2abca292dc --- /dev/null +++ b/src/server/app/Repository/WatchlistLikeRepository.php @@ -0,0 +1,50 @@ +<?php + +require_once __DIR__ . '/../App/Repository.php'; + +class WatchlistLikeRepository extends Repository +{ + protected string $table = "watchlist_like"; + + public function __construct(PDO $connection) + { + parent::__construct($connection); + } + + public function saveByWatchlistAndUser(string $watchlistId, string $userId) + { + $statement = $this->connection->prepare("INSERT INTO $this->table (watchlist_id, user_id) VALUES (:watchlist_id, :user_id)"); + $statement->bindValue(":watchlist_id", $watchlistId); + $statement->bindValue(":user_id", $userId); + $statement->execute(); + + $statement->closeCursor(); + } + + public function findOneByWatchlistAndUser(string $watchlistId, string $userId) + { + $statement = $this->connection->prepare("SELECT * FROM $this->table WHERE watchlist_id = :watchlist_id AND user_id = :user_id"); + $statement->bindValue(":watchlist_id", $watchlistId); + $statement->bindValue(":user_id", $userId); + $statement->execute(); + + try { + if ($row = $statement->fetch()) { + return $row; + } + return null; + } finally { + $statement->closeCursor(); + } + } + + public function deleteByWatchlistAndUser(string $watchlistId, string $userId) + { + $statement = $this->connection->prepare("DELETE FROM $this->table WHERE watchlist_id = :watchlist_id AND user_id = :user_id"); + $statement->bindValue(":watchlist_id", $watchlistId); + $statement->bindValue(":user_id", $userId); + $statement->execute(); + + $statement->closeCursor(); + } +} \ No newline at end of file diff --git a/src/server/app/Repository/WatchlistRepository.php b/src/server/app/Repository/WatchlistRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..b64699cd0a55ef6e1c821f3e404d97e333028f3f --- /dev/null +++ b/src/server/app/Repository/WatchlistRepository.php @@ -0,0 +1,378 @@ +<?php + +require_once __DIR__ . '/../App/Repository.php'; +require_once __DIR__ . '/../Domain/Watchlist.php'; + +class WatchlistRepository extends Repository +{ + protected string $table = 'watchlists'; + + public function __construct(\PDO $connection) + { + parent::__construct($connection); + } + + public function findAll(array $projection = [], int|null $page = null, int|null $pageSize = null): array + { + $result = parent::findAll($projection, $page, $pageSize); + + $result['items'] = array_map( + function ($row) { + $watchlist = new Watchlist(); + $watchlist->fromArray($row); + + return $watchlist; + }, + $result['items'] + ); + + return $result; + } + + public function findOne($key, $value, $projection = []): ?Watchlist + { + $result = parent::findOne($key, $value, $projection); + + if ($result) { + $watchlist = new Watchlist(); + $watchlist->fromArray($result); + return $watchlist; + } else { + return null; + } + } + + public function findAllCustom(string $userId, string $search, string $category, string $sortBy, string $order, string $tag, int $page = 1, int $pageSize = 10) + { + if ($category != "") + $category = " AND category = '$category'"; + + // Queries + $selectFirstQuery = " + WITH first_agg AS ( + SELECT w.id AS watchlist_id, jsonb_agg(jsonb_build_object( + 'rank', rank, + 'poster', poster, + 'catalog_uuid', c.uuid + )) AS posters, w.uuid AS watchlist_uuid, u.name AS creator, u.uuid AS creator_uuid, item_count, loved, saved, w.title, w.description, w.category, visibility, love_count, w.created_at AS created_at + "; + $selectSecondQuery = " + ) + SELECT + jsonb_agg(jsonb_build_object( + 'id', t.id, + 'name', t.name + )) AS tags, w.watchlist_id, w.posters as posters, w.watchlist_uuid, w.creator, w.creator_uuid, w.item_count, w.loved, w.saved, w.title, w.description, w.category, w.visibility, w.love_count, w.created_at + FROM first_agg AS w + LEFT JOIN watchlist_tag as wt ON wt.watchlist_id = w.watchlist_id + LEFT JOIN tags as t ON t.id = wt.tag_id + GROUP BY + w.watchlist_id, w.watchlist_uuid, w.posters, w.creator, w.creator_uuid, w.item_count, w.loved, w.saved, w.title, w.description, w.category, w.visibility, w.love_count, w.created_at + ORDER BY + $sortBy $order, + w.created_at DESC + "; + $countQuery = "WITH rows AS (SELECT COUNT(*)"; + $mainQuery = " + FROM ( + SELECT + id, uuid, title, description, category, visibility, like_count AS love_count, item_count, user_id, created_at, + CASE + WHEN id IN ( + SELECT watchlist_id + FROM watchlist_like + WHERE user_id = :user_id + ) THEN TRUE + ELSE FALSE + END AS loved, + CASE + WHEN id IN ( + SELECT watchlist_id + FROM watchlist_save + WHERE user_id = :user_id + ) THEN TRUE + ELSE FALSE + END AS saved + FROM + watchlists w + WHERE + (w.title ILIKE :watchlist_title + AND visibility = 'PUBLIC' + $category) + OR + (visibility = 'PUBLIC' + $category) + ORDER BY + $sortBy $order, + created_at DESC + ) AS w JOIN users AS u ON w.user_id = u.id + JOIN (SELECT * FROM watchlist_items WHERE rank < 5) AS wi ON wi.watchlist_id = w.id + JOIN catalogs AS c ON c.id = wi.catalog_id + WHERE + u.name ILIKE :creator + OR w.title ILIKE :watchlist_title + GROUP BY + w.id, w.uuid, u.name, w.title, u.name, u.uuid, item_count, loved, saved, w.description, w.category, visibility, love_count, w.created_at + ORDER BY + $sortBy $order, + w.created_at DESC + "; + + if ($tag) { + $selectStatement = $this->connection->prepare("WITH outer_query AS (" . + $selectFirstQuery . $mainQuery . $selectSecondQuery . + ") SELECT * FROM outer_query as o + WHERE EXISTS + (SELECT 1 + FROM jsonb_array_elements(o.tags) + AS elem WHERE elem @> '{\"name\": \"$tag\"}'::jsonb) + LIMIT :limit + OFFSET :offset + "); + $pageCountStatement = $this->connection->prepare("WITH outer_query AS (" . + $selectFirstQuery . $mainQuery . $selectSecondQuery . + ") SELECT COUNT(*) FROM outer_query as o + WHERE EXISTS + (SELECT 1 + FROM jsonb_array_elements(o.tags) + AS elem WHERE elem @> '{\"name\": \"$tag\"}'::jsonb) + LIMIT :limit + OFFSET :offset + "); + } else { + $selectStatement = $this->connection->prepare($selectFirstQuery . $mainQuery . $selectSecondQuery . " LIMIT :limit + OFFSET :offset"); + $pageCountStatement = $this->connection->prepare($countQuery . $mainQuery . ") SELECT COUNT(*) FROM rows"); + } + + + // Binding select + $selectStatement->bindValue(":user_id", $userId); + $selectStatement->bindValue(":watchlist_title", '%' . $search . '%'); + $selectStatement->bindValue(":limit", $pageSize); + $selectStatement->bindValue(":offset", ($page - 1) * $pageSize); + $selectStatement->bindValue(":creator", '%' . $search . '%'); + + // Binding count + $pageCountStatement->bindValue(":user_id", $userId); + $pageCountStatement->bindValue(":watchlist_title", '%' . $search . '%'); + if ($tag) { + $pageCountStatement->bindValue(":limit", PHP_INT_MAX); + $pageCountStatement->bindValue(":offset", 0); + } + $pageCountStatement->bindValue(":creator", '%' . $search . '%'); + + $selectStatement->execute(); + $pageCountStatement->execute(); + + try { + return [ + "items" => $selectStatement->fetchAll(), + "page" => $page, + "pageTotal" => ceil($pageCountStatement->fetchColumn() / $pageSize) + ]; + } finally { + $selectStatement->closeCursor(); + $pageCountStatement->closeCursor(); + } + } + + public function findByUser(int $userId, string|null $visibility, int $page = null, int $pageSize = null) + { + $query = " + FROM ( + SELECT + id, uuid, title, description, category, visibility, like_count, item_count, user_id, updated_at, created_at, + CASE + WHEN id IN ( + SELECT watchlist_id + FROM watchlist_like + WHERE user_id = :user_id + ) THEN TRUE + ELSE FALSE + END AS liked + FROM + watchlists + WHERE + user_id = :user_id " . (!empty($visibility) ? "AND visibility = :visibility" : "") . " + LIMIT :limit + OFFSET :offset + ) AS w JOIN users AS u ON w.user_id = u.id + JOIN (SELECT * FROM watchlist_items WHERE rank < 5) AS wi ON wi.watchlist_id = w.id + JOIN catalogs AS c ON c.id = wi.catalog_id + "; + $selectFirstQuery = " + WITH first_agg AS ( + SELECT w.id AS watchlist_id, jsonb_agg(jsonb_build_object( + 'rank', rank, + 'poster', poster, + 'catalog_uuid', c.uuid + )) AS posters, w.uuid AS watchlist_uuid, name AS creator, u.uuid AS creator_uuid, item_count, liked, w.title, w.description, w.category, visibility, like_count, w.updated_at AS updated_at, w.created_at AS created_at"; + $selectSecondQuery = " + ) + SELECT + jsonb_agg(jsonb_build_object( + 'id', t.id, + 'name', t.name + )) AS tags, fa.watchlist_id, fa.posters as posters, fa.watchlist_uuid, fa.creator, fa.creator_uuid, fa.item_count, fa.liked, fa.title, fa.description, fa.category, fa.visibility, fa.like_count, fa.created_at + FROM first_agg AS fa + LEFT JOIN watchlist_tag as wt ON wt.watchlist_id = fa.watchlist_id + LEFT JOIN tags as t ON t.id = wt.tag_id + GROUP BY + fa.watchlist_id, fa.watchlist_uuid, fa.posters, fa.creator, fa.creator_uuid, fa.item_count, fa.liked, fa.title, fa.description, fa.category, fa.visibility, fa.like_count, fa.created_at + ORDER BY + fa.created_at DESC + "; + $pageCountQuery = "SELECT COUNT(*) FROM (SELECT DISTINCT w.id "; + + $selectStatement = $this->connection->prepare($selectFirstQuery . $query . "GROUP BY + watchlist_id, watchlist_uuid, creator, u.uuid, w.title, w.uuid, u.name, item_count, liked, w.id, w.description, w.category, visibility, like_count, w.updated_at, w.created_at" . $selectSecondQuery); + $selectStatement->bindValue(':user_id', $userId, PDO::PARAM_INT); + $selectStatement->bindValue(':limit', $pageSize, PDO::PARAM_INT); + $offset = ($page - 1) * $pageSize; + $selectStatement->bindValue(':offset', $offset, PDO::PARAM_INT); + + $pageCountStatement = $this->connection->prepare($pageCountQuery . $query . ") AS w"); + $pageCountStatement->bindValue(':user_id', $userId, PDO::PARAM_INT); + $pageCountStatement->bindValue(':limit', PHP_INT_MAX, PDO::PARAM_INT); + $pageCountStatement->bindValue(':offset', 0, PDO::PARAM_INT); + if (!empty($visibility)) { + $selectStatement->bindValue(':visibility', $visibility, PDO::PARAM_STR); + $pageCountStatement->bindValue(':visibility', $visibility, PDO::PARAM_STR); + } + + $selectStatement->execute(); + $pageCountStatement->execute(); + + try { + $rows = $selectStatement->fetchAll(); + return [ + 'items' => $rows, + 'page' => max(1, $page), + 'totalPage' => $pageSize > 0 ? ceil($pageCountStatement->fetchColumn() / $pageSize) : 1 + ]; + } finally { + $selectStatement->closeCursor(); + $pageCountStatement->closeCursor(); + } + } + + public function findByUUID(string $uuid, int|null $user_id, int $page = 1, int $pageSize = 10) + { + $selectQuery = " + WITH first_agg AS ( + WITH w AS ( + SELECT + id, uuid, title, description, category, visibility, like_count, item_count, user_id, created_at + ,CASE + WHEN id IN ( + SELECT watchlist_id + FROM watchlist_like + WHERE user_id = :user_id + ) THEN TRUE + ELSE FALSE + END AS liked, + CASE + WHEN id IN ( + SELECT watchlist_id + FROM watchlist_save + WHERE user_id = :user_id + ) THEN TRUE + ELSE FALSE + END AS saved + FROM + watchlists + WHERE + watchlists.uuid = :uuid + LIMIT 1 + ) + SELECT w.id AS watchlist_id, jsonb_agg(jsonb_build_object( + 'rank', rank, + 'poster', poster, + 'catalog_uuid', c.uuid, + 'catalog_id', c.id, + 'description', wi.description, + 'title', c.title, + 'category', c.category + )) AS catalogs, w.uuid AS watchlist_uuid, name AS creator, item_count, w.title, w.description, w.category, visibility, like_count, w.created_at, u.uuid AS creator_uuid + ,liked, saved + FROM w JOIN users AS u ON w.user_id = u.id + , (SELECT * FROM watchlist_items WHERE watchlist_id IN (SELECT id FROM w) ORDER BY rank LIMIT :limit OFFSET :offset) AS wi + JOIN catalogs AS c ON c.id = wi.catalog_id + GROUP BY + watchlist_id, watchlist_uuid, creator, w.title, w.uuid, name, item_count, w.id, w.description, w.category, visibility, like_count, w.created_at, u.uuid + ,liked, saved + ) + SELECT + jsonb_agg(jsonb_build_object( + 'id', t.id, + 'name', t.name + )) AS tags, fa.watchlist_id, fa.catalogs, fa.watchlist_uuid, fa.creator, fa.creator_uuid, fa.item_count, fa.liked, fa.saved, fa.title, fa.description, fa.category, fa.visibility, fa.like_count, fa.created_at + FROM first_agg AS fa + LEFT JOIN watchlist_tag as wt ON wt.watchlist_id = fa.watchlist_id + LEFT JOIN tags as t ON t.id = wt.tag_id + GROUP BY + fa.watchlist_id, fa.watchlist_uuid, fa.catalogs, fa.creator, fa.creator_uuid, fa.item_count, fa.liked, fa.saved, fa.title, fa.description, fa.category, fa.visibility, fa.like_count, fa.created_at + ORDER BY + fa.created_at DESC + "; + + $pageCountQuery = " + SELECT COUNT(*) + FROM ( + SELECT id, user_id + FROM + watchlists + WHERE + watchlists.uuid = :uuid + LIMIT 1) AS w JOIN users AS u ON w.user_id = u.id + JOIN (SELECT * FROM watchlist_items ORDER BY rank) AS wi ON wi.watchlist_id = w.id + JOIN catalogs AS c ON c.id = wi.catalog_id + "; + + $selectStatement = $this->connection->prepare($selectQuery); + $selectStatement->bindParam(':uuid', $uuid, PDO::PARAM_STR); + $selectStatement->bindParam(':limit', $pageSize, PDO::PARAM_INT); + $offset = ($page - 1) * $pageSize; + $selectStatement->bindParam(':offset', $offset, PDO::PARAM_INT); + $selectStatement->bindParam(':user_id', $user_id, PDO::PARAM_INT); + + + $pageCountStatement = $this->connection->prepare($pageCountQuery); + $pageCountStatement->bindParam(':uuid', $uuid, PDO::PARAM_STR); + + $selectStatement->execute(); + $pageCountStatement->execute(); + + try { + function catalogCompare($element1, $element2) + { + return $element1["rank"] - $element2["rank"]; + } + + $totalPage = $pageSize > 0 ? ceil($pageCountStatement->fetchColumn() / $pageSize) : 1; + if ($rows = $selectStatement->fetch()) { + $catalogs = json_decode($rows["catalogs"], true); + $tags = json_decode($rows["tags"], true); + $tags = array_filter($tags, function ($value) { + return $value["id"] !== null; + }); + usort($catalogs, "catalogCompare"); + $rows["tags"] = $tags; + + $rows["catalogs"] = [ + "items" => $catalogs, + "page" => max(1, $page), + 'totalPage' => $totalPage + ]; + + return $rows; + } + + return null; + } finally { + $selectStatement->closeCursor(); + $pageCountStatement->closeCursor(); + } + } +} \ No newline at end of file diff --git a/src/server/app/Repository/WatchlistSaveRepository.php b/src/server/app/Repository/WatchlistSaveRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..5fae6c6826ef1808ee4fbe76993b87da0c1e5d97 --- /dev/null +++ b/src/server/app/Repository/WatchlistSaveRepository.php @@ -0,0 +1,152 @@ +<?php + +class WatchlistSaveRepository extends Repository +{ + protected string $table = "watchlist_save"; + + public function __construct(PDO $connection) + { + parent::__construct($connection); + } + + public function findByUser(string $userId, int $page, int $pageSize) + { + $selectQuery = " + WITH first_agg AS ( + SELECT + w.id AS watchlist_id, + jsonb_agg( + jsonb_build_object( + 'rank', rank, + 'poster', poster, + 'catalog_uuid', c.uuid + ) + ) AS posters, + w.uuid AS watchlist_uuid, name AS creator, u.uuid AS creator_uuid, item_count, + liked, w.title, w.description, w.category, visibility, + like_count, w.created_at AS created_at, w.updated_at AS updated_at + FROM + ( + SELECT + w.id, uuid, title, description, category, + visibility, like_count, item_count, w.user_id, + created_at, updated_at, + CASE WHEN w.id IN( + SELECT watchlist_id + FROM watchlist_like + WHERE user_id = :user_id + ) THEN TRUE ELSE FALSE + END AS liked + FROM watchlists w + JOIN watchlist_save wv ON + w.id = wv.watchlist_id + WHERE wv.user_id = :user_id + AND w.visibility = 'PUBLIC' + LIMIT :limit + OFFSET :offset + ) AS w + JOIN users AS u ON + w.user_id = u.id + JOIN( + SELECT * + FROM watchlist_items + WHERE rank < 5 + ) AS wi + ON wi.watchlist_id = w.id + JOIN catalogs AS c + ON c.id = wi.catalog_id + GROUP BY w.id, w.uuid, creator, + w.title, name, u.uuid, item_count, liked, w.description, + w.category, visibility, like_count, w.created_at, w.updated_at + ) + SELECT + jsonb_agg(jsonb_build_object( + 'id', t.id, + 'name', t.name + )) AS tags, fa.watchlist_id, fa.posters as posters, fa.watchlist_uuid, fa.creator, fa.creator_uuid, fa.item_count, fa.liked, fa.title, fa.description, fa.category, fa.visibility, fa.like_count, fa.created_at + FROM first_agg AS fa + LEFT JOIN watchlist_tag as wt ON wt.watchlist_id = fa.watchlist_id + LEFT JOIN tags as t ON t.id = wt.tag_id + GROUP BY + fa.watchlist_id, fa.watchlist_uuid, fa.posters, fa.creator, fa.creator_uuid, fa.item_count, fa.liked, fa.title, fa.description, fa.category, fa.visibility, fa.like_count, fa.created_at + ORDER BY + fa.created_at DESC + "; + + $pageCountQuery = " + SELECT COUNT(*) + FROM ( SELECT DISTINCT w.id + FROM ( + SELECT w.id, w.user_id + FROM watchlists w + JOIN watchlist_save wv + ON w.id = wv.watchlist_id + WHERE wv.user_id = :user_id + ) AS w JOIN users AS u + ON w.user_id = u.id + JOIN (SELECT * FROM watchlist_items WHERE rank < 5) AS wi ON wi.watchlist_id = w.id + JOIN catalogs AS c ON c.id = wi.catalog_id ) AS w + "; + + $selectStatement = $this->connection->prepare($selectQuery); + $selectStatement->bindParam(":user_id", $userId, PDO::PARAM_INT); + $selectStatement->bindParam(":limit", $pageSize, PDO::PARAM_INT); + $selectStatement->bindValue(":offset", ($page - 1) * $pageSize, PDO::PARAM_INT); + $selectStatement->execute(); + + $pageCountStatement = $this->connection->prepare($pageCountQuery); + $pageCountStatement->bindParam(":user_id", $userId, PDO::PARAM_INT); + $pageCountStatement->execute(); + + try { + $result = $selectStatement->fetchAll(PDO::FETCH_ASSOC); + $pageCount = $pageCountStatement->fetchColumn(); + + return [ + 'items' => $result, + 'page' => max(1, $page), + 'totalPage' => $pageSize > 0 ? ceil($pageCount / $pageSize) : 1 + ]; + } finally { + $selectStatement->closeCursor(); + $pageCountStatement->closeCursor(); + } + } + + public function saveByWatchlistAndUser(string $watchlistId, string $userId) + { + $statement = $this->connection->prepare("INSERT INTO $this->table (watchlist_id, user_id) VALUES (:watchlist_id, :user_id)"); + $statement->bindValue(":watchlist_id", $watchlistId); + $statement->bindValue(":user_id", $userId); + $statement->execute(); + + $statement->closeCursor(); + } + + public function findOneByWatchlistAndUser(string $watchlistId, string $userId) + { + $statement = $this->connection->prepare("SELECT * FROM $this->table WHERE watchlist_id = :watchlist_id AND user_id = :user_id"); + $statement->bindValue(":watchlist_id", $watchlistId); + $statement->bindValue(":user_id", $userId); + $statement->execute(); + + try { + if ($row = $statement->fetch()) { + return $row; + } + return null; + } finally { + $statement->closeCursor(); + } + } + + public function deleteByWatchlistAndUser(string $watchlistId, string $userId) + { + $statement = $this->connection->prepare("DELETE FROM $this->table WHERE watchlist_id = :watchlist_id AND user_id = :user_id"); + $statement->bindValue(":watchlist_id", $watchlistId); + $statement->bindValue(":user_id", $userId); + $statement->execute(); + + $statement->closeCursor(); + } +} \ No newline at end of file diff --git a/src/server/app/Repository/WatchlistTagRepository.php b/src/server/app/Repository/WatchlistTagRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..6e30dbc467ea439fbff7961932eaae4eb4bf98fc --- /dev/null +++ b/src/server/app/Repository/WatchlistTagRepository.php @@ -0,0 +1,13 @@ +<?php + +require_once __DIR__ . '/../App/Repository.php'; + +class WatchlistTagRepository extends Repository +{ + protected string $table = "watchlist_tag"; + + public function __construct(PDO $connection) + { + parent::__construct($connection); + } +} \ No newline at end of file diff --git a/src/server/app/Service/BookmarkService.php b/src/server/app/Service/BookmarkService.php new file mode 100644 index 0000000000000000000000000000000000000000..077f6d58632999d112ab53897974f600af96e1d0 --- /dev/null +++ b/src/server/app/Service/BookmarkService.php @@ -0,0 +1,26 @@ +<?php +require_once __DIR__ . '/../Config/Database.php'; +require_once __DIR__ . '/../Exception/ValidationException.php'; +require_once __DIR__ . '/../Utils/UUIDGenerator.php'; + +require_once __DIR__ . '/../Domain/WatchlistSave.php'; + +require_once __DIR__ . '/../Repository/WatchlistSaveRepository.php'; + +require_once __DIR__ . '/../Model/bookmark/BookmarkGetRequest.php'; + +class BookmarkService +{ + private WatchlistSaveRepository $watchlistSaveRepository; + + public function __construct(WatchlistSaveRepository $watchlistSaveRepository) + { + $this->watchlistSaveRepository = $watchlistSaveRepository; + } + + public function findByUser(BookmarkGetRequest $request) + { + $result = $this->watchlistSaveRepository->findByUser($request->userId, $request->page, $request->pageSize); + return $result; + } +} \ No newline at end of file diff --git a/src/server/app/Service/CatalogService.php b/src/server/app/Service/CatalogService.php new file mode 100644 index 0000000000000000000000000000000000000000..f340805663344e6e679335c39b987cb5feb592ff --- /dev/null +++ b/src/server/app/Service/CatalogService.php @@ -0,0 +1,223 @@ +<?php + +require_once __DIR__ . '/../Repository/CatalogRepository.php'; +require_once __DIR__ . '/../Config/Database.php'; +require_once __DIR__ . '/../Utils/FileUploader.php'; +require_once __DIR__ . '/../Utils/UUIDGenerator.php'; + +require_once __DIR__ . '/../Model/CatalogCreateRequest.php'; +require_once __DIR__ . '/../Model/catalog/CatalogUpdateRequest.php'; +require_once __DIR__ . '/../Model/CatalogSearchRequest.php'; + +require_once __DIR__ . '/../Model/CatalogCreateResponse.php'; +require_once __DIR__ . '/../Model/CatalogSearchResponse.php'; + +class CatalogService +{ + private CatalogRepository $catalogRepository; + private FileUploader $posterUploader; + private FileUploader $trailerUploader; + + public function __construct(CatalogRepository $catalogRepository) + { + $this->catalogRepository = $catalogRepository; + $this->posterUploader = new FileUploader('Poster', 'assets/images/catalogs/posters/'); + $this->trailerUploader = new FileUploader('Trailer', 'assets/videos/catalogs/trailers/'); + + $this->trailerUploader->allowedExtTypes = ["mp4"]; + $this->trailerUploader->allowedMimeTypes = ["video/mp4"]; + $this->trailerUploader->maxFileSize = 100000000; + } + + public function findAll(int $page = 1, string $category = "MIXED"): array + { + $query = $this->catalogRepository->query(); + if ($category != "MIXED") { + $category = strtoupper(trim($category)); + $query = $query->whereEquals('category', $category); + } + $projection = ['id', 'uuid', 'title', 'category', 'description', 'poster']; + $catalogs = $query->get($projection, $page, 10); + return $catalogs; + } + + public function findByUUID(string $uuid): ?Catalog + { + $catalog = $this->catalogRepository->findOne('uuid', $uuid); + return $catalog; + } + + public function deleteByUUID(string $uuid): void + { + $catalog = $this->catalogRepository->findOne('uuid', $uuid); + if ($catalog) { + $this->catalogRepository->deleteBy('uuid', $uuid); + } else { + throw new ValidationException("Catalog not found."); + } + } + + public function deleteById(int $id): void + { + $this->catalogRepository->deleteBy('id', $id); + } + + public function create(CatalogCreateRequest $request): CatalogCreateResponse + { + $this->validateCatalogCreateRequest($request); + + try { + Database::beginTransaction(); + + $catalog = new Catalog(); + + $catalog->uuid = UUIDGenerator::uuid4(); + $catalog->title = trim($request->title); + $catalog->description = trim($request->description); + + $postername = $this->posterUploader->uploadFie($request->poster, $catalog->title); + if ($request->trailer && $request->trailer['error'] == UPLOAD_ERR_OK) { + $trailername = $this->trailerUploader->uploadFie($request->trailer, $catalog->title); + } + + $catalog->poster = $postername; + $catalog->trailer = $trailername ?? null; + $catalog->category = strtoupper(trim($request->category)); + + $this->catalogRepository->save($catalog); + + $response = new CatalogCreateResponse(); + $response->catalog = $catalog; + + Database::commitTransaction(); + return $response; + } catch (FileUploaderException $exception) { + Database::rollbackTransaction(); + throw new ValidationException($exception->getMessage()); + } catch (\Exception $exception) { + Database::rollbackTransaction(); + throw $exception; + } + } + + private function validateCatalogCreateRequest(CatalogCreateRequest $request) + { + if ( + $request->title == null || trim($request->title) == "" + ) { + throw new ValidationException("Title cannot be blank."); + } + + if (strlen($request->title) > 40) { + throw new ValidationException("Title cannot be more than 40 characters."); + } + + if (strlen($request->description) > 255) { + throw new ValidationException("Description cannot be more than 255 characters."); + } + + if ($request->category == null || trim($request->category) == "") { + throw new ValidationException("Category cannot be blank."); + } + + if ($request->category != "ANIME" && $request->category != "DRAMA") { + throw new ValidationException("Category must be either ANIME or DRAMA."); + } + + if ($request->poster == null || $request->poster['error'] != UPLOAD_ERR_OK) { + throw new ValidationException("Poster cannot be blank."); + } + } + + public function update(CatalogUpdateRequest $request) + { + $this->validateCatalogUpdateRequest($request); + + try { + Database::beginTransaction(); + + $catalog = $this->catalogRepository->findOne('uuid', $request->uuid); + + if (!$catalog) { + throw new ValidationException("Catalog not found."); + } + + $catalog->title = trim($request->title); + $catalog->description = trim($request->description); + + if ($request->poster && $request->poster['error'] == UPLOAD_ERR_OK) { + $postername = $this->posterUploader->uploadFie($request->poster, $catalog->title); + $catalog->poster = $postername; + } + + if ($request->trailer && $request->trailer['error'] == UPLOAD_ERR_OK) { + $trailername = $this->trailerUploader->uploadFie($request->trailer, $catalog->title); + $catalog->trailer = $trailername; + } + + if ($request->category != null && trim($request->category) != "") { + $catalog->category = strtoupper(trim($request->category)); + } + + $this->catalogRepository->update($catalog); + + Database::commitTransaction(); + } catch (FileUploaderException $exception) { + Database::rollbackTransaction(); + throw new ValidationException($exception->getMessage()); + } catch (\Exception $exception) { + Database::rollbackTransaction(); + throw $exception; + } + } + + private function validateCatalogUpdateRequest(CatalogUpdateRequest $request) + { + if ($request->uuid == null || trim($request->uuid) == "") { + throw new ValidationException("UUID cannot be blank."); + } + + if ( + $request->title == null || trim($request->title) == "" + ) { + throw new ValidationException("Title cannot be blank."); + } + + if (strlen($request->title) > 40) { + throw new ValidationException("Title cannot be more than 40 characters."); + } + + if (strlen($request->description) > 255) { + throw new ValidationException("Description cannot be more than 255 characters."); + } + + if ($request->category == null || trim($request->category) == "") { + throw new ValidationException("Category cannot be blank."); + } + + if ($request->category != "ANIME" && $request->category != "DRAMA") { + throw new ValidationException("Category must be either ANIME or DRAMA."); + } + } + + public function search(CatalogSearchRequest $catalogSearchRequest): CatalogSearchResponse + { + $this->validateCatalogSearchRequest($catalogSearchRequest); + + $query = $this->catalogRepository->query(); + $query = $query->whereContains('title', $catalogSearchRequest->title); + $catalogs = $query->get(["id", "uuid", "title", "poster", "category"], $catalogSearchRequest->page, $catalogSearchRequest->pageSize); + + $response = new CatalogSearchResponse(); + $response->catalogs = $catalogs; + + return $response; + } + + private function validateCatalogSearchRequest(CatalogSearchRequest $catalogSearchRequest): void + { + if (!isset($catalogSearchRequest->title)) { + throw new ValidationException("Search field is required"); + } + } +} \ No newline at end of file diff --git a/src/server/app/Service/SessionService.php b/src/server/app/Service/SessionService.php new file mode 100644 index 0000000000000000000000000000000000000000..cff1a119e380f874d4d8655b32be0a00597bba39 --- /dev/null +++ b/src/server/app/Service/SessionService.php @@ -0,0 +1,59 @@ +<?php + +require_once __DIR__ . '/../Utils/UUIDGenerator.php'; + +require_once __DIR__ . '/../Repository/SessionRepository.php'; +require_once __DIR__ . '/../Repository/UserRepository.php'; + +require_once __DIR__ . '/../Domain/Session.php'; +require_once __DIR__ . '/../Domain/User.php'; + +require_once __DIR__ . '/../Model/session/SessionCreateRequest.php'; + +class SessionService +{ + public static string $COOKIE_NAME = 'bogoshipo__ohimesama'; + private SessionRepository $sessionRepository; + private UserRepository $userRepository; + + public function __construct(SessionRepository $sessionRepository, UserRepository $userRepository) + { + $this->sessionRepository = $sessionRepository; + $this->userRepository = $userRepository; + } + + public function create(SessionCreateRequest $sessionCreateRequest): Session + { + $session = new Session(); + $session->id = UUIDGenerator::uuid4(); + $session->userId = $sessionCreateRequest->userId; + $session->expired = gmdate(DATE_RFC3339, strtotime("+1 week")); + + $this->sessionRepository->save($session); + + setcookie(self::$COOKIE_NAME, $session->id, time() + (60 * 60 * 24 * 7), "/", "", false, true); + + return $session; + } + + public function destroy() + { + $sessionId = $_COOKIE[self::$COOKIE_NAME] ?? ''; + $this->sessionRepository->deleteBy("id", $sessionId); + + setcookie(self::$COOKIE_NAME, '', 1, "/"); + } + + public function current(): ?User + { + $sessionId = $_COOKIE[self::$COOKIE_NAME] ?? ''; + $session = $this->sessionRepository->findOne("id", $sessionId); + + if ($session == null || $session->expired < gmdate(DATE_RFC3339)) { + $this->destroy(); + return null; + } + + return $this->userRepository->findOne("id", $session->userId); + } +} diff --git a/src/server/app/Service/TagService.php b/src/server/app/Service/TagService.php new file mode 100644 index 0000000000000000000000000000000000000000..7923586703f3b12646372e74bfde5db0017e2bde --- /dev/null +++ b/src/server/app/Service/TagService.php @@ -0,0 +1,19 @@ +<?php + +class TagService +{ + private TagRepository $tagRepository; + + public function __construct(TagRepository $tagRepository) + { + $this->tagRepository = $tagRepository; + } + + public function findAll() + { + $query = $this->tagRepository->query(); + $projection = ["id", "name"]; + $tags = $query->get($projection, 1, 100); + return $tags; + } +} \ No newline at end of file diff --git a/src/server/app/Service/UserService.php b/src/server/app/Service/UserService.php new file mode 100644 index 0000000000000000000000000000000000000000..f3f768014bead8cdcd6e7dcc393e3819b7939d40 --- /dev/null +++ b/src/server/app/Service/UserService.php @@ -0,0 +1,202 @@ +<?php + +require_once __DIR__ . '/../Config/Database.php'; +require_once __DIR__ . '/../Utils/UUIDGenerator.php'; + +require_once __DIR__ . '/../Repository/UserRepository.php'; + +require_once __DIR__ . '/../Model/UserSignUpRequest.php'; +require_once __DIR__ . '/../Model/UserSignUpResponse.php'; +require_once __DIR__ . '/../Model/UserSignInRequest.php'; +require_once __DIR__ . '/../Model/UserSignInResponse.php'; +require_once __DIR__ . '/../Model/UserEditRequest.php'; +require_once __DIR__ . '/../Model/UserEditResponse.php'; + + +class UserService +{ + private UserRepository $userRepository; + + public function __construct(UserRepository $userRepository) + { + $this->userRepository = $userRepository; + } + + public function signUp(UserSignUpRequest $request): UserSignUpResponse + { + $this->validateUserSignUpRequest($request); + + try { + Database::beginTransaction(); + + $user = $this->userRepository->findOne("email", $request->email); + if (isset($user)) { + throw new ValidationException("User already exist"); + } + + $request->email = trim($request->email); + $request->password = trim($request->password); + $request->confirmPassword = trim($request->confirmPassword); + + $user = new User(); + $user->uuid = UUIDGenerator::uuid4(); + $user->name = explode("@", $request->email)[0]; + $user->email = $request->email; + $user->password = password_hash($request->password, PASSWORD_BCRYPT); + + $this->userRepository->save($user); + + $response = new UserSignUpResponse(); + $response->user = $user; + + Database::commitTransaction(); + + return $response; + } catch (Exception $exception) { + Database::rollbackTransaction(); + throw $exception; + } + } + + public function signIn(UserSignInRequest $request): UserSignInResponse + { + $this->validateUserSignInRequest($request); + + $user = $this->userRepository->findOne("email", $request->email); + if ($user == null) { + throw new ValidationException("Invalid email or password."); + } + + if (password_verify($request->password, $user->password)) { + $response = new UserSignInResponse(); + $response->user = $user; + + return $response; + } else { + throw new ValidationException("Invalid email or password."); + } + } + + public function update(User $currentuser, UserEditRequest $request) + { + $this->validateEditProfileRequest($currentuser, $request); + try { + //code... + Database::beginTransaction(); + + if (!($request->name == null || trim($request->name == ""))) { + $currentuser->name = trim($request->name); + $this->userRepository->updateName($currentuser); + } + + if ( + !($request->oldPassword == null || trim($request->oldPassword) == "") + && !($request->newPassword == null || trim($request->newPassword) == "") + ) { + $currentuser->password = password_hash(trim($request->newPassword), PASSWORD_BCRYPT); + $this->userRepository->updatePassword($currentuser); + } + Database::commitTransaction(); + } catch (\Exception $exception) { + //throw $th; + Database::rollbackTransaction(); + throw $exception; + } + } + + private function validateUserSignUpRequest(UserSignUpRequest $request) + { + if ( + $request->email == null || + $request->password == null || + $request->confirmPassword == null || + trim($request->email) == "" || + trim($request->password) == "" || + trim($request->confirmPassword) == "" + ) { + throw new ValidationException("Email, password, and confirm password cannot be blank."); + } + + if ($request->password != $request->confirmPassword) { + throw new ValidationException("Make sure both passwords are typed the same."); + } + } + + private function validateUserSignInRequest(UserSignInRequest $request) + { + + if ( + $request->email == null || + $request->password == null || + trim($request->email) == "" || + trim($request->password) == "" + ) { + throw new ValidationException("Email and password cannot be blank."); + } + } + + public function validateEditProfileRequest(User $currentuser, UserEditRequest $request) + { + if ( + ($request->oldPassword == null || trim($request->oldPassword) == "") + && ($request->newPassword == null || trim($request->newPassword) == "") + && ($request->name == null || trim($request->name == "")) + ) { + throw new ValidationException("Data cannot be empty."); + } else if ( + !($request->name == null || trim($request->name == "")) + && !($request->oldPassword == null || trim($request->oldPassword) == "") + && ($request->newPassword == null || trim($request->newPassword) == "") + ) { + throw new ValidationException("New password cannot be blank."); + } else if ( + !($request->name == null || trim($request->name == "")) + && ($request->oldPassword == null || trim($request->oldPassword) == "") + && !($request->newPassword == null || trim($request->newPassword) == "") + ) { + throw new ValidationException("Old password cannot be blank."); + } else if ( + ($request->name == null || trim($request->name == "")) + && !($request->oldPassword == null || trim($request->oldPassword) == "") + && ($request->newPassword == null || trim($request->newPassword) == "") + ) { + throw new ValidationException("New password cannot be blank."); + } else if ( + ($request->name == null || trim($request->name == "")) + && ($request->oldPassword == null || trim($request->oldPassword) == "") + && !($request->newPassword == null || trim($request->newPassword) == "") + ) { + throw new ValidationException("Old password cannot be blank."); + } + + if ( + (!($request->oldPassword == null || trim($request->oldPassword) == "") + && !($request->newPassword == null || trim($request->newPassword) == "")) && + ($request->oldPassword == $request->newPassword) + ) { + throw new ValidationException("New password cannot be the same as old password."); + } + + if ( + !($request->oldPassword == null || trim($request->oldPassword) == "") && + !password_verify($request->oldPassword, $currentuser->password) + ) { + throw new ValidationException("Old password is incorrect."); + } + } + + public function findByEmail(string $email): User + { + return $this->userRepository->findOne('email', $email); + } + + public function deleteByEmail(string $email) + { + $this->userRepository->deleteBy('email', $email); + } + + public function deleteBySession(string $email) + { + $this->userRepository->deleteBySession($email); + } +} \ No newline at end of file diff --git a/src/server/app/Service/WatchlistService.php b/src/server/app/Service/WatchlistService.php new file mode 100644 index 0000000000000000000000000000000000000000..426fd5d40406b2fa507e078dfc327fd718ebcd13 --- /dev/null +++ b/src/server/app/Service/WatchlistService.php @@ -0,0 +1,329 @@ +<?php +require_once __DIR__ . '/../Config/Database.php'; +require_once __DIR__ . '/../Exception/ValidationException.php'; +require_once __DIR__ . '/../Utils/UUIDGenerator.php'; + +require_once __DIR__ . '/../Domain/Watchlist.php'; +require_once __DIR__ . '/../Domain/WatchlistItem.php'; +require_once __DIR__ . '/../Domain/WatchlistLike.php'; +require_once __DIR__ . '/../Domain/WatchlistTag.php'; + +require_once __DIR__ . '/../Repository/WatchlistRepository.php'; +require_once __DIR__ . '/../Repository/WatchlistItemRepository.php'; +require_once __DIR__ . '/../Repository/WatchlistLikeRepository.php'; +require_once __DIR__ . '/../Repository/WatchlistTagRepository.php'; + +require_once __DIR__ . '/../Model/WatchlistsGetRequest.php'; +require_once __DIR__ . '/../Model/WatchlistCreateRequest.php'; +require_once __DIR__ . '/../Model/watchlist/WatchlistGetOneByUserRequest.php'; +require_once __DIR__ . '/../Model/watchlist/WatchlistLikeRequest.php'; +require_once __DIR__ . '/../Model/watchlist/WatchlistSaveRequest.php'; +require_once __DIR__ . '/../Model/watchlist/WatchlistGetOneRequest.php'; +require_once __DIR__ . '/../Model/watchlist/WatchlistEditRequest.php'; +require_once __DIR__ . '/../Model/watchlist/WatchlistDeleteRequest.php'; + +class WatchlistService +{ + private WatchlistRepository $watchlistRepository; + private WatchlistItemRepository $watchlistItemRepository; + private WatchlistLikeRepository $watchlistLikeRepository; + private WatchlistSaveRepository $watchlistSaveRepository; + private WatchlistTagRepository $watchlistTagRepository; + + public function __construct(WatchlistRepository $watchlistRepository, WatchlistItemRepository $watchlistItemRepository, WatchlistLikeRepository $watchlistLikeRepository, WatchlistSaveRepository $watchlistSaveRepository, WatchlistTagRepository $watchlistTagRepository) + { + $this->watchlistRepository = $watchlistRepository; + $this->watchlistItemRepository = $watchlistItemRepository; + $this->watchlistLikeRepository = $watchlistLikeRepository; + $this->watchlistSaveRepository = $watchlistSaveRepository; + $this->watchlistTagRepository = $watchlistTagRepository; + } + + + public function create(WatchlistCreateRequest $watchlistCreateRequest) + { + $this->validateWatchlistCreateEditRequest($watchlistCreateRequest); + + try { + Database::beginTransaction(); + + // Create watchlist + $watchlist = new Watchlist(); + $watchlist->uuid = UUIDGenerator::uuid4(); + $watchlist->title = $watchlistCreateRequest->title; + $watchlist->description = $watchlistCreateRequest->description; + $watchlist->visibility = $watchlistCreateRequest->visibility; + $watchlist->category = "DRAMA"; + $watchlist->userId = $watchlistCreateRequest->userId; + + // check watchlist category by travers through items + $cntDrama = 0; + $cntAnime = 0; + foreach ($watchlistCreateRequest->items as $item) { + if ($item["category"] == "ANIME") + $cntAnime++; + if ($item["category"] == "DRAMA") + $cntDrama++; + } + + if ($cntDrama != 0 && $cntAnime != 0) + $watchlist->category = "MIXED"; + else if ($cntAnime != 0) + $watchlist->category = "ANIME"; + + $watchlistNew = $this->watchlistRepository->save($watchlist); + + // save the items + $currRank = 1; + foreach ($watchlistCreateRequest->items as $item) { + $watchlistItem = new WatchlistItem(); + $watchlistItem->uuid = UUIDGenerator::uuid4(); + $watchlistItem->rank = $currRank; + $watchlistItem->description = $item["description"]; + $watchlistItem->watchlistId = $watchlistNew->id; + $watchlistItem->catalogId = $item["id"]; + + $this->watchlistItemRepository->save($watchlistItem); + + $currRank++; + } + + // save the tags + foreach ($watchlistCreateRequest->tags as $tag) { + $watchlistTag = new WatchlistTag(); + $watchlistTag->tagId = $tag["id"]; + $watchlistTag->watchlistId = $watchlist->id; + + $this->watchlistTagRepository->save($watchlistTag); + } + + Database::commitTransaction(); + } catch (\Exception $exception) { + Database::rollbackTransaction(); + throw $exception; + } + } + + public function edit(WatchlistEditRequest $watchlistEditRequest) + { + $this->validateWatchlistCreateEditRequest($watchlistEditRequest); + + try { + Database::beginTransaction(); + + // Create watchlist + $watchlist = new Watchlist(); + $watchlist->id = $watchlistEditRequest->watchlist["watchlist_id"]; + $watchlist->uuid = $watchlistEditRequest->watchlist["watchlist_uuid"]; + $watchlist->title = $watchlistEditRequest->title; + $watchlist->description = $watchlistEditRequest->description; + $watchlist->visibility = $watchlistEditRequest->visibility; + $watchlist->category = "DRAMA"; + $watchlist->userId = $watchlistEditRequest->userId; + + // check watchlist category by travers through items + $cntDrama = 0; + $cntAnime = 0; + foreach ($watchlistEditRequest->items as $item) { + if ($item["category"] == "ANIME") + $cntAnime++; + if ($item["category"] == "DRAMA") + $cntDrama++; + } + + if ($cntDrama != 0 && $cntAnime != 0) + $watchlist->category = "MIXED"; + else if ($cntAnime != 0) + $watchlist->category = "ANIME"; + + $watchlistNew = $this->watchlistRepository->update($watchlist); + + // delete all items with corresponding watchlistId + $this->watchlistItemRepository->deleteBy("watchlist_id", $watchlistNew->id); + + // save the items + $currRank = 1; + foreach ($watchlistEditRequest->items as $item) { + $watchlist_item = new WatchlistItem(); + $watchlist_item->uuid = UUIDGenerator::uuid4(); + $watchlist_item->rank = $currRank; + $watchlist_item->description = $item["description"]; + $watchlist_item->watchlistId = $watchlistNew->id; + $watchlist_item->catalogId = $item["id"]; + + $this->watchlistItemRepository->save($watchlist_item); + + $currRank++; + } + + // delete all tags with corresponding watchlistId + $this->watchlistTagRepository->deleteBy("watchlist_id", $watchlistNew->id); + + // save the tags + foreach ($watchlistEditRequest->tags as $tag) { + $watchlistTag = new WatchlistTag(); + $watchlistTag->tagId = $tag["id"]; + $watchlistTag->watchlistId = $watchlist->id; + + $this->watchlistTagRepository->save($watchlistTag); + } + + Database::commitTransaction(); + } catch (\Exception $exception) { + Database::rollbackTransaction(); + throw $exception; + } + } + + public function findAll(WatchlistsGetRequest $watchlistsGetRequest) + { + if (!in_array(strtoupper(trim($watchlistsGetRequest->category)), ["", "MIXED", "ANIME", "DRAMA"])) { + $watchlistsGetRequest->category = ""; + } + if (!isset($watchlistsGetRequest->order) || !in_array(strtoupper(trim($watchlistsGetRequest->order)), ["ASC", "DESC"])) { + $watchlistsGetRequest->order = "DESC"; + } + if (!isset($watchlistsGetRequest->sortBy) || !in_array(strtoupper(trim($watchlistsGetRequest->sortBy)), ["DATE", "LOVE"])) { + $watchlistsGetRequest->sortBy = "LOVE"; + } + if (!isset($watchlistsGetRequest->tag) || !in_array(strtoupper(trim($watchlistsGetRequest->tag)), $watchlistsGetRequest->tagsInit)) { + $watchlistsGetRequest->tag = ""; + } + + if ($watchlistsGetRequest->sortBy == "LOVE") + $watchlistsGetRequest->sortBy = "love_count"; + if ($watchlistsGetRequest->sortBy == "DATE") + $watchlistsGetRequest->sortBy = "w.created_at"; + + $result = $this->watchlistRepository->findAllCustom($watchlistsGetRequest->userId, $watchlistsGetRequest->search, $watchlistsGetRequest->category, $watchlistsGetRequest->sortBy, $watchlistsGetRequest->order, $watchlistsGetRequest->tag, $watchlistsGetRequest->page, 10); + return $result; + } + + public function findByUser(WatchlistGetOneByUserRequest $request) + { + if (!isset($request->visibility) || !in_array(strtoupper(trim($request->visibility)), ["ALL", "PUBLIC", "PRIVATE"]) || strtoupper($request->visibility) == "ALL") { + $request->visibility = ""; + } + + $result = $this->watchlistRepository->findByUser($request->userId, strtoupper($request->visibility), 1, 10); + return $result; + } + + public function findByUUID(WatchlistsGetOneRequest $request) + { + $result = $this->watchlistRepository->findByUUID($request->uuid, $request->userId, $request->page, $request->pageSize); + return $result; + } + + + public function like(WatchlistLikeRequest $watchlistLikeRequest): void + { + $this->validateWatchlistLikeAndSaveRequest($watchlistLikeRequest); + + try { + Database::beginTransaction(); + + // 1. Get watchlist by UUID + $watchlist = $this->watchlistRepository->findOne("uuid", $watchlistLikeRequest->watchlistUUID, ["id"]); + if ($watchlist == null) { + throw new ValidationException("Watchlist not found."); + } + + // 2. Insert or delete a row from watchlist_like table + $watchlistLike = $this->watchlistLikeRepository->findOneByWatchlistAndUser($watchlist->id, $watchlistLikeRequest->userId); + if ($watchlistLike == null) { + $this->watchlistLikeRepository->saveByWatchlistAndUser($watchlist->id, $watchlistLikeRequest->userId); + } else { + $this->watchlistLikeRepository->deleteByWatchlistAndUser($watchlist->id, $watchlistLikeRequest->userId); + } + + Database::commitTransaction(); + } catch (\Exception $exception) { + Database::rollbackTransaction(); + throw $exception; + } + } + + public function bookmark(WatchlistSaveRequest $watchlistSaveRequest): void + { + $this->validateWatchlistLikeAndSaveRequest($watchlistSaveRequest); + + try { + Database::beginTransaction(); + + // 1. Get watchlist by UUID + $watchlist = $this->watchlistRepository->findOne("uuid", $watchlistSaveRequest->watchlistUUID, ["id"]); + if ($watchlist == null) { + throw new ValidationException("Watchlist not found."); + } + + // 2. Insert or delete a row from watchlist_save table + $watchlistLike = $this->watchlistSaveRepository->findOneByWatchlistAndUser($watchlist->id, $watchlistSaveRequest->userId); + if ($watchlistLike == null) { + $this->watchlistSaveRepository->saveByWatchlistAndUser($watchlist->id, $watchlistSaveRequest->userId); + } else { + $this->watchlistSaveRepository->deleteByWatchlistAndUser($watchlist->id, $watchlistSaveRequest->userId); + } + + Database::commitTransaction(); + } catch (\Exception $exception) { + Database::rollbackTransaction(); + throw $exception; + } + } + + public function deleteByUUID(WatchlistDeleteRequest $watchlistDeleteRequest) + { + $this->watchlistRepository->deleteBy("uuid", $watchlistDeleteRequest->watchlistUUID); + } + + private function validateWatchlistCreateEditRequest(WatchlistCreateRequest|WatchlistEditRequest $watchlistCreateUpdateRequest) + { + if (!isset($watchlistCreateUpdateRequest->title) || trim($watchlistCreateUpdateRequest->title) == "") { + throw new ValidationException("Title is required."); + } + if (strlen($watchlistCreateUpdateRequest->title) > 40) { + throw new ValidationException("Title is too long. Maximum 40 chars."); + } + if (!isset($watchlistCreateUpdateRequest->visibility) || !in_array($watchlistCreateUpdateRequest->visibility, ["PUBLIC", "PRIVATE"])) { + throw new ValidationException("Visibility is invalid."); + } + if (isset($watchlistCreateUpdateRequest->description) && strlen($watchlistCreateUpdateRequest->description) > 255) { + throw new ValidationException("Description is too long. Maximum 255 characters."); + } + if (!isset($watchlistCreateUpdateRequest->items) || count($watchlistCreateUpdateRequest->items) == 0) { + throw new ValidationException("Watchlist must contain 1 item."); + } + if (count($watchlistCreateUpdateRequest->items) > 50) { + throw new ValidationException("Too many items. Maximum 50 items."); + } + + foreach ($watchlistCreateUpdateRequest->items ?? [] as $item) { + if (strlen($item["description"]) > 255) { + throw new ValidationException("Description is too long for item ${item["title"]}. Maximum 255 chars."); + } + } + + $selectedTags = []; + foreach ($watchlistCreateUpdateRequest->tags ?? [] as $tag) { + $found = false; + foreach ($watchlistCreateUpdateRequest->initialTags ?? [] as $initTag) { + if ($tag["id"] == $initTag->id && !in_array($initTag->id, $selectedTags)) { + array_push($selectedTags, $initTag->id); + $found = true; + break; + } + } + if (!$found) { + throw new ValidationException("Tags is invalid."); + } + } + } + + private function validateWatchlistLikeAndSaveRequest(WatchlistLikeRequest|WatchlistSaveRequest $watchlistRequest) + { + if (!isset($watchlistRequest->watchlistUUID) || trim($watchlistRequest->watchlistUUID) == "") { + throw new ValidationException("Watchlist UUID is required"); + } + } +} \ No newline at end of file diff --git a/src/server/app/Utils/EnvLoader.php b/src/server/app/Utils/EnvLoader.php new file mode 100644 index 0000000000000000000000000000000000000000..e5dbbb81ebe09b738b8deef75e583bb8931d1d1c --- /dev/null +++ b/src/server/app/Utils/EnvLoader.php @@ -0,0 +1,38 @@ +<?php +class EnvLoader +{ + protected $path; + + public function __construct(string $path) + { + if (!file_exists($path)) { + throw new \InvalidArgumentException(sprintf('%s does not exist', $path)); + } + $this->path = $path; + } + + public function load(): void + { + if (!is_readable($this->path)) { + throw new \RuntimeException(sprintf('%s file is not readable', $this->path)); + } + + $lines = file($this->path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + foreach ($lines as $line) { + + if (strpos(trim($line), '#') === 0) { + continue; + } + + list($name, $value) = explode('=', $line, 2); + $name = trim($name); + $value = trim($value); + + if (!array_key_exists($name, $_SERVER) && !array_key_exists($name, $_ENV)) { + putenv(sprintf('%s=%s', $name, $value)); + $_ENV[$name] = $value; + $_SERVER[$name] = $value; + } + } + } +} diff --git a/src/server/app/Utils/FileUploader.php b/src/server/app/Utils/FileUploader.php new file mode 100644 index 0000000000000000000000000000000000000000..a16044c730cb3dead72bce52ffbebe47a814fcb9 --- /dev/null +++ b/src/server/app/Utils/FileUploader.php @@ -0,0 +1,76 @@ +<?php + +require_once __DIR__ . '/../Exception/FileUploaderException.php'; + +class FileUploader +{ + public string $id; + public int $maxFilenameSize = 255; + public int $maxFileSize = 200000; + public string $targetDir = '.'; + public array $allowedExtTypes = ["jpg", "jpeg", "png", "webp"]; + public array $allowedMimeTypes = ["image/jpeg", "image/png", "image/webp"]; + + public function __construct($id, $targetDir) + { + $this->id = $id; + $this->targetDir = $targetDir; + } + public function uploadFie($file, $filename) + { + if ($file == null) { + throw new FileUploaderException($this->id . " File is required."); + } + + $this->validateFile($file); + $isImage = in_array("image/jpeg", $this->allowedMimeTypes) || in_array("image/png", $this->allowedMimeTypes); + if ($isImage) { + $fileext = "webp"; + } else { + $fileext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + } + $filename = UUIDGenerator::uuid4() . '.' . $fileext; + $targetFile = $this->targetDir . $filename; + + if (file_exists($targetFile)) { + throw new FileUploaderException($this->id . " File already exists."); + } + + + if (move_uploaded_file($file["tmp_name"], $targetFile)) { + return $filename; + } + throw new FileUploaderException($this->id . " File is not a valid upload file."); + } + + private function validateFile($file) + { + $this->checkFileUploadedExt($file); + $this->checkFileUploadedSize($file); + $this->checkFileUploadedMimeType($file); + } + + private function checkFileUploadedExt($file) + { + $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); + if (!in_array($ext, $this->allowedExtTypes)) { + throw new FileUploaderException($this->id . " File does not have a valid extension. Only allowed " . implode(", ", $this->allowedExtTypes) . "."); + } + } + + private function checkFileUploadedSize($file) + { + if ($file["size"] > $this->maxFileSize) { + throw new FileUploaderException($this->id . " File is too big. Maximum " . $this->maxFileSize . " bytes."); + } + } + + private function checkFileUploadedMimeType($file) + { + $mimeType = $file['type']; + + if (!in_array($mimeType, $this->allowedMimeTypes)) { + throw new FileUploaderException($this->id . " File does not have a valid mime type."); + } + } +} \ No newline at end of file diff --git a/src/server/app/Utils/FilterBuilder.php b/src/server/app/Utils/FilterBuilder.php new file mode 100644 index 0000000000000000000000000000000000000000..cd226e72b8023aaddbe19f5185cb5e9fdb08d5e8 --- /dev/null +++ b/src/server/app/Utils/FilterBuilder.php @@ -0,0 +1,46 @@ +<?php + +class FilterBuilder +{ + public string $filterQuery; + public function __construct() + { + $this->filterQuery = ""; + } + + public function whereEquals(string $key, $value): FilterBuilder + { + $this->filterQuery .= " WHERE $key = '$value'"; + return $this; + } + + public function whereContains(string $key, $value): FilterBuilder + { + $this->filterQuery .= " WHERE $key ILIKE '%$value%'"; + return $this; + } + + public function andWhereEquals(string $key, $value): FilterBuilder + { + $this->filterQuery .= " AND "; + return $this->whereEquals($key, $value); + } + + public function andWhereContains(string $key, $value): FilterBuilder + { + $this->filterQuery .= " AND "; + return $this->whereContains($key, $value); + } + + public function orWhereEquals(string $key, $value): FilterBuilder + { + $this->filterQuery .= " OR "; + return $this->whereEquals($key, $value); + } + + public function orWhereContains(string $key, $value): FilterBuilder + { + $this->filterQuery .= " OR "; + return $this->whereContains($key, $value); + } +} \ No newline at end of file diff --git a/src/server/app/Utils/QueryBuilder.php b/src/server/app/Utils/QueryBuilder.php new file mode 100644 index 0000000000000000000000000000000000000000..c145b32ba1e489a7caef1a3261be2c3417e5493d --- /dev/null +++ b/src/server/app/Utils/QueryBuilder.php @@ -0,0 +1,71 @@ +<?php + +require_once __DIR__ . '/../App/Repository.php'; + + +class QueryBuilder +{ + public string $query; + protected Repository $repository; + + public function __construct(Repository $repository) + { + $this->query = ""; + $this->repository = $repository; + } + + public function whereEquals(string $key, $value): QueryBuilder + { + if (is_int($value)) + $this->query .= " WHERE $key = $value"; + else if (is_string($value)) + $this->query .= " WHERE $key = '$value'"; + return $this; + } + + public function whereContains(string $key, $value): QueryBuilder + { + $this->query .= " WHERE $key ILIKE '%$value%'"; + return $this; + } + + public function andWhereEquals(string $key, $value): QueryBuilder + { + $this->query .= " AND "; + return $this->whereEquals($key, $value); + } + + public function andWhereContains(string $key, $value): QueryBuilder + { + $this->query .= " AND "; + return $this->whereContains($key, $value); + } + + public function orWhereEquals(string $key, $value): QueryBuilder + { + $this->query .= " OR "; + return $this->whereEquals($key, $value); + } + + public function orWhereContains(string $key, $value): QueryBuilder + { + $this->query .= " OR "; + return $this->whereContains($key, $value); + } + + public function join(string $foreignKey, string $table, string $key): QueryBuilder + { + $this->query .= " JOIN $table ON " . $this->repository->getTable() . ".$foreignKey = $table.$key"; + return $this; + } + + public function get( + array $projection = [], + int $page = null, + int $pageSize = null + ) + { + return $this->repository->findAll($projection, $page, $pageSize); + } + +} \ No newline at end of file diff --git a/src/server/app/Utils/UUIDGenerator.php b/src/server/app/Utils/UUIDGenerator.php new file mode 100644 index 0000000000000000000000000000000000000000..43f8559874d53f2d35b43558f7e3bce4d68689e5 --- /dev/null +++ b/src/server/app/Utils/UUIDGenerator.php @@ -0,0 +1,14 @@ +<?php + +class UUIDGenerator +{ + public static function uuid(int $half = 16) + { + return bin2hex(random_bytes($half)); + } + + public static function uuid4(): string + { + return vsprintf('%s%s%s%s', str_split(self::uuid(8), 4)); + } +} \ No newline at end of file diff --git a/src/server/app/View/404.php b/src/server/app/View/404.php new file mode 100644 index 0000000000000000000000000000000000000000..bb604b98b3c1aa70e96b4ffd62a163814ed374b7 --- /dev/null +++ b/src/server/app/View/404.php @@ -0,0 +1,10 @@ +<main class="error-page-container"> + <div> + <h1>404</h1> + <div> + <h2>There's <span>NOTHING</span> here...</h2> + <p>...maybe the page you're looking for is not found or never existed</p> + </div> + <a href="/" class="btn btn-bold">Back to Home</a> + </div> +</main> \ No newline at end of file diff --git a/src/server/app/View/500.php b/src/server/app/View/500.php new file mode 100644 index 0000000000000000000000000000000000000000..2de360de2645c69868ab342da52035f357a6a3e6 --- /dev/null +++ b/src/server/app/View/500.php @@ -0,0 +1,13 @@ +<main class="error-page-container"> + <div> + <h1>500</h1> + <div> + <h2>Sorry, It's not you. It's us.</h2> + <p>We're expecting an internal server problem. + <br> + Please try again later. + </p> + </div> + <a href="/" class="btn btn-bold">Back to Home</a> + </div> +</main> \ No newline at end of file diff --git a/src/server/app/View/catalog/detail.php b/src/server/app/View/catalog/detail.php new file mode 100644 index 0000000000000000000000000000000000000000..181a5cba49a09829da9b10b8b3f0fbbf5e680155 --- /dev/null +++ b/src/server/app/View/catalog/detail.php @@ -0,0 +1,42 @@ +<?php +$catalog = $model['data']['item']; +$userRole = $model['data']['userRole']; +?> + +<main> + <div class="catalog-detail-header"> + <div class="catalog-detail-header-poster"></div> + <img class="poster" src="<?= '/assets/images/catalogs/posters/' . $catalog['poster'] ?>" + alt="<?= 'Poster of ' . $catalog['title'] ?>"> + </div> + <article class="catalog-detail-content"> + <h2> + <?= $catalog['title'] ?> + </h2> + <div class="tag"> + <?= $catalog['category'] ?> + </div> + <p> + <?= (!isset($catalog['description']) || empty($catalog['description'])) ? "No description" : $catalog['description'] ?> + </p> + <?php if (isset($catalog['trailer']) && $catalog['trailer'] !== null): ?> + <h3>Trailer</h3> + <video class="catalog-trailer" controls> + <source src="<?= '/assets/videos/catalogs/trailers/' . $catalog['trailer'] ?>" type="video/mp4"> + </video> + <?php endif; ?> + </article> + <?php if ($userRole && $userRole === "ADMIN"): ?> + <div class="button-container"> + <a href="/catalog/<?= $catalog['uuid'] ?>/edit" id="edit" aria-label="Edit <?= $catalog['title'] ?>" + class="btn-icon"> + <?php require PUBLIC_PATH . 'assets/icons/edit.php' ?> + </a> + <button id="delete-trigger" type="submit" data-uuid="<?= $catalog['uuid'] ?>" + data-title="<?= $catalog['title'] ?>" aria-label="Delete <?= $catalog['title'] ?>" + class="catalog-delete-trigger dialog-trigger btn-icon"> + <?php require PUBLIC_PATH . 'assets/icons/trash.php' ?> + </button> + </div> + <?php endif; ?> +</main> \ No newline at end of file diff --git a/src/server/app/View/catalog/edit.php b/src/server/app/View/catalog/edit.php new file mode 100644 index 0000000000000000000000000000000000000000..5525bd2a388515ec66156535ae217a649f37738b --- /dev/null +++ b/src/server/app/View/catalog/edit.php @@ -0,0 +1,52 @@ +<?php function selectCategory($selected) +{ + $id = 'category'; + $placeholder = 'Select Category'; + $content = [ + "DRAMA", + "ANIME" + ]; + require __DIR__ . '/../components/select.php'; +} +?> + +<main> + <h2> + <?= $model['title'] ?> + </h2> + <form id="catalog-edit-form" enctype="multipart/form-data"> + <div class="input-group"> + <label class="input-required">Category</label> + <?php selectCategory($model['data']['category'] ?? 'ANIME'); ?> + </div> + <div class="input-group"> + <label for="titleField" class="input-required">Title</label> + <input type="text" id="titleField" name="title" placeholder="Title" + value="<?= $model['data']['title'] ?? "" ?>" maxlength="40" required> + </div> + <div class="input-group"> + <label for="descriptionField">Description</label> + <textarea placeholder="Enter description" name="description" id="descriptionField" maxlength="255"><?php if (isset($model['data'])) { + echo $model['data']['description']; + } ?></textarea> + </div> + <div class="input-group"> + <label for="posterField" class="input-required">Poster</label> + <img class="poster" src="<?= '/assets/images/catalogs/posters/' . $model['data']['poster'] ?>" + alt="<?= 'Poster of ' . $model['data']['title'] ?>"> + <input type="file" id="posterField" name="poster" accept="image/*"> + </div> + <div class="input-group"> + <?php if (isset($model['data']['trailer']) && $model['data']['trailer'] !== null): ?> + <video class="catalog-trailer" controls> + <source src="<?= '/assets/videos/catalogs/trailers/' . $model['data']['trailer'] ?>" type="video/mp4"> + </video> + <?php endif; ?> + <label for="trailerField">Trailer</label> + <input type="file" id="trailerField" name="trailer" accept="video/mp4"> + </div> + <button id="edit" class="btn-bold" type="submit"> + Edit + </button> + </form> +</main> \ No newline at end of file diff --git a/src/server/app/View/catalog/form.php b/src/server/app/View/catalog/form.php new file mode 100644 index 0000000000000000000000000000000000000000..830b7ea5908b78c98b8aa21f80985f38b3c29c31 --- /dev/null +++ b/src/server/app/View/catalog/form.php @@ -0,0 +1,66 @@ +<?php function selectCategory($selected) +{ + $id = 'category'; + $placeholder = 'Select Category'; + $content = [ + "DRAMA", + "ANIME" + ]; + require __DIR__ . '/../components/select.php'; +} + +function alert($title, $message) +{ + $type = 'error'; + require __DIR__ . '/../components/alert.php'; +} + +?> + +<main> + <h2> + <?= $model['title'] ?> + </h2> + <form id="catalog-create-update"> + <div class="input-group"> + <label class="input-required">Category</label> + <?php selectCategory($model['data']['category'] ?? 'ANIME'); ?> + </div> + + <div class="input-group"> + <label for="titleField" class="input-required">Title</label> + <input type="text" id="titleField" name="title" placeholder="Title" + value="<?= $model['data']['title'] ?? "" ?>" maxlength="40" required> + </div> + + <div class="input-group"> + <label for="descriptionField">Description</label> + <textarea placeholder="Enter description" name="description" id="descriptionField" maxlength="255"><?php if (isset($model['data'])) { + echo $model['data']['description']; + } ?></textarea> + </div> + + <div class="input-group"> + <label for="posterField" class="input-required">Poster</label> + <?php if (isset($model['data']['poster'])): ?> + <img id="poster" class="poster" src="<?= '/assets/images/catalogs/posters/' . $model['data']['poster'] ?>" + alt="<?= 'Poster of ' . $model['data']['title'] ?>"> + <input type="file" id="posterField" name="poster" accept="image/*"> + <?php else: ?> + <input type="file" id="posterField" name="poster" accept="image/*" required> + <?php endif; ?> + </div> + <div class="input-group"> + <?php if (isset($model['data']['trailer']) && $model['data']['trailer'] !== null): ?> + <video id="trailer" class="catalog-trailer" controls> + <source src="<?= '/assets/videos/catalogs/trailers/' . $model['data']['trailer'] ?>" type="video/mp4"> + </video> + <?php endif; ?> + <label for="trailerField">Trailer</label> + <input type="file" id="trailerField" name="trailer" accept="video/mp4"> + </div> + <button id="save" class="btn-bold" type="submit"> + Save + </button> + </form> +</main> \ No newline at end of file diff --git a/src/server/app/View/catalog/index.php b/src/server/app/View/catalog/index.php new file mode 100644 index 0000000000000000000000000000000000000000..a6df824829344d67024db762795792956b11baeb --- /dev/null +++ b/src/server/app/View/catalog/index.php @@ -0,0 +1,66 @@ +<?php +function selectCategory($selected) +{ + $id = 'category'; + $placeholder = 'MIXED'; + $content = [ + "MIXED", + "DRAMA", + "ANIME" + ]; + require __DIR__ . '/../components/select.php'; +} + +function catalogCard(Catalog $catalog, bool $isAdmin = false) +{ + $title = $catalog->title; + $poster = $catalog->poster; + $category = $catalog->category; + $description = $catalog->description; + $uuid = $catalog->uuid; + $id = $catalog->id; + $editable = $isAdmin; + require __DIR__ . '/../components/card/catalogCard.php'; +} + +function pagination(int $currentPage, int $totalPage) +{ + require __DIR__ . '/../components/pagination.php'; +} +?> +<main> + <section class="search-filter"> + <form action="/catalog"> + <div class="input"> + <label>Category</label> + <?php selectCategory($model['data']['category'] ?? ""); ?> + </div> + <button class="btn-primary" type="submit"> + Apply + </button> + </form> + <?php if ($model['data']['userRole'] && $model['data']['userRole'] === "ADMIN"): ?> + <a href="/catalog/create" class="btn btn-bold"> + <span class="icon-new"> + <?php require PUBLIC_PATH . 'assets/icons/plus.php' ?> + </span> + Add Catalog + </a> + <?php endif; ?> + </section> + <?php if (count($model['data']['catalogs']['items']) == 0): ?> + <div class="no-item__container"> + <h1>Oops! 😣</h1> + <div> + <h2>There's No Catalog Yet...</h2> + <p>...we will add more soon!</p> + </div> + </div> + <?php endif; ?> + <section class="content"> + <?php foreach ($model['data']['catalogs']['items'] ?? [] as $catalog): ?> + <?php catalogCard($catalog, $model['data']['userRole'] && $model['data']['userRole'] === "ADMIN"); ?> + <?php endforeach; ?> + <?php pagination($model['data']['catalogs']['page'], $model['data']['catalogs']['totalPage']); ?> + </section> +</main> \ No newline at end of file diff --git a/src/server/app/View/components/alert.php b/src/server/app/View/components/alert.php new file mode 100644 index 0000000000000000000000000000000000000000..4cc4bdb6064465c40e9e8e166bb364dbe6f02cb0 --- /dev/null +++ b/src/server/app/View/components/alert.php @@ -0,0 +1,14 @@ +<div class="alert" data-type="<?= $type ?>"> + <?php require PUBLIC_PATH . "assets/icons/" . $type . ".php" ?> + <div> + <h3> + <?= $title ?> + </h3> + <p> + <?= $message ?> + </p> + </div> + <button id="close" class="btn-ghost"> + <?php require PUBLIC_PATH . "assets/icons/cancel.php" ?> + </button> +</div> \ No newline at end of file diff --git a/src/server/app/View/components/card/catalogCard.php b/src/server/app/View/components/card/catalogCard.php new file mode 100644 index 0000000000000000000000000000000000000000..7fd441154cc70666d2fac417cfbf7fbf08649446 --- /dev/null +++ b/src/server/app/View/components/card/catalogCard.php @@ -0,0 +1,39 @@ +<?php +if (!file_exists('assets/images/catalogs/posters/' . $poster)) { + $poster = 'no-poster.webp'; +} +?> + +<div id="card-<?= $uuid ?>" class="card card-catalog"> + <div class="card-content"> + <a href="/catalog/<?= $uuid ?>"> + <img width="86.4" height="128" onerror="this.src = '/assets/images/catalogs/posters/no-poster.webp'" + src="<?= "/assets/images/catalogs/posters/" . $poster ?>" alt=<?= $title ?> class="poster" + alt="<?= $title ?>" /> + </a> + <div class="card-body"> + <a href="/catalog/<?= $uuid ?>"> + <h3 class="card-title"> + <?= $title ?> + </h3> + </a> + <div class="tag"> + <?= $category ?> + </div> + <p> + <?= $description ?> + </p> + </div> + </div> + <?php if (isset($editable) && $editable): ?> + <div class="card-button-container"> + <a aria-label="Edit <?= $title ?>" href="/catalog/<?= $uuid ?>/edit" id="edit-<?= $uuid ?>" class="btn-icon"> + <?php require PUBLIC_PATH . 'assets/icons/edit.php' ?> + </a> + <button aria-label="Delete <?= $title ?>" id="delete-trigger-<?= $uuid ?>" data-uuid="<?= $uuid ?>" + data-title="<?= $title ?>" class="catalog-delete-trigger dialog-trigger btn-icon"> + <?php require PUBLIC_PATH . 'assets/icons/trash.php' ?> + </button> + </div> + <?php endif; ?> +</div> \ No newline at end of file diff --git a/src/server/app/View/components/card/commentCard.php b/src/server/app/View/components/card/commentCard.php new file mode 100644 index 0000000000000000000000000000000000000000..681789aa46222f0e7d78bb461e11d04bbe5c431e --- /dev/null +++ b/src/server/app/View/components/card/commentCard.php @@ -0,0 +1,26 @@ +<div class="card card-comment"> + <div class="card-content"> + <img src=<?= "/assets/images/catalogs/posters/" . $user_image ?> alt=<?= $user_name . ' profile image' ?> + class="avatar" /> + <div class="card-body"> + <div class="header"> + <h4> + <?= $user_name ?> + </h4> + <p class="subtitle"> + <?= $created_at ?> + </p> + </div> + <p> + <?= $content ?> + </p> + </div> + </div> + <?php if ($is_user): ?> + <div class="card-button-container"> + <button class="btn-icon"> + <?php require PUBLIC_PATH . 'assets/icons/trash.php' ?> + </button> + </div> + <?php endif; ?> +</div> \ No newline at end of file diff --git a/src/server/app/View/components/card/watchlistCard.php b/src/server/app/View/components/card/watchlistCard.php new file mode 100644 index 0000000000000000000000000000000000000000..2d9e73b7fe38932ca9c3c9648643509b774cfb71 --- /dev/null +++ b/src/server/app/View/components/card/watchlistCard.php @@ -0,0 +1,127 @@ +<?php +if (!function_exists("formatDate")) { + function formatDate($createdAt) + { + require __DIR__ . '/../../../../config/dateFormat.php'; + } +} + +if (!function_exists("likeAndSave")) { + + function likeAndSave($class, $icon, $ariaLabel = "") + { + $triggerClasses = "btn-ghost $class"; + $triggerText = ""; + $triggerIcon = $icon; + $title = "Sign In Required"; + $content = 'signInRequired'; + require __DIR__ . '/../modal.php'; + } +} +?> + +<div id="watchlist-card-<?= $uuid ?>" class="card watchlist__card"> + <div class="card-content"> + <div class="list__poster"> + <?php for ($i = 0; $i < 4; $i++): ?> + <?php + if (!isset($posters[3 - $i])): ?> + <div> + <img loading=<?= $loading ?? 'eager' ?> + src="<?= "/assets/images/catalogs/posters/" . (isset($posters[3 - $i]) ? $posters[3 - $i]["poster"] : "no-poster.webp") ?>" + alt="Anime or Drama Poster" class="poster" /> + </div> + <?php else: ?> + <?php + if (!file_exists('assets/images/catalogs/posters/' . $posters[3 - $i]['poster'])) { + $posters[3 - $i]['poster'] = 'no-poster.webp'; + } + ?> + <a href="/catalog<?= isset($posters[3 - $i]) ? "/" . $posters[3 - $i]["catalog_uuid"] : "" ?>"> + <img loading=<?= $loading ?? 'eager' ?> + src="<?= "/assets/images/catalogs/posters/" . (isset($posters[3 - $i]) ? $posters[3 - $i]["poster"] : "no-poster.webp") ?>" + alt="Anime or Drama Poster" class="poster" /> + </a> + <?php endif; ?> + <?php endfor; ?> + </div> + <div class="card-body"> + <div class="watchlist__visibility-title"> + <?php if ($visibility === "PRIVATE"): ?> + <svg xmlns="http://www.w3.org/2000/svg" width="12" height="14" viewBox="0 0 12 14" fill="none"> + <path + d="M9 7H10.05C10.2985 7 10.5 7.20145 10.5 7.45V12.55C10.5 12.7985 10.2985 13 10.05 13H1.95C1.70147 13 1.5 12.7985 1.5 12.55V7.45C1.5 7.20145 1.70147 7 1.95 7H3M9 7V4C9 3 8.4 1 6 1C3.6 1 3 3 3 4V7M9 7H3" + stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /> + </svg> + <?php endif; ?> + <a href="/watchlist/<?= $uuid ?>"> + <h3 class="card-title"> + <?= $title ?> + </h3> + </a> + </div> + <div class="watchlist__meta"> + <div class="watchlist__wrapper-type-author"> + <span class="tag"> + <?= $category ?> + </span> + <?php for ($i = 0; $i < min(3, count($item["tags"])); $i++): ?> + <span class="tag"> + <?= $item["tags"][$i]["name"] ?> + </span> + <?php endfor; ?> + <span class="subtitle">by <span class="author-name"> + <?= $creator ?> + </span></span> + </div> + <span class="subtitle"> + <?= formatDate($createdAt); ?> + </span> + + </div> + <p class="watchlist__description"> + <?= $description ?> + </p> + <span class="watchlist__item-count"> + <?php require PUBLIC_PATH . 'assets/icons/clapperboard.php' ?> + <?= $itemCount ?> items + </span> + </div> + </div> + <div class="watchlist__actions"> + <div class="watchlist__action-save"> + <?php if (!$self): ?> + <?php if ($userUUID == ""): ?> + <?php likeAndSave("btn__save", "bookmark", "Save " . $title); ?> + <?php else: ?> + <button aria-label="Save <?= $title ?>" type="button" class="btn-ghost btn__save" data-id="<?= $uuid ?>" + data-saved="<?= $saved ?>"> + <?php + if (isset($saved)) { + $type = $saved ? "filled" : "unfilled"; + } + require PUBLIC_PATH . 'assets/icons/bookmark.php' + ?> + </button> + <?php endif; ?> + <?php endif; ?> + </div> + <div class="watchlist__action-love"> + <?php if ($userUUID == ""): ?> + <?php likeAndSave("btn__like", "love", "Love " . $title); ?> + <?php else: ?> + <button aria-label="Love <?= $title ?>" type="button" class="btn-ghost btn__like" data-id="<?= $uuid ?>" + data-liked="<?= $loved ?>"> + <?php + if (isset($loved)) { + $type = $loved ? "filled" : "unfilled"; + } + require PUBLIC_PATH . 'assets/icons/love.php' ?> + </button> + <?php endif; ?> + <span data-id="<?= $uuid ?>"> + <?= $loveCount ?> + </span> + </div> + </div> +</div> \ No newline at end of file diff --git a/src/server/app/View/components/footer.php b/src/server/app/View/components/footer.php new file mode 100644 index 0000000000000000000000000000000000000000..308b1d01b6ca1e7ab1b1fa896e6a8497bbcd1a37 --- /dev/null +++ b/src/server/app/View/components/footer.php @@ -0,0 +1,2 @@ +</body> +</html> diff --git a/src/server/app/View/components/header.php b/src/server/app/View/components/header.php new file mode 100644 index 0000000000000000000000000000000000000000..7462e99aa2871161367df291d89f8bb259698110 --- /dev/null +++ b/src/server/app/View/components/header.php @@ -0,0 +1,42 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta name="description" content="<?= $description ?? '#1 Drama and Anime Watch List Website' ?>" /> + + <title> + <?= 'Drawl | ' . $model['title'] ?? '🌸' ?> + </title> + + <!-- CSS --> + <link rel="stylesheet" href="/css/global.css"> + <!-- <link rel="stylesheet" href="/css/components/select.css"> + <link rel="stylesheet" href="/css/components/card.css"> + <link rel="stylesheet" href="/css/components/button.css"> + <link rel="stylesheet" href="/css/components/pagination.css"> + <link rel="stylesheet" href="/css/components/input.css"> + <link rel="stylesheet" href="/css/components/form.css"> + <link rel="stylesheet" href="/css/components/icon.css"> + <link rel="stylesheet" href="/css/components/textarea.css"> + <link rel="stylesheet" href="/css/components/modal.css"> + <link rel='stylesheet' href='/css/components/alert.css'> --> + + <?php foreach ($model['styles'] ?? [] as $style): ?> + <link rel='stylesheet' href='<?= $style ?>'> + <?php endforeach; ?> + + <!-- JS --> + <script type="text/javascript" src="/js/global.js" defer></script> + <script type='text/javascript' src='/js/components/navbar.js' defer></script> + <script type="text/javascript" src="/js/components/select.js" defer></script> + <script type="text/javascript" src="/js/components/modal.js" defer></script> + <script type='text/javascript' src='/js/components/alert.js' defer></script> + + <?php foreach ($model['js'] ?? [] as $js): ?> + <script type='text/javascript' src='<?= $js ?>' defer></script> + <?php endforeach; ?> +</head> + +<body> \ No newline at end of file diff --git a/src/server/app/View/components/modal.php b/src/server/app/View/components/modal.php new file mode 100644 index 0000000000000000000000000000000000000000..e101b95795add34c38d2af23c6e95eb5268d1d31 --- /dev/null +++ b/src/server/app/View/components/modal.php @@ -0,0 +1,42 @@ +<!-- +Attributes: +- triggerClasses +- triggerText +- triggerIcon +- ariaLabel +- title +- content +- data +--> + +<div class="modal"> + <button aria-label="<?= $ariaLabel ?? "" ?>" type="button" class="modal__trigger <?= $triggerClasses ?>" + <?php foreach ($data ?? [] + + as $key => $val): ?> + data-<?= $key ?>>="<?= $val ?>" + <?php endforeach; ?> + > + <?php + if (isset($triggerIcon) && $triggerIcon != '') { + require PUBLIC_PATH . 'assets/icons/' . $triggerIcon . '.php'; + } + ?> + <?= $triggerText ?? "" ?> + </button> + + <div id="modal__content" class="modal__backdrop"> + <div class="modal__content"> + <h2><?= $title ?></h2> + <button class="modal__close btn-ghost"> + <?php require PUBLIC_PATH . 'assets/icons/x.php' ?> + </button> + + <?php + if (isset($content)) { + require __DIR__ . '/../components/modal/' . $content . '.php'; + } + ?> + </div> + </div> +</div> \ No newline at end of file diff --git a/src/server/app/View/components/modal/signInRequired.php b/src/server/app/View/components/modal/signInRequired.php new file mode 100644 index 0000000000000000000000000000000000000000..38b4a4e0e8086b407c433994bf53cbdb3909a8f6 --- /dev/null +++ b/src/server/app/View/components/modal/signInRequired.php @@ -0,0 +1,3 @@ +<a href="/signin" class="btn btn-bold"> + Sign In +</a> diff --git a/src/server/app/View/components/modal/watchlistAddItem.php b/src/server/app/View/components/modal/watchlistAddItem.php new file mode 100644 index 0000000000000000000000000000000000000000..f5f2efa7a553e62ffdbc077cf40db178a37086cc --- /dev/null +++ b/src/server/app/View/components/modal/watchlistAddItem.php @@ -0,0 +1,11 @@ +<div> + <form class="form-default form-search"> + <div class="search"> + <label> + <?php require PUBLIC_PATH . '/assets/icons/search.php' ?> + </label> + <input type="text" id="search" name="search" placeholder="Search by title" class="input-default search__input" /> + </div> + </form> + <div class="search__items"></div> +</div> \ No newline at end of file diff --git a/src/server/app/View/components/modal/watchlistAddSearchItem.php b/src/server/app/View/components/modal/watchlistAddSearchItem.php new file mode 100644 index 0000000000000000000000000000000000000000..bb70c30efe22a1eea55452103b473912bed3690f --- /dev/null +++ b/src/server/app/View/components/modal/watchlistAddSearchItem.php @@ -0,0 +1,10 @@ +<div class="search-item" data-page="<?= $page ?>"> + <img src="<?= '/assets/images/catalogs/posters/' . $poster ?>" class="search-item__poster" /> + <div class="search-item__content"> + <h3 class="search-item__title"><?= $title ?></h3> + <p class="search-item__description"><?= $description ?></p> + </div> + <button type="button" class="search-item__action" data-id="<?= $uuid ?>"> + <?php require PUBLIC_PATH . 'assets/icons/plus.php' ?> + </button> +</div> \ No newline at end of file diff --git a/src/server/app/View/components/navbar.php b/src/server/app/View/components/navbar.php new file mode 100644 index 0000000000000000000000000000000000000000..5238aabb8e4c4c5d20926492c95285af1a0c12cb --- /dev/null +++ b/src/server/app/View/components/navbar.php @@ -0,0 +1,31 @@ +<div class="navbar"> + <div class="navbar-content"> + <div class="navbar-header"> + <div> + <a href='/' class="brand__title">Drawl</a> + </div> + <button aria-label="Open Menu" id="navbar-toggle" class="navbar-toggle" aria-expanded="false" + aria-controls="navbar-menu"> + <?php require PUBLIC_PATH . 'assets/icons/menu.php' ?> + </button> + </div> + <div id="navbar-menu" class="navbar-menu collapsed" aria-labelledby="navbar-toggle"> + <a href="/" class="btn">Discover</a> + <a href="/catalog" class="btn">Catalog</a> + <?php if ($user == null): ?> + <a href="/signin" class="btn">Sign In</a> + <?php else: ?> + <button id="profile-menu-toggle" class="profile-icon" aria-label="Open Profile Dropdown" + aria-expanded="false"> + <?php require PUBLIC_PATH . 'assets/icons/user.php' ?> + </button> + <div id="profile-menu" class="profile-menu collapsed" aria-labelledby="profile-menu-toggle"> + <a href="/profile" class="btn">Profile</a> + <a href="/profile/watchlist" class="btn">My Watchlist</a> + <a href="/profile/bookmark" class="btn">My Bookmark</a> + <button id="logout" class="btn">Logout</button> + </div> + <?php endif; ?> + </div> + </div> +</div> \ No newline at end of file diff --git a/src/server/app/View/components/pagination.php b/src/server/app/View/components/pagination.php new file mode 100644 index 0000000000000000000000000000000000000000..4b9be9cc392e2baef308a16aeaac62cc22ce43ff --- /dev/null +++ b/src/server/app/View/components/pagination.php @@ -0,0 +1,34 @@ +<?php +function createQueryUrl(int $page): string +{ + $currentQueryParams = $_GET; + $query = array_merge($currentQueryParams, ['page' => $page]); + $url = '?' . http_build_query($query); + return $url; +} + +?> +<div class="pagination"> + <?php if ($currentPage > 1): ?> + <a aria-label="Previous page" href="<?= createQueryUrl($currentPage - 1) ?>" class="pagination-item prev"> + <?php require PUBLIC_PATH . 'assets/icons/chevron-down.php' ?> + </a> + <?php endif; ?> + <?php if (max(0, $currentPage - 2) > 0): ?> + <span class="pagination-elips">...</span> + <?php endif; ?> + <?php for ($i = max(0, $currentPage - 2); $i < min($totalPage, $currentPage + 2); $i++): ?> + <a href="<?= createQueryUrl($i + 1) ?>" class="pagination-item" + data-type="<?= $i + 1 === $currentPage ? 'active' : 'inactive' ?>"> + <?= $i + 1 ?> + </a> + <?php endfor; ?> + <?php if (min($totalPage, $currentPage + 2) < $totalPage): ?> + <span class="pagination-elips">...</span> + <?php endif; ?> + <?php if ($currentPage < $totalPage): ?> + <a aria-label="Next page" href="<?= createQueryUrl($currentPage + 1) ?>" class="pagination-item next"> + <?php require PUBLIC_PATH . 'assets/icons/chevron-down.php' ?> + </a> + <?php endif; ?> +</div> \ No newline at end of file diff --git a/src/server/app/View/components/select.php b/src/server/app/View/components/select.php new file mode 100644 index 0000000000000000000000000000000000000000..cd8bb7cd6fb51c64372160c8f358619e27d437ed --- /dev/null +++ b/src/server/app/View/components/select.php @@ -0,0 +1,25 @@ +<!-- Attributes +- id +- placeholder +- content +- selected +--> + +<div class="c-select-menu" data-id="<?= $id ?>"> + <div class="c-select-btn"> + <span class="c-select-btn-text"><?= $selected ?? $placeholder ?? 'Select' ?></span> + <?php require PUBLIC_PATH . 'assets/icons/chevron-down.php' ?> + </div> + + <input type="hidden" id="<?= $id ?>" name="<?= $id ?>" value="<?= $selected ?>"/> + + <?php if (isset($content)) : ?> + <ul class="c-select-options c-select-hide"> + <?php foreach ($content as $item) : ?> + <li id="c-select-option-<?= $item ?>" class="c-select-option"> + <span class="c-select-option-text"><?= $item ?></span> + </li> + <?php endforeach; ?> + </ul> + <?php endif; ?> +</div> \ No newline at end of file diff --git a/src/server/app/View/components/toast.php b/src/server/app/View/components/toast.php new file mode 100644 index 0000000000000000000000000000000000000000..773d59aca1320702f7b2dd247a5644f078e10394 --- /dev/null +++ b/src/server/app/View/components/toast.php @@ -0,0 +1,13 @@ +<div id="toast" class="toast hidden" data-type="<?= $type ?? "error" ?>"> + <div> + <h3> + <?= $title ?? "" ?> + </h3> + <p> + <?= $message ?? "" ?> + </p> + </div> + <button id="close" class="btn-ghost"> + <?php require PUBLIC_PATH . "assets/icons/cancel.php" ?> + </button> +</div> \ No newline at end of file diff --git a/src/server/app/View/components/watchlist/watchlistItem.php b/src/server/app/View/components/watchlist/watchlistItem.php new file mode 100644 index 0000000000000000000000000000000000000000..661477a91d2265f7e8db8660cd574c9628d5bd7b --- /dev/null +++ b/src/server/app/View/components/watchlist/watchlistItem.php @@ -0,0 +1,21 @@ +<!-- <div class="watchlist-item__wrapper"> --> +<img alt="Poster of <?= $title ?>" src="<?= '/assets/images/catalogs/posters/' . ($poster ?? 'no-poster.webp') ?>" + class="watchlist-item__poster"> +<div class="watchlist-item__content"> + <h3 class="watchlist-item__title"> + <?= $title ?> + </h3> + <textarea name="<?= 'item[' . $id . '__' . $uuid . '__' . $category . ']' ?>" + class="input-default watchlist-item__description" placeholder="Enter description" + maxlength="255"><?= $description ?? "" ?></textarea> +</div> +<!-- </div> --> +<div class="watchlist-item__actions"> + <button aria-label="Delete <?= $title ?>" type="button" class="btn-ghost watchlist-item__delete" + data-id="<?= $uuid ?>"> + <?php require PUBLIC_PATH . 'assets/icons/trash.php' ?> + </button> + <span class="span__icon drag-handler"> + <?php require PUBLIC_PATH . 'assets/icons/grip-vertical.php' ?> + </span> +</div> \ No newline at end of file diff --git a/src/server/app/View/home/index.php b/src/server/app/View/home/index.php new file mode 100644 index 0000000000000000000000000000000000000000..cffdcb3a89d6f213740e2a96c2abdd9dcd9f6e65 --- /dev/null +++ b/src/server/app/View/home/index.php @@ -0,0 +1,120 @@ +<?php +function selectCategory() +{ + $id = 'category'; + $placeholder = 'Select Category'; + $content = [ + "MIXED", + "DRAMA", + "ANIME" + ]; + $selected = validateQueryParams($id, $content); + require __DIR__ . '/../components/select.php'; +} + +function sortBy() +{ + $id = 'sortBy'; + $placeholder = 'Sort By'; + $content = [ + "DATE", + "LOVE" + ]; + $selected = validateQueryParams($id, $content); + require __DIR__ . '/../components/select.php'; +} + +function tags($tags) +{ + $id = 'tag'; + $placeholder = 'Select Tag'; + $content = $tags; + $selected = validateQueryParams($id, $content); + + require __DIR__ . '/../components/select.php'; +} + +function vallidateOrder(): ?string +{ + if (!isset($_GET["order"]) || ($_GET["order"] != "asc" && $_GET["order"] != "desc")) + return null; + return $_GET["order"]; +} + +function fillLove($item) +{ + $type = $item["like_status"] == 1 ? "filled" : "unfilled"; + require PUBLIC_PATH . 'assets/icons/love.php'; +} + +function watchlist($item, $userUUID) +{ + $uuid = $item["watchlist_uuid"]; + $posters = $item["posters"]; + $visibility = $item["visibility"]; + $title = $item["title"]; + $category = $item["category"]; + $creator = $item["creator"]; + $createdAt = $item["created_at"]; + $description = $item["description"]; + $itemCount = $item["item_count"]; + $loveCount = $item["love_count"]; + $loved = $item["loved"]; + $saved = $item["saved"]; + $self = ($userUUID == $item["creator_uuid"]); + + require __DIR__ . '/../components/card/watchlistCard.php'; +} + +function pagination(int $currentPage, int $totalPage) +{ + require __DIR__ . '/../components/pagination.php'; +} + +?> + +<main> + <form class="form-search-filter"> + <div class="search"> + <?php require PUBLIC_PATH . 'assets/icons/search.php'; ?> + <input type="text" name="search" placeholder="Search title or creator" class="input-default input-search" + value="<?= trim($_GET['search'] ?? '') ?? '' ?>"/> + </div> + <div class="filter"> + <?php tags($model["data"]["tags"]); ?> + <?php selectCategory(); ?> + <div class="filter__sort"> + <?php sortBy(); ?> + <button aria-label="Sort Category" type="button" class="btn-sort"> + <span class="span-icon btn-sort-asc <?= vallidateOrder() == 'desc' || !vallidateOrder() ? 'hidden' : '' ?>"> + <?php require PUBLIC_PATH . 'assets/icons/asc.php' ?> + </span> + <span class="span-icon btn-sort-desc <?= vallidateOrder() == 'asc' ? 'hidden' : '' ?>"> + <?php require PUBLIC_PATH . 'assets/icons/desc.php' ?> + </span> + </button> + <input type="hidden" id="order" name="order" value="<?= vallidateOrder() ?? 'desc' ?>"/> + </div> + </div> + <button type="submit" id="btn-apply" class="btn-primary btn--apply">Apply</button> + </form> + + + <a class="btn btn-primary" href='/watchlist/create'> + <?php require PUBLIC_PATH . 'assets/icons/plus.php' ?> + New List + </a> + + <div class="list__watchlist"> + <?php if (count($model["data"]["items"]) == 0) : ?> + <div class="loading">No Results Found.</div> + <?php endif; ?> + <?php foreach ($model["data"]["items"] as $item) : ?> + <?php watchlist($item, $model["data"]["userUUID"]); ?> + <?php endforeach; ?> + <?php if (count($model["data"]["items"]) > 0) : ?> + <?php pagination($model["data"]["page"], $model["data"]["pageTotal"]); ?> + <?php endif; ?> + </div> + +</main> \ No newline at end of file diff --git a/src/server/app/View/profile/bookmark.php b/src/server/app/View/profile/bookmark.php new file mode 100644 index 0000000000000000000000000000000000000000..f7fa0ffbfd61c00096eebaab47d7b9958b683893 --- /dev/null +++ b/src/server/app/View/profile/bookmark.php @@ -0,0 +1,47 @@ +<?php + +function watchlistCard(array $item, string $userUUID, string $loading = "eager") +{ + $uuid = $item["watchlist_uuid"]; + $posters = $item["posters"]; + $visibility = $item["visibility"]; + $title = $item["title"]; + $category = $item["category"]; + $creator = $item["creator"]; + $createdAt = $item["created_at"]; + $description = $item["description"]; + $itemCount = $item["item_count"]; + $loveCount = $item["like_count"]; + $loved = $item["liked"]; + $self = ($userUUID == $item["creator_uuid"]); + $saved = true; + + require __DIR__ . '/../components/card/watchlistCard.php'; +} + +function pagination(int $currentPage, int $totalPage) +{ + require __DIR__ . '/../components/pagination.php'; +} + +?> + +<main class="watchlist-self"> + <section class="search-filter"> + <h2>My Bookmark</h2> + </section> + <?php if (count($model['data']['bookmarks']['items']) == 0): ?> + <div class="no-item__container"> + <h1>Oops! 😣</h1> + <div> + <h2>There's No Bookmark Yet...</h2> + </div> + </div> + <?php endif; ?> + <section class="content"> + <?php for ($i = 0; $i < count($model['data']['bookmarks']['items']); $i++): ?> + <?php watchlistCard($model['data']['bookmarks']['items'][$i], $model["data"]["userUUID"], $i < 4 ? "eager" : "lazy"); ?> + <?php endfor; ?> + <?php pagination($model['data']['bookmarks']['page'], $model['data']['bookmarks']['totalPage']); ?> + </section> +</main> \ No newline at end of file diff --git a/src/server/app/View/profile/watchlist.php b/src/server/app/View/profile/watchlist.php new file mode 100644 index 0000000000000000000000000000000000000000..f8a6768104e58eabe0d4405890126a77720c3a2f --- /dev/null +++ b/src/server/app/View/profile/watchlist.php @@ -0,0 +1,64 @@ +<?php + +function watchlistCard(array $item, string $userUUID, string $loading = "eager") +{ + $uuid = $item["watchlist_uuid"]; + $posters = $item["posters"]; + $visibility = $item["visibility"]; + $title = $item["title"]; + $category = $item["category"]; + $creator = $item["creator"]; + $createdAt = $item["created_at"]; + $description = $item["description"]; + $itemCount = $item["item_count"]; + $loveCount = $item["like_count"]; + $loved = $item["liked"]; + $saved = true; + $self = ($userUUID == $item["creator_uuid"]); + + require __DIR__ . '/../components/card/watchlistCard.php'; +} + +function pagination(int $currentPage, int $totalPage) +{ + require __DIR__ . '/../components/pagination.php'; +} + +?> + +<main class="watchlist-self"> + <section class="search-filter"> + <div> + <h2>My Watchlist</h2> + <div class="visibility"> + <a id="visibility-all" href="/profile/watchlist?visibility=all" + class="btn <?= $model['data']['visibility'] === "all" ? "selected" : "" ?>">All</a> + <a id="visibility-private" href="/profile/watchlist?visibility=private" + class="btn <?= $model['data']['visibility'] === "private" ? "selected" : "" ?>">Private</a> + <a id="visibility-public" href="/profile/watchlist?visibility=public" + class="btn <?= $model['data']['visibility'] === "public" ? "selected" : "" ?>">Public</a> + </div> + </div> + <a href="/watchlist/create" class="btn btn-bold"> + <span class="icon-new"> + <?php require PUBLIC_PATH . 'assets/icons/plus.php' ?> + </span> + Add Watchlist + </a> + </section> + <?php if (count($model['data']['watchlists']['items']) == 0): ?> + <div class="no-item__container"> + <h1>Oops! 😣</h1> + <div> + <h2>There's No Watchlist Yet...</h2> + <p>...Go to <a href="/">Home</a> or <a href="/watchlist/create">create some watchlist!</a></p> + </div> + </div> + <?php endif; ?> + <section class="content"> + <?php for ($i = 0; $i < count($model['data']['watchlists']['items']); $i++): ?> + <?php watchlistCard($model['data']['watchlists']['items'][$i], $model["data"]["userUUID"], $i < 4 ? "eager" : "lazy", ); ?> + <?php endfor; ?> + <?php pagination($model['data']['watchlists']['page'], $model['data']['watchlists']['totalPage']); ?> + </section> +</main> \ No newline at end of file diff --git a/src/server/app/View/user/editProfile.php b/src/server/app/View/user/editProfile.php new file mode 100644 index 0000000000000000000000000000000000000000..300694a9dd25b2cc26d93a670656aa5da0c34199 --- /dev/null +++ b/src/server/app/View/user/editProfile.php @@ -0,0 +1,82 @@ +<?php +function alert($title, $message, $type = 'error') +{ + require __DIR__ . '/../components/alert.php'; +} + +?> + + +<main> + <div class="edit-parameters"> + <div class="my-profile-container"> + <h2> + <?= $model['data']['name'] ?> - Profile + </h2> + </div> + <?php if (isset($model['error'])): ?> + <?php alert('Failed to Update', $model['error']); ?> + <?php endif; ?> + <?php if (isset($model['success'])): ?> + <?php alert('Success', $model['success'], 'success'); ?> + <?php endif; ?> + <form id="profile-edit-form" class="form-default"> + <div class="display-name"> + <h3>Name</h3> + <p id="name"> + <?= $model['data']['name'] ?> + </p> + </div> + <p>Change name</p> + <div class="input-container"> + <div class="input-box"> + <input class="input" name="name" placeholder="Enter new name" + value="<?= $model['data']['name'] ?>" /> + </div> + </div> + <div class="display-name"> + <h3>Email</h3> + <p> + <?= $model['data']['email'] ?> + </p> + </div> + <div class="password display-name"> + <h3>Change Password</h3> + <div class="password-button-container"> + <div class="password-container"> + <div class="password-title"> + <div class="password-texts"> + <p>Old Password</p> + <div class="red-star">*</div> + </div> + <div class="input-container"> + <div class="input-box"> + <input class="input" type="password" name="oldPassword" + placeholder="Enter old password" /> + </div> + </div> + </div> + <div class="password-title"> + <div class="password-texts"> + <p>New Password</p> + <div class="red-star">*</div> + </div> + <div class="input-container"> + <div class="input-box"> + <input class="input" type="password" name="newPassword" + placeholder="Enter new password" /> + </div> + </div> + </div> + </div> + </div> + <button id="update-account" class="btn-primary save" name="update_button" type="submit"> + save + </button> + <button id="delete-account" class="btn-bold" type="button"> + Delete Account + </button> + </form> + </div> + </div> +</main> \ No newline at end of file diff --git a/src/server/app/View/user/signIn.php b/src/server/app/View/user/signIn.php new file mode 100644 index 0000000000000000000000000000000000000000..e633706dac1291e10d9613bcb16dd42a40cf8078 --- /dev/null +++ b/src/server/app/View/user/signIn.php @@ -0,0 +1,41 @@ +<?php +function alert($title, $message) +{ + $type = 'error'; + require __DIR__ . '/../components/alert.php'; +} + +?> + + +<div class="signin-container row"> + <img src="/assets/images/Suzume.webp" alt="Sign In Image" class="signin-poster" /> + <div class="right-side"> + <div class="main-container"> + <div class="welcome-text"> + <h2 class="welcome-text__h2">Hello Again!</h2> + <p class="welcome-text__h1">Welcome back! Please provide your details</p> + </div> + <?php if (isset($model['error'])): ?> + <?php alert('Failed to Sign in', $model['error']); ?> + <?php endif; ?> + + <form class="inputs" action="/signin" method="post"> + <div class="parameter"> + <label for="email" class="input-required">Email</label> + <input type="email" name="email" id="email" class="input-default" placeholder="Enter email" + value="<?= $model['data']['email'] ?? "" ?>"> + </div> + <div class="parameter"> + <label for="password" class="input-required">Password</label> + <input type="password" name="password" id="password" class="input-default" + placeholder="Enter password"> + </div> + <button class="btn-bold" type="submit"> + Sign In + </button> + <p>Don't have an account? <a href="/signup" class="signup-link">Sign up</a></p> + </form> + </div> + </div> +</div> \ No newline at end of file diff --git a/src/server/app/View/user/signUp.php b/src/server/app/View/user/signUp.php new file mode 100644 index 0000000000000000000000000000000000000000..a9e507ea5650688a549ca6ccd404e140312a2e45 --- /dev/null +++ b/src/server/app/View/user/signUp.php @@ -0,0 +1,46 @@ +<?php +function alert($title, $message) +{ + $type = 'error'; + require __DIR__ . '/../components/alert.php'; +} + +?> + +<div class="signup-container row"> + <img src="/assets/images/Tomorrow.webp" alt="Sign Up Image" class="signup-poster" /> + <div class="right-side"> + <div class="main-container"> + <div class="welcome-text"> + <h2 class="welcome-text__h2">Let's Get Started!</h2> + <p class="welcome-text__h1">Glad to see you joining us! Please provide your details</p> + </div> + + <?php if (isset($model['error'])): ?> + <?php alert('Failed to Sign up', $model['error']); ?> + <?php endif; ?> + + <form class="inputs" action="/signup" method="post"> + <div class="parameter"> + <label for="email" class="input-required">Email</label> + <input type="email" name="email" id="email" class="input-default" required + placeholder="Enter email"> + </div> + <div class="parameter"> + <label for="password" class="input-required">Password</label> + <input type="password" name="password" id="password" class="input-default" required + placeholder="Enter password" /> + </div> + <div class="parameter"> + <label for="passwordConfirm" class="input-required">Confirm Password</label> + <input type="password" name="passwordConfirm" id="passwordConfirm" class="input-default" required + placeholder="Enter confirm password" /> + </div> + <button class="btn-bold" type="submit"> + Sign Up + </button> + <p>Already have an account? <a href="/signin" class="signin-link">Sign in</a></p> + </form> + </div> + </div> +</div> \ No newline at end of file diff --git a/src/server/app/View/watchlist/createUpdate.php b/src/server/app/View/watchlist/createUpdate.php new file mode 100644 index 0000000000000000000000000000000000000000..36c9264baacf277326135611bc1bdd8a044f5d5a --- /dev/null +++ b/src/server/app/View/watchlist/createUpdate.php @@ -0,0 +1,111 @@ +<?php +function visibility($visibility) +{ + $id = 'visibility'; + $content = [ + "PUBLIC", + "PRIVATE" + ]; + $selected = $visibility ?? 'PUBLIC'; + require __DIR__ . '/../components/select.php'; +} + +function addItem() +{ + $triggerClasses = "btn-outline btn__add-item"; + $triggerText = 'Add Item'; + $triggerIcon = 'plus'; + $title = 'Search'; + $content = 'watchlistAddItem'; + require __DIR__ . '/../components/modal.php'; +} + +function getItem() +{ + require __DIR__ . '/../components/watchlist/watchlistItem.php'; +} + +function alert($title, $message) +{ + $type = 'error'; + require __DIR__ . '/../components/alert.php'; +} + +function watchlistItem($poster, $title, $id, $uuid, $category, $description) +{ + require __DIR__ . '/../components/watchlist/watchlistItem.php'; +} + +?> + +<main> + <?php if (isset($error)) + echo $error; ?> + <h2 class="title-h2"><?= $model["title"] ?></h2> + <?php if (isset($model['error'])) { + alert('Failed to ' . $model['title'], $model['error']); + } ?> + <div class="container__create-watchlist"> + <div class="container__form"> + <form id="update-watchlist" class="form-default form__create-watchlist"> + <div class="form-input-default"> + <label for="title" class="input-required">Title</label> + <input type="text" name="title" id="title" class="input-default" placeholder="Best Anime and Drama" + value="<?= $model["data"]["title"] ?? '' ?>" required/> + </div> + + <div class="form-input-default"> + <label for="description">Description</label> + <textarea name="description" id="description" class="input-default" maxlength="255" + placeholder="Enter your watchlist description"><?= $model["data"]["description"] ?? '' ?></textarea> + </div> + + <div class="form-input-default"> + <label for="visibility" class="input-required">Visibility</label> + <?php visibility(isset($model["data"]["visibility"]) ? $model["data"]["visibility"] : null); ?> + </div> + + <div class="form-input-default"> + <label for="tags">Tags</label> + <div class="tags"> + <?php foreach ($model["data"]["tags"] ?? [] as $tag): ?> + <?php $selected = false ?> + <?php foreach ($model["data"]["tagsSelected"] ?? [] as $ts): ?> + <?php if ($tag->id == $ts["id"]) $selected = true; ?> + <?php endforeach; ?> + <div class="input-tag"> + <label for="tag_<?= $tag->name ?>"><?= $tag->name ?></label> + <input aria-label="tag_<?= $tag->name ?>" type="checkbox" id="tag_<?= $tag->name ?>" + name="<?= $tag->name ?>" + value="<?= $tag->id ?>" class="checkbox watchlist-tag" + <?= $selected ? "checked" : "" ?>/> + </div> + <?php endforeach; ?> + </div> + </div> + + <h3 class="watchlist-items__title">Items</h3> + <div class="watchlist-items"> + <?php foreach ((isset($model["data"]["catalogs"]["items"]) ? $model["data"]["catalogs"]["items"] : []) as $item): ?> + <div class="watchlist-item" draggable="true" data-id="<?= $item["catalog_uuid"] ?>"> + <?php watchlistItem($item["poster"], $item["title"], $item["catalog_id"], $item["catalog_uuid"], $item["category"], $item["description"]); ?> + </div> + <?php endforeach; ?> + <?php if (!isset($model["data"]["catalogs"]["items"])): ?> + <p class="items-placeholder">No items selected.</p> + <?php endif; ?> + </div> + + <input id="input-submit" type="submit" class="hidden"/> + </form> + + + </div> + <div class="actions"> + <?php addItem(); ?> + <label for="input-submit" class="btn btn-bold btn__save"> + Save + </label> + </div> + </div> +</main> \ No newline at end of file diff --git a/src/server/app/View/watchlist/detail.php b/src/server/app/View/watchlist/detail.php new file mode 100644 index 0000000000000000000000000000000000000000..c115e58f4a9922ff4fe4413d18bf2ccbba7b73f6 --- /dev/null +++ b/src/server/app/View/watchlist/detail.php @@ -0,0 +1,123 @@ +<?php + +if (!function_exists("formatDate")) { + function formatDate($createdAt) + { + require __DIR__ . '/../../../config/dateFormat.php'; + } +} + +function catalogCard($catalog) +{ + $title = $catalog['title']; + $poster = $catalog['poster']; + $category = $catalog['category']; + $description = $catalog['description']; + $uuid = $catalog['catalog_uuid']; + require __DIR__ . '/../components/card/catalogCard.php'; +} + +function pagination($currentPage, $totalPage) +{ + require __DIR__ . '/../components/pagination.php'; +} + +if (!function_exists("likeAndSave")) { + + function likeAndSave($class, $icon, $ariaLabel = "") + { + $triggerClasses = "btn-ghost $class"; + $triggerText = ""; + $triggerIcon = $icon; + $title = "Sign In Required"; + $content = 'signInRequired'; + require __DIR__ . '/../components/modal.php'; + } +} + +?> + +<main> + <article class="header"> + <div class="detail"> + <h2> + <?php if ($model['data']['item']['visibility'] === "PRIVATE"): ?> + <svg xmlns="http://www.w3.org/2000/svg" width="12" height="14" viewBox="0 0 12 14" fill="none"> + <path + d="M9 7H10.05C10.2985 7 10.5 7.20145 10.5 7.45V12.55C10.5 12.7985 10.2985 13 10.05 13H1.95C1.70147 13 1.5 12.7985 1.5 12.55V7.45C1.5 7.20145 1.70147 7 1.95 7H3M9 7V4C9 3 8.4 1 6 1C3.6 1 3 3 3 4V7M9 7H3" + stroke="#0F172A" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /> + </svg> + <?php endif; ?> + <?= $model['data']['item']['title'] ?> + </h2> + <div class="container-subtitle"> + <div class="tag"> + <?= $model['data']['item']['category'] ?> + </div> + <p class="subtitle"> + <?= $model['data']['item']['creator'] ?> | + <?= formatDate($model['data']['item']['created_at']) ?> + </p> + </div> + <div class="watchlist__wrapper-type-author"> + <?php foreach ($model["data"]["item"]["tags"] as $tag): ?> + <span class="tag"> + <?= $tag["name"] ?> + </span> + <?php endforeach; ?> + </div> + <p> + <?= $model['data']['item']['description'] ?> + </p> + </div> + <div class="container-button"> + <div class="container-btn-love"> + <?php if ($model['data']['userUUID'] == ""): ?> + <?php likeAndSave("btn__like", "love", "Love " . $model['data']['item']['title']); ?> + <?php else: ?> + <button class="btn-ghost btn__like" data-id="<?= $model["data"]["item"]["watchlist_uuid"] ?>" + data-liked="<?= $model["data"]["item"]["liked"] ?>"> + <?php + $type = (isset($model['data']["item"]['liked']) && $model['data']["item"]['liked']) ? "filled" : "unfilled"; + require PUBLIC_PATH . 'assets/icons/love.php' ?> + </button> + <?php endif; ?> + <span data-id="<?= $model["data"]["item"]["watchlist_uuid"] ?>"> + <?= $model['data']['item']['like_count'] ?> + </span> + </div> + <div class="container-btn-love"> + <?php if (!isset($model['data']['userUUID']) || $model['data']['userUUID'] != $model['data']['item']['creator_uuid']): ?> + <?php if ($model['data']['userUUID'] == ""): ?> + <?php likeAndSave("btn__save", "bookmark", "Save " . $model['data']['item']['title']); ?> + <?php else: ?> + <button class="btn-ghost btn__save" data-id="<?= $model["data"]["item"]["watchlist_uuid"] ?>" + data-saved="<?= $model["data"]["item"]["saved"] ?>"> + <?php + $type = (isset($model['data']['item']['saved']) && $model['data']['item']['saved']) ? "filled" : "unfilled"; + require PUBLIC_PATH . 'assets/icons/bookmark.php' ?> + </button> + <?php endif; ?> + <?php endif; ?> + </div> + </div> + </article> + <article id="catalogs" class="content"> + <?php foreach ($model['data']['item']['catalogs']['items'] ?? [] as $catalog): ?> + <?php catalogCard($catalog); ?> + <?php endforeach; ?> + <?php pagination($model['data']['item']['catalogs']['page'], $model['data']['item']['catalogs']['totalPage']); ?> + </article> + <?php if (isset($model['data']['userUUID']) && $model['data']['userUUID'] === $model['data']['item']['creator_uuid']): ?> + <div class="watchlist-detail__button-container"> + <a href="<?= "/watchlist/" . $model['data']['item']['watchlist_uuid'] . "/edit" ?>" id="edit" + aria-label="Edit <?= $model['data']['item']['title'] ?>" class="btn-icon"> + <?php require PUBLIC_PATH . 'assets/icons/edit.php' ?> + </a> + <button type="submit" aria-label="Delete <?= $model['data']['item']['title'] ?>" + class="dialog-trigger btn-icon btn__delete" data-id="<?= $model["data"]["item"]["watchlist_uuid"] ?>"> + <?php require PUBLIC_PATH . 'assets/icons/trash.php' ?> + </button> + </div> + <?php endif; ?> +</main> \ No newline at end of file diff --git a/src/server/config/bootstrap.php b/src/server/config/bootstrap.php new file mode 100644 index 0000000000000000000000000000000000000000..0639fbabe591cf61358741d4fe88b89a0beafeca --- /dev/null +++ b/src/server/config/bootstrap.php @@ -0,0 +1,17 @@ +<?php +define('PUBLIC_PATH', '/var/www/html/'); +require_once __DIR__ . '/../app/Utils/EnvLoader.php'; + +// Load env +(new EnvLoader(__DIR__ . '/../.env'))->load(); + +if (!function_exists('validateQueryParams')) { + function validateQueryParams($id, $content): ?string + { + if (!isset($content)) return null; + if (isset($_GET[$id]) && in_array($_GET[$id], $content, TRUE)) { + return $_GET[$id]; + } + return null; + } +} diff --git a/src/server/config/database.php b/src/server/config/database.php new file mode 100644 index 0000000000000000000000000000000000000000..b0482b033cdd7ba5fadcc817ba5ac812be11a1d8 --- /dev/null +++ b/src/server/config/database.php @@ -0,0 +1,18 @@ +<?php + +function getDatabaseConfig(): array +{ + return [ + "database" => [ + "dev" => [ + "url" => 'pgsql:host=' . getenv('DB_HOST') . ';port=' . getenv('DB_PORT') . ';dbname=' . getenv('DB_NAME') . ';', + 'host_url' => 'pgsql:host=' . getenv('DB_HOST') . ';port=' . getenv('DB_PORT') . ';', + 'host' => getenv('DB_HOST'), + 'port' => getenv('DB_PORT'), + 'name' => getenv('DB_NAME'), + 'username' => getenv('DB_USER'), + 'password' => getenv('DB_PASSWORD'), + ], + ], + ]; +} diff --git a/src/server/config/dateFormat.php b/src/server/config/dateFormat.php new file mode 100644 index 0000000000000000000000000000000000000000..283c188259483e6698c6d298103c1bd4ec9f36fd --- /dev/null +++ b/src/server/config/dateFormat.php @@ -0,0 +1,32 @@ +<?php +if (!function_exists("timeAgo")) { + function timeAgo($timestamp): string + { + $timestamp = strtotime($timestamp); + $currentTimestamp = strtotime(gmdate(DATE_RFC3339)); + $timeDifference = $currentTimestamp - $timestamp; + + $intervals = array( + 31536000 => 'year', + 2592000 => 'month', + 604800 => 'week', + 86400 => 'day', + 3600 => 'hour', + 60 => 'minute', + 1 => 'second', + ); + + foreach ($intervals as $seconds => $label) { + $quotient = $timeDifference / $seconds; + if ($quotient >= 1) { + $rounded = round($quotient); + $plural = ($rounded > 1) ? 's' : ''; + return $rounded . ' ' . $label . $plural . ' ago'; + } + } + + return 'just now'; + } +} + +echo timeAgo($createdAt); \ No newline at end of file diff --git a/src/server/routes/view.php b/src/server/routes/view.php new file mode 100644 index 0000000000000000000000000000000000000000..850d114035dbdc88c0174227ae6e11552fc51d30 --- /dev/null +++ b/src/server/routes/view.php @@ -0,0 +1,63 @@ +<?php +require_once __DIR__ . "/../app/App/Router.php"; + +require_once __DIR__ . "/../app/Controller/HomeController.php"; +require_once __DIR__ . "/../app/Controller/UserController.php"; +require_once __DIR__ . "/../app/Controller/CatalogController.php"; +require_once __DIR__ . '/../app/Controller/WatchlistController.php'; +require_once __DIR__ . '/../app/Controller/ErrorPageController.php'; +require_once __DIR__ . '/../app/Controller/BookmarkController.php'; + +require_once __DIR__ . '/../app/Middleware/UserAuthMiddleware.php'; +require_once __DIR__ . '/../app/Middleware/AdminAuthMiddleware.php'; +require_once __DIR__ . '/../app/Middleware/UserAuthApiMiddleware.php'; +require_once __DIR__ . '/../app/Middleware/AdminAuthApiMiddleware.php'; + + +// Register routes +// Home controllers +Router::add('GET', '/', HomeController::class, 'index', []); +Router::add("GET", "/api/watchlists", HomeController::class, 'watchlists', []); + +// User controllers +Router::add('GET', '/signup', UserController::class, 'signUp', []); +Router::add('POST', '/signup', UserController::class, 'postSignUp', []); +Router::add('GET', '/signin', UserController::class, 'signIn', []); +Router::add('POST', '/signin', UserController::class, 'postSignIn', []); +Router::add('POST', '/api/auth/logout', UserController::class, 'logout', [UserAuthMiddleware::class]); +Router::add('DELETE', '/api/auth/delete', UserController::class, 'delete', [UserAuthMiddleware::class]); +Router::add('PUT', '/api/auth/update', UserController::class, 'update', [UserAuthMiddleware::class]); + +// Catalog controllers +Router::add('GET', '/catalog', CatalogController::class, 'index', []); +Router::add('GET', '/catalog/create', CatalogController::class, 'create', [AdminAuthMiddleware::class]); +Router::add('GET', '/catalog/([A-Za-z0-9\-]*)', CatalogController::class, 'detail', []); +Router::add('GET', '/catalog/([A-Za-z0-9\-]*)/edit', CatalogController::class, 'edit', [AdminAuthMiddleware::class]); +Router::add('POST', '/api/catalog/create', CatalogController::class, 'postCreate', [AdminAuthMiddleware::class]); +Router::add('GET', '/api/catalog', CatalogController::class, "search", [UserAuthApiMiddleware::class]); +Router::add("DELETE", "/api/catalog/([A-Za-z0-9\-]*)/delete", CatalogController::class, "delete", [AdminAuthMiddleware::class]); +Router::add("POST", '/api/catalog/([A-Za-z0-9\-]*)/update', CatalogController::class, 'update', [AdminAuthMiddleware::class]); + +// Watchlist controllers +Router::add("GET", "/watchlist/create", WatchlistController::class, 'create', [UserAuthMiddleware::class]); +Router::add("GET", "/watchlist/([A-Za-z0-9]*)/edit", WatchlistController::class, "edit", [UserAuthMiddleware::class]); +Router::add("GET", "/watchlist/([A-Za-z0-9]*)", WatchlistController::class, 'detail', []); + +Router::add("POST", "/api/watchlist", WatchlistController::class, "postCreate", [UserAuthApiMiddleware::class]); +Router::add("PUT", "/api/watchlist", WatchlistController::class, "putEdit", [UserAuthApiMiddleware::class]); +Router::add("DELETE", "/api/watchlist", WatchlistController::class, "delete", [UserAuthApiMiddleware::class]); +Router::add("GET", "/api/watchlist/item", WatchlistController::class, 'item', [UserAuthApiMiddleware::class]); +Router::add("POST", "/api/watchlist/like", WatchlistController::class, "like", [UserAuthApiMiddleware::class]); +Router::add("POST", "/api/watchlist/save", WatchlistController::class, "bookmark", [UserAuthApiMiddleware::class]); + + +Router::add('GET', '/profile', UserController::class, 'showEditProfile', [UserAuthMiddleware::class]); +Router::add('GET', '/profile/bookmark', BookmarkController::class, 'self', [UserAuthMiddleware::class]); +Router::add('GET', '/profile/watchlist', WatchlistController::class, 'self', [UserAuthMiddleware::class]); + +// Error page +Router::add('GET', '/404', ErrorPageController::class, 'fourohfour', []); +Router::add('GET', '/500', ErrorPageController::class, 'fivehundred', []); + +// Execute +Router::run(); \ No newline at end of file