minio #1

Open
qpismont wants to merge 5 commits from minio into main
51 changed files with 956 additions and 957 deletions

7
.devcontainer/Dockerfile Normal file
View file

@ -0,0 +1,7 @@
FROM debian:12
WORKDIR /app
RUN apt update &&\
apt install git curl unzip -y &&\
curl -fsSL https://bun.sh/install | bash

View file

@ -0,0 +1,18 @@
{
"workspaceFolder": "/workspace",
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,Z",
"build": {
"dockerfile": "Dockerfile"
},
"customizations": {
"vscode": {
"extensions": [
"oven.bun-vscode",
"biomejs.biome",
"astro-build.astro-vscode"
]
}
},
"forwardPorts": [8080],
"runArgs": ["--network=dev-network", "--name=trepa-web-dev"]
}

View file

@ -10,4 +10,7 @@ export default defineConfig({
adapter: node({ adapter: node({
mode: "standalone", mode: "standalone",
}), }),
server: {
port: 8080
}
}); });

View file

@ -1,5 +1,5 @@
{ {
"$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
"organizeImports": { "organizeImports": {
"enabled": true "enabled": true
}, },

BIN
bun.lockb

Binary file not shown.

View file

@ -1,27 +1,26 @@
{ {
"name": "trepa-web", "name": "trepa-web",
"type": "module", "type": "module",
"version": "0.0.1", "version": "0.0.1",
"scripts": { "scripts": {
"dev": "bunx --bun astro dev", "dev": "bunx --bun astro dev",
"build": "rm -rf dist && bunx --bun astro check && bunx --bun astro build", "build": "rm -rf dist && bunx --bun astro check && bunx --bun astro build",
"preview": "bunx --bun astro preview", "preview": "bunx --bun astro preview",
"astro": "astro" "astro": "astro"
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "^0.8.1", "@astrojs/check": "^0.9.4",
"@astrojs/node": "^8.3.2", "@astrojs/node": "^9.1.3",
"@astrojs/react": "^3.6.0", "@astrojs/react": "^4.2.2",
"@types/react": "^18.3.3", "@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"astro": "^4.11.5", "astro": "^5.5.5",
"bootstrap": "^5.3.3", "react": "^18.3.1",
"react": "^18.3.1", "react-dom": "^18.3.1",
"react-dom": "^18.3.1", "react-icons": "^5.5.0",
"react-icons": "^5.2.1", "typescript": "^5.8.2"
"typescript": "^5.5.3" },
}, "devDependencies": {
"devDependencies": { "@biomejs/biome": "1.9.4"
"@biomejs/biome": "1.8.3" }
}
} }

View file

@ -1,59 +0,0 @@
import type React from "react";
import { useEffect, useRef, useState } from "react";
interface DebounceProps {
debouceDelay: number;
onDebounce: (value: string) => void;
}
type DebouceInputProps = DebounceProps &
React.InputHTMLAttributes<HTMLInputElement>;
export default function DebounceInput(props: DebouceInputProps) {
const [value, setValue] = useState<string>("");
const debounceTimeout = useRef<number | null>(null);
useEffect(() => {
if (typeof props.value === "string") {
setValue(props.value);
} else {
setValue("");
}
}, [props.value]);
function handleOnChange(e: React.ChangeEvent<HTMLInputElement>) {
if (debounceTimeout.current) {
clearTimeout(debounceTimeout.current);
}
setValue(e.target.value);
debounceTimeout.current = setTimeout(
() => props.onDebounce(e.target.value),
props.debouceDelay,
);
if (props.onChange) {
props.onChange(e);
}
}
const inputAttr = {
...props,
debouceDelay: undefined,
onDebounce: undefined,
};
return (
<input
className={props.className}
placeholder={props.placeholder}
id={props.id}
type="text"
value={value}
onChange={handleOnChange}
onKeyDown={props.onKeyDown}
/>
);
}

56
src/components/Logo.astro Normal file
View file

@ -0,0 +1,56 @@
---
interface Props {
class?: string;
}
const { class: className = "" } = Astro.props;
---
<div class={`logo ${className}`}>
<div class="logo-icon">
<div class="play-icon"></div>
</div>
</div>
<style>
.logo {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
}
.logo-icon {
width: 80px;
height: 80px;
background: rgba(255, 255, 255, 0.1);
backdrop-filter: var(--glass-blur);
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.logo-icon::before {
content: "";
position: absolute;
inset: 0;
background: var(--primary-gradient);
opacity: 0.5;
}
.play-icon {
width: 0;
height: 0;
border-style: solid;
border-width: 15px 0 15px 25px;
border-color: transparent transparent transparent #ffffff;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
position: relative;
z-index: 1;
transform: translateX(2px);
}
</style>

View file

@ -1,37 +0,0 @@
import { useState } from "react";
import DebounceInput from "./DebouceInput";
interface MoviesSearchInput {
searchUrl: string;
}
export default function MoviesSearchInput({ searchUrl }: MoviesSearchInput) {
const [value, setValue] = useState<string>("");
function handleOnDebounce(value: string) {}
function handleOnChange(event: React.ChangeEvent<HTMLInputElement>) {
setValue(event.target.value);
}
function handleOnKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
window.location.href = `${searchUrl}?q=${value}`;
}
}
return (
<div className="form-floating mb-3">
<DebounceInput
debouceDelay={1000}
type="text"
className="form-control"
id="moviesearch"
onChange={handleOnChange}
onDebounce={handleOnDebounce}
onKeyDown={handleOnKeyDown}
/>
<label className="form-label">Search a movie</label>
</div>
);
}

View file

@ -1,31 +0,0 @@
import { useState } from "react";
import type { Movie } from "../types";
import TmdbSearchInput from "./TmdbSearchInput";
import GroupMoviesList from "./lists/movies/GroupMoviesList";
interface TmdbSearchProps {
onSearch: (movie: Movie) => void;
}
export default function TmdbSearch({ onSearch }: TmdbSearchProps) {
const [movies, setMovies] = useState<Movie[]>([]);
function handleOnSearch(movies: Movie[]) {
setMovies(movies);
}
return (
<>
<div className="row">
<div className="col">
<TmdbSearchInput onSearch={handleOnSearch} />
</div>
</div>
<div style={{ marginTop: "8px" }} className="row">
<div className="col">
<GroupMoviesList movies={movies} onClick={onSearch} />
</div>
</div>
</>
);
}

View file

@ -1,34 +0,0 @@
import type { Movie } from "../types";
import DebounceInput from "./DebouceInput";
interface TmdbSearchInputProps {
onSearch: (movies: Movie[]) => void;
}
export default function TmdbSearchInput({ onSearch }: TmdbSearchInputProps) {
function handleOnSearchInputDebounce(value: string) {
fetch(`/api/tmdb/movies/search?query=${value}`, {
method: "GET",
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
})
.then((res) => res.json())
.then((json) => {
onSearch(json.movies);
})
.catch((err) => console.log(err));
onSearch([]);
}
return (
<DebounceInput
className="form-control"
placeholder="Search here"
onDebounce={handleOnSearchInputDebounce}
debouceDelay={1000}
/>
);
}

View file

@ -1,34 +0,0 @@
import { useState } from "react";
import TmdbSearch from "../TmdbSearch";
import type { CreateMovie, Movie } from "../../types";
import MovieFormData from "./MovieFormData";
export default function AddMovieForm() {
const [tmdbMovie, setTmdbMovie] = useState<Movie | null>(null);
function handleOnSearch(movie: Movie) {
setTmdbMovie(movie);
}
const createMovie: CreateMovie | undefined = tmdbMovie
? {
title: tmdbMovie.title,
backdrop_path: tmdbMovie.backdrop_path,
poster_path: tmdbMovie.poster_path,
overview: tmdbMovie.overview,
release_date: tmdbMovie.release_date,
tmdb_id: tmdbMovie.id,
}
: undefined;
return (
<div className="row">
<div className="col">
<TmdbSearch onSearch={handleOnSearch} />
</div>
<div className="col">
<MovieFormData defaultValue={createMovie} />
</div>
</div>
);
}

View file

@ -1,74 +0,0 @@
import { useState } from "react";
export interface LoginFormValue {
username: string;
password: string;
}
interface LoginFormProps {
onSubmit: (data: LoginFormValue) => void;
disabled?: boolean;
}
export default function LoginForm(props: LoginFormProps) {
const [form, setForm] = useState<LoginFormValue>({
username: "",
password: "",
});
function handleInputChange(event: React.ChangeEvent<HTMLInputElement>) {
const target = event.target;
const name = target.name;
const value = target.value;
setForm({
...form,
[name]: value,
});
}
function handleBtnClick() {
props.onSubmit({ ...form });
}
return (
<div className="card">
<div className="card-body">
<form>
<div className="form-floating mb-3">
<input
type="text"
className="form-control"
id="username"
name="username"
value={form.username}
onChange={handleInputChange}
/>
<label className="form-label">Nom d'utilisateur</label>
</div>
<div className="form-floating mb-3">
<input
type="password"
className="form-control"
id="password"
name="password"
onChange={handleInputChange}
value={form.password}
/>
<label className="form-label">Mot de passe</label>
</div>
</form>
<div className="d-grid">
<button
type="button"
disabled={props.disabled}
onClick={handleBtnClick}
className="btn btn-primary"
>
Connexion
</button>
</div>
</div>
</div>
);
}

View file

@ -1,29 +0,0 @@
import { useState } from "react";
import LoginForm, { type LoginFormValue } from "./LoginForm";
export default function LoginFormData() {
const [loading, setLoading] = useState<boolean>(false);
function handleOnFormSubmit(data: LoginFormValue) {
const searchParams = new URLSearchParams(window.location.search);
setLoading(true);
fetch("/api/accounts/login", {
method: "POST",
body: JSON.stringify(data),
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
})
.then((res) => res.json())
.then(() => {
window.location.href = searchParams.get("redirect") || "/home";
})
.catch((err) => console.log(err))
.finally(() => setLoading(false));
}
return <LoginForm onSubmit={handleOnFormSubmit} disabled={loading} />;
}

View file

@ -1,120 +0,0 @@
import { useEffect, useState } from "react";
import type { CreateMovie } from "../../types";
interface MovieFormProps {
defaultValue?: CreateMovie;
disabled?: boolean;
onSubmit: (createMovie: CreateMovie) => void;
}
export default function MovieForm({
defaultValue,
onSubmit,
disabled,
}: MovieFormProps) {
const [form, setForm] = useState<CreateMovie>(
defaultValue || {
title: "",
overview: "",
release_date: "",
backdrop_path: "",
poster_path: "",
tmdb_id: 0,
},
);
useEffect(() => {
if (defaultValue) setForm(defaultValue);
}, [defaultValue]);
function handleInputChange(
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) {
const target = event.target;
const name = target.name;
const value = target.value;
setForm({
...form,
[name]: value,
});
}
function handleBtnClick() {
onSubmit({ ...form });
}
return (
<div className="card">
<div className="card-body">
<form>
<div className="form-floating mb-3">
<input
type="text"
className="form-control"
id="title"
name="title"
value={form.title}
onChange={handleInputChange}
/>
<label className="form-label">Title</label>
</div>
<div className="form-floating mb-3">
<textarea
className="form-control"
id="overview"
name="overview"
style={{ minHeight: "96px" }}
onChange={handleInputChange}
value={form.overview}
/>
<label className="form-label">Overview</label>
</div>
<div className="form-floating mb-3">
<input
type="date"
className="form-control"
id="release_date"
name="release_date"
onChange={handleInputChange}
value={form.release_date}
/>
<label className="form-label">Release date</label>
</div>
<div className="form-floating mb-3">
<input
type="text"
className="form-control"
id="backdrop_path"
name="backdrop_path"
onChange={handleInputChange}
value={form.backdrop_path}
/>
<label className="form-label">Backdrop path</label>
</div>
<div className="form-floating mb-3">
<input
type="text"
className="form-control"
id="poster_path"
name="poster_path"
onChange={handleInputChange}
value={form.poster_path}
/>
<label className="form-label">Poster path</label>
</div>
</form>
<div className="d-grid">
<button
type="button"
disabled={disabled}
onClick={handleBtnClick}
className="btn btn-primary"
>
Create
</button>
</div>
</div>
</div>
);
}

View file

@ -1,82 +0,0 @@
import { useState } from "react";
import type { CreateMovie } from "../../types";
import MovieForm from "./MovieForm";
declare let bootstrap: any;
interface MovieFormDataProps {
defaultValue?: CreateMovie;
}
export default function MovieFormData({ defaultValue }: MovieFormDataProps) {
const [loading, setLoading] = useState<boolean>(false);
const [showModal, setShowModal] = useState<boolean>(false);
function handleOnSubmit(createMovie: CreateMovie) {
const searchParams = new URLSearchParams(window.location.search);
setLoading(true);
fetch("/api/movies", {
method: "POST",
body: JSON.stringify(createMovie),
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
})
.then((res) => res.json())
.then((movie) => {
const myModal = new bootstrap.Modal(
document.getElementById("exampleModal"),
);
myModal?.show();
console.log(movie);
})
.catch((err) => console.log(err))
.finally(() => setLoading(false));
}
return (
<>
<MovieForm
defaultValue={defaultValue}
disabled={loading}
onSubmit={handleOnSubmit}
/>
<div className="modal fade" id="exampleModal" role="dialog">
<div className="modal-dialog" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title">Modal title</h5>
<button
type="button"
className="close"
data-dismiss="modal"
aria-label="Close"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div className="modal-body">
<p>Modal body text goes here.</p>
</div>
<div className="modal-footer">
<button type="button" className="btn btn-primary">
Save changes
</button>
<button
type="button"
className="btn btn-secondary"
data-dismiss="modal"
>
Close
</button>
</div>
</div>
</div>
</div>
</>
);
}

View file

@ -0,0 +1,56 @@
---
import Alert from "../../ui/Alert.astro";
import Logo from "../../Logo.astro";
import Button from "../../ui/Button.astro";
import Input from "../../ui/Input.astro";
interface Props {
error?: string;
username?: string;
password?: string;
}
const { error, username = "", password = "" } = Astro.props;
---
<Logo />
<hr />
{
error && (
<Alert type="error" class="text-center">
{error}
</Alert>
)
}
<form method="post" action="/login">
<div class="form-group">
<label class="form-label" for="username">Nom d'utilisateur</label>
<Input
type="text"
id="username"
name="username"
value={username}
placeholder="Entrez votre nom d'utilisateur"
/>
</div>
<div class="form-group">
<label class="form-label" for="password">Mot de passe</label>
<Input
type="password"
id="password"
name="password"
value={password}
placeholder="Entrez votre mot de passe"
/>
</div>
<Button type="submit" variant="primary" class="w-100"> Connexion </Button>
</form>
<style>
h2 {
background: var(--primary-gradient);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-size: 1.75rem;
}
</style>

View file

@ -0,0 +1,86 @@
---
import LoginForm from "./LoginForm.astro";
type LoginError =
| "MISSING_FIELDS"
| "INVALID_CREDENTIALS"
| "SERVER_ERROR"
| "NOT_FOUND";
const ERROR_MESSAGES: Record<LoginError, string> = {
MISSING_FIELDS: "Tous les champs sont requis",
INVALID_CREDENTIALS: "Identifiants invalides",
NOT_FOUND: "Compte non trouvé",
SERVER_ERROR: "Une erreur est survenue",
};
interface LoginResponse {
jwt: string;
}
interface LoginResult {
error?: LoginError;
jwt?: string;
}
async function handleLogin(
username: string,
password: string,
): Promise<LoginResult> {
if (!username || !password) {
return { error: "MISSING_FIELDS" };
}
try {
const res = await fetch(`${import.meta.env.API_URL}/accounts/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username, password }),
});
if (!res.ok) {
return {
error:
res.status === 401
? "INVALID_CREDENTIALS"
: res.status === 404
? "NOT_FOUND"
: "SERVER_ERROR",
};
}
const data = (await res.json()) as LoginResponse;
return { jwt: data.jwt };
} catch (err) {
return { error: "SERVER_ERROR" };
}
}
const req = Astro.request;
let error: string | undefined;
if (req.method === "POST") {
const form = await req.formData();
const username = form.get("username")?.toString().trim() ?? "";
const password = form.get("password")?.toString().trim() ?? "";
const { error: loginError, jwt } = await handleLogin(username, password);
if (loginError) {
error = ERROR_MESSAGES[loginError];
} else if (jwt) {
Astro.cookies.set("jwt", jwt, {
path: "/",
secure: true,
httpOnly: true,
sameSite: "strict",
});
return Astro.redirect("/");
}
}
---
<LoginForm error={error} />

View file

@ -1,18 +0,0 @@
import type { Movie } from "../../../types";
import CardMoviesListItem from "./CardMoviesListItem";
interface CardMoviesListProps {
movies: Movie[];
}
export default function CardMoviesList({ movies }: CardMoviesListProps) {
const items = movies.map((elt) => {
return (
<div key={elt.id} className="col-2 mb-4">
<CardMoviesListItem movie={elt} />
</div>
);
});
return <div className="row">{items}</div>;
}

View file

@ -1,50 +0,0 @@
import { FaEye } from "react-icons/fa";
import type { Movie } from "../../../types";
interface CardMoviesListItemProps {
movie: Movie;
}
export default function CardMoviesListItem({ movie }: CardMoviesListItemProps) {
return (
<div
className="cardshadow"
style={{
backgroundImage: `url(${movie.poster_path})`,
backgroundPosition: "center",
backgroundSize: "cover",
height: "320px",
borderRadius: "5px",
}}
>
<div style={{ height: "100%", width: "100%" }} className="movieitem">
<div style={{ height: "100%" }} className="row justify-content-center">
<div style={{ height: "100%" }} className="col align-self-center">
<div style={{ height: "50%" }} className="row">
<div className="col align-self-end">
<h5 style={{ textAlign: "center", verticalAlign: "center" }}>
{movie.title}
</h5>
</div>
</div>
<div
style={{ height: "50%" }}
className="row justify-content-center"
>
<div className="col-8 align-self-center">
<div className="d-grid">
<a
href={`/home/movies/${movie.id}`}
className="btn btn-primary"
>
<FaEye />
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,18 +0,0 @@
import type { Movie } from "../../../types";
import GroupMoviesListItem from "./GroupMoviesListItem";
interface GroupMoviesListProps {
movies: Movie[];
onClick?: (movie: Movie) => void;
}
export default function GroupMoviesList({
movies,
onClick,
}: GroupMoviesListProps) {
const items = movies.map((elt) => (
<GroupMoviesListItem key={elt.id} item={elt} onClick={onClick} />
));
return <div className="list-group">{items}</div>;
}

View file

@ -1,43 +0,0 @@
import type { Movie } from "../../../types";
interface GroupMoviesListItemProps {
item: Movie;
onClick?: (movie: Movie) => void;
}
export default function GroupMoviesListItem({
item,
onClick,
}: GroupMoviesListItemProps) {
function handleOnClick(e: React.MouseEvent<HTMLElement>) {
e.stopPropagation();
e.preventDefault();
if (onClick) onClick(item);
}
return (
<button
onClick={handleOnClick}
type="submit"
className="list-group-item list-group-item-action flex-column align-items-start"
>
<div className="d-flex w-100 justify-content-between">
<h5 className="mb-1">{item.title}</h5>
<small>{item.release_date}</small>
</div>
<div className="row">
<div className="col-2">
<img
className="img-thumbnail"
src={`https://image.tmdb.org/t/p/w500${item.poster_path}`}
alt="lol"
/>
</div>
<div className="col-10">
<p className="mb-1">{item.overview}</p>
</div>
</div>
</button>
);
}

View file

@ -0,0 +1,15 @@
---
interface Props {
type?: "error" | "warning" | "success";
class?: string;
}
const { type = "error", class: className = "" } = Astro.props;
const typeClass = `message-${type}`;
---
<div class:list={["message", typeClass, className]}>
<slot />
</div>

View file

@ -0,0 +1,23 @@
---
interface Props {
type?: "button" | "submit" | "reset";
variant?: "default" | "primary" | "danger" | "warning";
class?: string;
fullWidth?: boolean;
}
const {
type = "button",
variant = "default",
class: className = "",
fullWidth = false,
} = Astro.props;
const variantClass = variant !== "default" ? `btn-${variant}` : "";
const widthClass = fullWidth ? "w-100" : "";
---
<button type={type} class:list={["btn", variantClass, widthClass, className]}>
<slot />
</button>

View file

@ -0,0 +1,30 @@
---
interface Props {
class?: string;
padding?: "sm" | "md" | "lg";
}
const { class: className = "", padding = "md" } = Astro.props;
const paddingMap = {
sm: "1rem",
md: "1.5rem",
lg: "2rem",
};
---
<div class:list={["card", className]} style={`padding: ${paddingMap[padding]}`}>
<slot />
</div>
<style>
.card :global(h2) {
background: var(--primary-gradient);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
font-size: 1.75rem;
margin-bottom: 1.5rem;
}
</style>

View file

@ -0,0 +1,68 @@
---
interface Props {
label?: string;
type?: "text" | "password" | "email" | "number";
name: string;
id?: string;
placeholder?: string;
value?: string | number;
required?: boolean;
error?: string;
class?: string;
}
const {
label,
type = "text",
name,
id = name,
placeholder = "",
value = "",
required = false,
error,
class: className = "",
} = Astro.props;
---
<div class:list={["form-group", className]}>
{
label && (
<label class="form-label" for={id}>
{label}
{required && <span class="required">*</span>}
</label>
)
}
<input
type={type}
id={id}
name={name}
class:list={["input", { "input-error": error }]}
placeholder={placeholder}
value={value}
required={required}
/>
{error && <div class="input-error-message">{error}</div>}
</div>
<style>
.required {
color: var(--error-color);
margin-left: 0.25rem;
}
.input-error {
border-color: var(--error-color);
}
.input-error:focus {
border-color: var(--error-color);
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.2);
}
.input-error-message {
color: var(--error-color);
font-size: 0.875rem;
margin-top: 0.5rem;
}
</style>

1
src/env.d.ts vendored
View file

@ -1,3 +1,4 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" /> /// <reference types="astro/client" />
declare namespace App { declare namespace App {

View file

@ -9,9 +9,17 @@ const { title } = Astro.props;
--- ---
<RootLayout title={title}> <RootLayout title={title}>
<div style="height: 100%;" class="row justify-content-center"> <div class="empty-layout">
<div class="col-auto align-self-center"> <slot />
<slot />
</div>
</div> </div>
</RootLayout> </RootLayout>
<style>
.empty-layout {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
}
</style>

View file

@ -20,18 +20,3 @@ const { title } = Astro.props;
</div> </div>
</div> </div>
</RootLayout> </RootLayout>
<script>
const logoutElt = document.getElementById("logout-btn");
logoutElt?.addEventListener("click", () => {
fetch(`${import.meta.env.PUBLIC_API_URL}/accounts/logout`, {
method: "POST",
credentials: "include",
})
.then(() => {
window.location.href = "/";
})
.catch((err) => console.error(err));
});
</script>

View file

@ -1,12 +1,24 @@
--- ---
const query = Astro.url.searchParams;
if (query.get("logout")) {
Astro.cookies.delete("jwt");
return Astro.redirect("/login");
}
interface Props { interface Props {
title: string; title: string;
} }
const { title } = Astro.props; const { title } = Astro.props;
import "bootstrap/dist/css/bootstrap.min.css"; // Import des styles
import "../styles/index.css"; import "../styles/index.css";
import "../styles/grid.css";
import "../styles/components/buttons.css";
import "../styles/components/cards.css";
import "../styles/components/forms.css";
import "../styles/components/alerts.css";
import "../styles/utils.css";
--- ---
<!doctype html> <!doctype html>
@ -33,7 +45,3 @@ import "../styles/index.css";
height: 100%; height: 100%;
} }
</style> </style>
<script>
import "bootstrap/dist/js/bootstrap";
</script>

View file

@ -3,12 +3,14 @@ import { defineMiddleware, sequence } from "astro:middleware";
const protectedRoutes = defineMiddleware(async (ctx, next) => { const protectedRoutes = defineMiddleware(async (ctx, next) => {
const pathname = ctx.url.pathname; const pathname = ctx.url.pathname;
if ( if (
!["/", "/register", "/logout"].includes(pathname) && !["/login", "/register"].includes(pathname) &&
!pathname.startsWith("/api") !pathname.startsWith("/api")
) { ) {
const redirect = `/login?redirect=${pathname}`;
const jwt = ctx.cookies.get("jwt"); const jwt = ctx.cookies.get("jwt");
if (!jwt) { if (!jwt) {
return ctx.redirect(`/?redirect=${pathname}`); return ctx.redirect(redirect);
} }
const jwtValue = jwt.value; const jwtValue = jwt.value;
@ -20,7 +22,7 @@ const protectedRoutes = defineMiddleware(async (ctx, next) => {
}); });
if (res.status !== 200) { if (res.status !== 200) {
return ctx.redirect(`/?redirect=${pathname}`); return ctx.redirect(redirect);
} }
ctx.locals.account = JSON.parse(await res.text()).account; ctx.locals.account = JSON.parse(await res.text()).account;

View file

@ -1,28 +0,0 @@
import type { APIRoute } from "astro";
export const POST: APIRoute = async ({ request, cookies }) => {
const reqBody = await request.text();
if (!reqBody) throw new Error("body not found");
const res = await fetch(`${import.meta.env.API_URL}/accounts/login`, {
method: "POST",
body: reqBody,
headers: {
"Content-Type": "application/json",
},
});
const resBody = await res.text();
const resBodyJson = JSON.parse(resBody);
if (res.status === 201 && resBodyJson?.jwt) {
cookies.set("jwt", resBodyJson.jwt, {
secure: true,
httpOnly: true,
path: "/",
sameSite: "strict",
});
}
return new Response(resBody, { status: res.status });
};

View file

@ -1,38 +0,0 @@
import type { APIRoute } from "astro";
export const POST: APIRoute = async ({ request, cookies }) => {
const jwt = cookies.get("jwt")?.value as string;
const reqBody = await request.text();
if (!reqBody) throw new Error("body not found");
const res = await fetch(`${import.meta.env.API_URL}/movies`, {
method: "POST",
body: reqBody,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${jwt}`,
},
});
const resBody = await res.text();
return new Response(resBody, { status: res.status });
};
export const GET: APIRoute = async ({ request, cookies }) => {
const jwt = cookies.get("jwt")?.value as string;
const url = new URL(request.url);
const res = await fetch(
`${import.meta.env.API_URL}/movies/search?q=${url.searchParams.get("query")}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${jwt}`,
},
},
);
const resBody = await res.text();
return new Response(resBody, { status: res.status });
};

View file

@ -1,20 +0,0 @@
import type { APIRoute } from "astro";
export const GET: APIRoute = async ({ request, cookies }) => {
const jwt = cookies.get("jwt")?.value as string;
const url = new URL(request.url);
const res = await fetch(
`${import.meta.env.API_URL}/tmdb/movies/search?query=${url.searchParams.get("query")}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${jwt}`,
},
},
);
const resBody = await res.text();
return new Response(resBody, { status: res.status });
};

View file

@ -1,43 +0,0 @@
---
import MoviesSearchInput from "../../components/MoviesSearchInput";
import CardMoviesList from "../../components/lists/movies/CardMoviesList";
import HomeLayout from "../../layouts/HomeLayout.astro";
import type { Movie } from "../../types";
const jwt = Astro.cookies.get("jwt")?.value as string;
const url = new URL(Astro.request.url);
const queryParams = new URLSearchParams();
const query = url.searchParams.get("q");
if (query) {
queryParams.append("q", query);
}
const res = await fetch(
`${import.meta.env.API_URL}/movies?${queryParams.toString()}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${jwt}`,
},
},
);
const resBody = (await res.json()) as { movies: Movie[] };
const account = Astro.locals.account;
---
<HomeLayout title="Movies">
<div class="row">
<div class="col">
<MoviesSearchInput searchUrl="/home" client:load />
</div>
</div>
<div class="row">
<div class="col">
<CardMoviesList movies={resBody?.movies || []} />
</div>
</div>
</HomeLayout>

View file

@ -1,61 +0,0 @@
---
import { FaEye } from "react-icons/fa";
import HomeLayout from "../../../layouts/HomeLayout.astro";
import type { Movie } from "../../../types";
const jwt = Astro.cookies.get("jwt")?.value as string;
const { id } = Astro.params;
const res = await fetch(`${import.meta.env.API_URL}/movies/${id}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${jwt}`,
},
});
const movie = (await res.json()).movie as Movie;
---
<HomeLayout title={movie.title}>
<>
<h1>{movie.title}</h1>
<div class="card">
<div
class="card-body"
style={{
background: `linear-gradient(to left, rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 1), rgba(0, 0, 0, 1)), url(${movie.backdrop_path})`,
backgroundPosition: "center",
backgroundSize: "cover",
minHeight: "512px",
}}
>
<div class="row" style={{ height: "512px" }}>
<div class="col align-self-center">
<p>{movie.overview}</p>
</div>
<div class="col align-self-center">
<div class="d-grid">
<a
href={`/home/movies/${movie.id}`}
class="btn btn-primary"
>
Watch
</a>
</div>
<hr />
<div class="d-grid">
<a
href={`/home/movies/${movie.id}`}
class="btn btn-secondary"
>
Share
</a>
</div>
</div>
</div>
</div>
</div>
</>
</HomeLayout>

View file

@ -1,34 +0,0 @@
---
import {
MdAccountBox,
MdAdminPanelSettings,
MdOutlineLocalMovies,
} from "react-icons/md";
import HomeLayout from "../../../layouts/HomeLayout.astro";
---
<HomeLayout title="Settings">
<div class="row justify-content-center">
<div class="col-4">
<div class="list-group">
<a
href="/home/settings/account"
class="list-group-item list-group-item-action"
>
<MdAccountBox /> My account
</a>
<a
href="/home/settings/accounts"
class="list-group-item list-group-item-action"
><MdAdminPanelSettings /> Accounts</a
>
<a
href="/home/settings/movies"
class="list-group-item list-group-item-action"
><MdOutlineLocalMovies /> Add movie</a
>
</div>
</div>
</div>
</HomeLayout>

View file

@ -1,20 +0,0 @@
---
import AddMovieForm from "../../../components/forms/AddMovieForm";
import HomeLayout from "../../../layouts/HomeLayout.astro";
---
<HomeLayout title="Add movie">
<div class="row justify-content-center">
<div class="col">
<div class="row">
<div class="col"><h1>Add movie</h1></div>
</div>
<div class="row">
<div class="col">
<h3>Search on TMDB Database</h3>
<AddMovieForm client:load />
</div>
</div>
</div>
</div>
</HomeLayout>

View file

@ -1,8 +1,7 @@
--- ---
import LoginFormData from "../components/forms/LoginFormData"; import HomeLayout from "../layouts/HomeLayout.astro";
import EmptyLayout from "../layouts/EmptyLayout.astro";
--- ---
<EmptyLayout title="Trepa - Connexion"> <HomeLayout title="Accueil">
<LoginFormData client:load /> <h1>Accueil</h1>
</EmptyLayout> </HomeLayout>

23
src/pages/login.astro Normal file
View file

@ -0,0 +1,23 @@
---
import LoginFormData from "../components/forms/login/LoginFormData.astro";
import EmptyLayout from "../layouts/EmptyLayout.astro";
import Card from "../components/ui/Card.astro";
---
<EmptyLayout title="Connexion">
<div class="container">
<div class="row justify-content-center">
<div class="col-sm-12 col-md-6 col-lg-4">
<Card>
<LoginFormData />
</Card>
</div>
</div>
</div>
</EmptyLayout>
<style>
.container {
padding: 2rem;
}
</style>

View file

@ -1,4 +0,0 @@
---
Astro.cookies.delete("jwt");
return Astro.redirect("/");
---

View file

@ -0,0 +1,24 @@
/* Styles des messages d'alerte */
.message {
padding: 1rem;
border-radius: var(--radius-md);
margin-bottom: 1rem;
}
.message-error {
background: rgba(255, 107, 107, 0.1);
border: 1px solid var(--error-color);
color: var(--error-color);
}
.message-warning {
background: rgba(255, 217, 61, 0.1);
border: 1px solid var(--warning-color);
color: var(--warning-color);
}
.message-success {
background: rgba(107, 203, 119, 0.1);
border: 1px solid var(--success-color);
color: var(--success-color);
}

View file

@ -0,0 +1,33 @@
/* Styles des boutons */
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: var(--radius-md);
font-weight: 500;
cursor: pointer;
transition: all var(--transition-speed);
background: var(--background-card);
color: var(--text-secondary);
backdrop-filter: var(--glass-blur);
}
.btn:hover {
opacity: 0.75;
}
.btn-primary {
background: var(--primary-gradient);
color: var(--background-dark);
font-weight: 600;
}
.btn-danger {
background: var(--error-color);
color: var(--text-primary);
}
.btn-warning {
background: var(--warning-color);
color: var(--background-dark);
font-weight: 600;
}

View file

@ -0,0 +1,8 @@
/* Styles des cartes */
.card {
background: var(--background-card);
border-radius: var(--radius-lg);
padding: 1.5rem;
backdrop-filter: var(--glass-blur);
border: 1px solid rgba(255, 255, 255, 0.1);
}

View file

@ -0,0 +1,27 @@
/* Styles des formulaires */
.input {
padding: 0.75rem 1rem;
background: var(--background-card);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-md);
color: var(--text-primary);
backdrop-filter: var(--glass-blur);
width: 100%;
transition: all var(--transition-speed);
}
.input:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(134, 227, 206, 0.2);
}
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-secondary);
}

260
src/styles/grid.css Normal file
View file

@ -0,0 +1,260 @@
/* Système de grille */
.container {
width: 100%;
margin-right: auto;
margin-left: auto;
padding-right: var(--container-padding);
padding-left: var(--container-padding);
}
@media (min-width: 640px) {
.container {
max-width: var(--breakpoint-sm);
}
}
@media (min-width: 768px) {
.container {
max-width: var(--breakpoint-md);
}
}
@media (min-width: 1024px) {
.container {
max-width: var(--breakpoint-lg);
}
}
@media (min-width: 1280px) {
.container {
max-width: var(--breakpoint-xl);
}
}
.row {
display: flex;
flex-wrap: wrap;
margin-right: calc(-1 * var(--grid-gutter));
margin-left: calc(-1 * var(--grid-gutter));
}
[class^="col-"] {
position: relative;
width: 100%;
padding-right: var(--grid-gutter);
padding-left: var(--grid-gutter);
}
/* Colonnes */
.col {
flex: 1 0 0%;
}
.col-auto {
flex: 0 0 auto;
width: auto;
}
/* Small (sm) */
@media (min-width: 640px) {
.col-sm-1 {
flex: 0 0 var(--col-1);
max-width: var(--col-1);
}
.col-sm-2 {
flex: 0 0 var(--col-2);
max-width: var(--col-2);
}
.col-sm-3 {
flex: 0 0 var(--col-3);
max-width: var(--col-3);
}
.col-sm-4 {
flex: 0 0 var(--col-4);
max-width: var(--col-4);
}
.col-sm-5 {
flex: 0 0 var(--col-5);
max-width: var(--col-5);
}
.col-sm-6 {
flex: 0 0 var(--col-6);
max-width: var(--col-6);
}
.col-sm-7 {
flex: 0 0 var(--col-7);
max-width: var(--col-7);
}
.col-sm-8 {
flex: 0 0 var(--col-8);
max-width: var(--col-8);
}
.col-sm-9 {
flex: 0 0 var(--col-9);
max-width: var(--col-9);
}
.col-sm-10 {
flex: 0 0 var(--col-10);
max-width: var(--col-10);
}
.col-sm-11 {
flex: 0 0 var(--col-11);
max-width: var(--col-11);
}
.col-sm-12 {
flex: 0 0 var(--col-12);
max-width: var(--col-12);
}
}
/* Medium (md) */
@media (min-width: 768px) {
.col-md-1 {
flex: 0 0 var(--col-1);
max-width: var(--col-1);
}
.col-md-2 {
flex: 0 0 var(--col-2);
max-width: var(--col-2);
}
.col-md-3 {
flex: 0 0 var(--col-3);
max-width: var(--col-3);
}
.col-md-4 {
flex: 0 0 var(--col-4);
max-width: var(--col-4);
}
.col-md-5 {
flex: 0 0 var(--col-5);
max-width: var(--col-5);
}
.col-md-6 {
flex: 0 0 var(--col-6);
max-width: var(--col-6);
}
.col-md-7 {
flex: 0 0 var(--col-7);
max-width: var(--col-7);
}
.col-md-8 {
flex: 0 0 var(--col-8);
max-width: var(--col-8);
}
.col-md-9 {
flex: 0 0 var(--col-9);
max-width: var(--col-9);
}
.col-md-10 {
flex: 0 0 var(--col-10);
max-width: var(--col-10);
}
.col-md-11 {
flex: 0 0 var(--col-11);
max-width: var(--col-11);
}
.col-md-12 {
flex: 0 0 var(--col-12);
max-width: var(--col-12);
}
}
/* Large (lg) */
@media (min-width: 1024px) {
.col-lg-1 {
flex: 0 0 var(--col-1);
max-width: var(--col-1);
}
.col-lg-2 {
flex: 0 0 var(--col-2);
max-width: var(--col-2);
}
.col-lg-3 {
flex: 0 0 var(--col-3);
max-width: var(--col-3);
}
.col-lg-4 {
flex: 0 0 var(--col-4);
max-width: var(--col-4);
}
.col-lg-5 {
flex: 0 0 var(--col-5);
max-width: var(--col-5);
}
.col-lg-6 {
flex: 0 0 var(--col-6);
max-width: var(--col-6);
}
.col-lg-7 {
flex: 0 0 var(--col-7);
max-width: var(--col-7);
}
.col-lg-8 {
flex: 0 0 var(--col-8);
max-width: var(--col-8);
}
.col-lg-9 {
flex: 0 0 var(--col-9);
max-width: var(--col-9);
}
.col-lg-10 {
flex: 0 0 var(--col-10);
max-width: var(--col-10);
}
.col-lg-11 {
flex: 0 0 var(--col-11);
max-width: var(--col-11);
}
.col-lg-12 {
flex: 0 0 var(--col-12);
max-width: var(--col-12);
}
}
/* Extra Large (xl) */
@media (min-width: 1280px) {
.col-xl-1 {
flex: 0 0 var(--col-1);
max-width: var(--col-1);
}
.col-xl-2 {
flex: 0 0 var(--col-2);
max-width: var(--col-2);
}
.col-xl-3 {
flex: 0 0 var(--col-3);
max-width: var(--col-3);
}
.col-xl-4 {
flex: 0 0 var(--col-4);
max-width: var(--col-4);
}
.col-xl-5 {
flex: 0 0 var(--col-5);
max-width: var(--col-5);
}
.col-xl-6 {
flex: 0 0 var(--col-6);
max-width: var(--col-6);
}
.col-xl-7 {
flex: 0 0 var(--col-7);
max-width: var(--col-7);
}
.col-xl-8 {
flex: 0 0 var(--col-8);
max-width: var(--col-8);
}
.col-xl-9 {
flex: 0 0 var(--col-9);
max-width: var(--col-9);
}
.col-xl-10 {
flex: 0 0 var(--col-10);
max-width: var(--col-10);
}
.col-xl-11 {
flex: 0 0 var(--col-11);
max-width: var(--col-11);
}
.col-xl-12 {
flex: 0 0 var(--col-12);
max-width: var(--col-12);
}
}

View file

@ -1,27 +1,62 @@
.cardshadow { /* Variables globales */
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.4); :root {
transition: box-shadow 0.2s ease-in-out; /* Couleurs principales */
--primary-gradient: linear-gradient(135deg, #86e3ce 0%, #d6a4e5 100%);
--background-dark: #1a1b1e;
--background-card: rgba(255, 255, 255, 0.05);
--text-primary: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.7);
--accent-color: #86e3ce;
--error-color: #ff6b6b;
--warning-color: #ffd93d;
--success-color: #6bcb77;
/* Effets */
--glass-blur: blur(10px);
--card-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
--transition-speed: 0.3s;
/* Border radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 16px;
/* Grid */
--grid-gutter: 1rem;
--container-padding: 1rem;
/* Breakpoints */
--breakpoint-sm: 640px;
--breakpoint-md: 768px;
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
/* Column widths */
--col-1: 8.333333%;
--col-2: 16.666667%;
--col-3: 25%;
--col-4: 33.333333%;
--col-5: 41.666667%;
--col-6: 50%;
--col-7: 58.333333%;
--col-8: 66.666667%;
--col-9: 75%;
--col-10: 83.333333%;
--col-11: 91.666667%;
--col-12: 100%;
} }
.cardshadow:hover { /* Reset et styles de base */
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.8); * {
margin: 0;
padding: 0;
box-sizing: border-box;
} }
.movieitem { body {
opacity: 0; background-color: var(--background-dark);
background-color: rgba(0, 0, 0, 0.75); color: var(--text-primary);
color: rgba(255, 255, 255, 1); font-family: "Inter", system-ui, sans-serif;
} line-height: 1.5;
min-height: 100vh;
.movieitem:hover {
animation: 0.2s ease-in-out forwards moviehover;
}
@keyframes moviehover {
from {
opacity: 0;
}
to {
opacity: 1;
}
} }

89
src/styles/utils.css Normal file
View file

@ -0,0 +1,89 @@
/* Classes utilitaires */
/* Flex */
.d-flex {
display: flex;
}
.flex-column {
flex-direction: column;
}
.justify-content-center {
justify-content: center;
}
.align-items-center {
align-items: center;
}
/* Text */
.text-center {
text-align: center;
}
/* Dimensions */
.w-100 {
width: 100%;
}
.h-100 {
height: 100%;
}
/* Marges */
.mx-auto {
margin-left: auto;
margin-right: auto;
}
.my-1 {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
.my-2 {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.my-3 {
margin-top: 1rem;
margin-bottom: 1rem;
}
.my-4 {
margin-top: 1.5rem;
margin-bottom: 1.5rem;
}
.my-5 {
margin-top: 2rem;
margin-bottom: 2rem;
}
/* Séparateur */
hr {
border: none;
height: 1px;
margin: 2rem 0;
background: linear-gradient(
90deg,
transparent,
var(--text-secondary),
transparent
);
opacity: 0.2;
}
/* Animations */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-in {
animation: fadeIn 0.5s ease-out;
}

View file

@ -1,3 +1,7 @@
export interface ObjectStats {
size: number;
}
export interface Movie { export interface Movie {
id: number; id: number;
title: string; title: string;
@ -7,6 +11,15 @@ export interface Movie {
release_date: string; release_date: string;
} }
export interface MovieDetail {
movie: Movie;
stats?: ObjectStats;
}
export type SearchMovie = {
ready: boolean;
} & Movie;
export interface CreateMovie { export interface CreateMovie {
title: string; title: string;
overview: string; overview: string;