minio #1
51 changed files with 956 additions and 957 deletions
7
.devcontainer/Dockerfile
Normal file
7
.devcontainer/Dockerfile
Normal 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
|
18
.devcontainer/devcontainer.json
Normal file
18
.devcontainer/devcontainer.json
Normal 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"]
|
||||||
|
}
|
|
@ -10,4 +10,7 @@ export default defineConfig({
|
||||||
adapter: node({
|
adapter: node({
|
||||||
mode: "standalone",
|
mode: "standalone",
|
||||||
}),
|
}),
|
||||||
|
server: {
|
||||||
|
port: 8080
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
BIN
bun.lockb
Binary file not shown.
17
package.json
17
package.json
|
@ -9,19 +9,18 @@
|
||||||
"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.2.1",
|
"react-icons": "^5.5.0",
|
||||||
"typescript": "^5.5.3"
|
"typescript": "^5.8.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "1.8.3"
|
"@biomejs/biome": "1.9.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
56
src/components/Logo.astro
Normal 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>
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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} />;
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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">×</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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
56
src/components/forms/login/LoginForm.astro
Normal file
56
src/components/forms/login/LoginForm.astro
Normal 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>
|
86
src/components/forms/login/LoginFormData.astro
Normal file
86
src/components/forms/login/LoginFormData.astro
Normal 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} />
|
|
@ -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>;
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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>;
|
|
||||||
}
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
15
src/components/ui/Alert.astro
Normal file
15
src/components/ui/Alert.astro
Normal 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>
|
||||||
|
|
23
src/components/ui/Button.astro
Normal file
23
src/components/ui/Button.astro
Normal 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>
|
||||||
|
|
30
src/components/ui/Card.astro
Normal file
30
src/components/ui/Card.astro
Normal 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>
|
||||||
|
|
68
src/components/ui/Input.astro
Normal file
68
src/components/ui/Input.astro
Normal 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
1
src/env.d.ts
vendored
|
@ -1,3 +1,4 @@
|
||||||
|
/// <reference path="../.astro/types.d.ts" />
|
||||||
/// <reference types="astro/client" />
|
/// <reference types="astro/client" />
|
||||||
|
|
||||||
declare namespace App {
|
declare namespace App {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
||||||
|
|
|
@ -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>
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 });
|
|
||||||
};
|
|
|
@ -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 });
|
|
||||||
};
|
|
|
@ -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 });
|
|
||||||
};
|
|
|
@ -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>
|
|
|
@ -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>
|
|
|
@ -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>
|
|
|
@ -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>
|
|
|
@ -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
23
src/pages/login.astro
Normal 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>
|
|
@ -1,4 +0,0 @@
|
||||||
---
|
|
||||||
Astro.cookies.delete("jwt");
|
|
||||||
return Astro.redirect("/");
|
|
||||||
---
|
|
24
src/styles/components/alerts.css
Normal file
24
src/styles/components/alerts.css
Normal 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);
|
||||||
|
}
|
33
src/styles/components/buttons.css
Normal file
33
src/styles/components/buttons.css
Normal 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;
|
||||||
|
}
|
8
src/styles/components/cards.css
Normal file
8
src/styles/components/cards.css
Normal 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);
|
||||||
|
}
|
27
src/styles/components/forms.css
Normal file
27
src/styles/components/forms.css
Normal 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
260
src/styles/grid.css
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
89
src/styles/utils.css
Normal 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;
|
||||||
|
}
|
13
src/types.ts
13
src/types.ts
|
@ -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;
|
||||||
|
|
Loading…
Reference in a new issue