minio #1
38 changed files with 164 additions and 933 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({
|
||||
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": {
|
||||
"enabled": true
|
||||
},
|
||||
|
|
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
16
package.json
16
package.json
|
@ -9,19 +9,19 @@
|
|||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.8.2",
|
||||
"@astrojs/node": "^8.3.2",
|
||||
"@astrojs/react": "^3.6.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/node": "^9.1.3",
|
||||
"@astrojs/react": "^4.2.2",
|
||||
"@types/react": "^18.3.5",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"astro": "^4.12.2",
|
||||
"astro": "^5.5.5",
|
||||
"bootstrap": "^5.3.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-icons": "^5.2.1",
|
||||
"typescript": "^5.5.4"
|
||||
"react-icons": "^5.5.0",
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"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}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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}?query=${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>
|
||||
</>
|
||||
);
|
||||
}
|
44
src/components/forms/login/LoginForm.astro
Normal file
44
src/components/forms/login/LoginForm.astro
Normal file
|
@ -0,0 +1,44 @@
|
|||
---
|
||||
interface Props {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const { error } = Astro.props as Props;
|
||||
---
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
{
|
||||
error && (
|
||||
<div class="alert alert-danger" style="text-align: center">
|
||||
{error}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<form method="post" action="/login">
|
||||
<div class="form-floating mb-3">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
id="username"
|
||||
name="username"
|
||||
/>
|
||||
<label class="form-label">Nom d'utilisateur</label>
|
||||
</div>
|
||||
<div class="form-floating mb-3">
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
id="password"
|
||||
name="password"
|
||||
/>
|
||||
<label class="form-label">Mot de passe</label>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Connexion
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
51
src/components/forms/login/LoginFormData.astro
Normal file
51
src/components/forms/login/LoginFormData.astro
Normal file
|
@ -0,0 +1,51 @@
|
|||
---
|
||||
import LoginForm from "./LoginForm.astro";
|
||||
|
||||
const req = Astro.request;
|
||||
const isPost = req.method === "POST";
|
||||
|
||||
let username = "";
|
||||
let password = "";
|
||||
let error = null;
|
||||
|
||||
if (isPost) {
|
||||
const form = await req.formData();
|
||||
username = form.get("username")?.toString().trim() ?? "";
|
||||
password = form.get("password")?.toString().trim() ?? "";
|
||||
}
|
||||
|
||||
if (!username || !password) {
|
||||
error = "Tous les champs sont requis";
|
||||
} else {
|
||||
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) {
|
||||
if (res.status === 401) {
|
||||
error = "Identifiants invalides";
|
||||
} else {
|
||||
error = "Une erreur est survenue";
|
||||
}
|
||||
} else {
|
||||
const data = await res.json();
|
||||
Astro.cookies.set("jwt", data.jwt, {
|
||||
path: "/",
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
});
|
||||
|
||||
return Astro.redirect("/");
|
||||
}
|
||||
} catch (err) {
|
||||
error = "Une erreur est survenue";
|
||||
}
|
||||
}
|
||||
---
|
||||
|
||||
<LoginForm error={error} />
|
|
@ -1,18 +0,0 @@
|
|||
import type { MovieDetail } from "../../../types";
|
||||
import CardMoviesListItem from "./CardMoviesListItem";
|
||||
|
||||
interface CardMoviesListProps {
|
||||
movies: MovieDetail[];
|
||||
}
|
||||
|
||||
export default function CardMoviesList({ movies }: CardMoviesListProps) {
|
||||
const items = movies.map((elt) => {
|
||||
return (
|
||||
<div key={elt.movie.id} className="col-2 mb-4">
|
||||
<CardMoviesListItem movie={elt} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
return <div className="row">{items}</div>;
|
||||
}
|
|
@ -1,61 +0,0 @@
|
|||
import { FaEye } from "react-icons/fa";
|
||||
import type { Movie, MovieDetail } from "../../../types";
|
||||
|
||||
interface CardMoviesListItemProps {
|
||||
movie: MovieDetail;
|
||||
}
|
||||
|
||||
export default function CardMoviesListItem({ movie }: CardMoviesListItemProps) {
|
||||
return (
|
||||
<div
|
||||
className="cardshadow"
|
||||
style={{
|
||||
backgroundImage: `url(${movie.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.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">
|
||||
{movie.stats ? (
|
||||
<span className="badge text-bg-success mb-2">
|
||||
Disponible
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge text-bg-warning mb-2">
|
||||
Bientot dispo.
|
||||
</span>
|
||||
)}
|
||||
|
||||
<br />
|
||||
<a
|
||||
href={`/home/movies/${movie.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>
|
||||
);
|
||||
}
|
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" />
|
||||
|
||||
declare namespace App {
|
||||
|
|
|
@ -9,9 +9,14 @@ const { title } = Astro.props;
|
|||
---
|
||||
|
||||
<RootLayout title={title}>
|
||||
<div style="height: 100%;" class="row justify-content-center">
|
||||
<div class="col-auto align-self-center">
|
||||
<div
|
||||
class="container-fluid d-flex align-items-center justify-content-center"
|
||||
style="min-height: 100vh;"
|
||||
>
|
||||
<div class="row">
|
||||
<div class="col-auto">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RootLayout>
|
||||
|
|
|
@ -20,18 +20,3 @@ const { title } = Astro.props;
|
|||
</div>
|
||||
</div>
|
||||
</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,4 +1,10 @@
|
|||
---
|
||||
const query = Astro.url.searchParams;
|
||||
if (query.get("logout")) {
|
||||
Astro.cookies.delete("jwt");
|
||||
return Astro.redirect("/login");
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
|
|
|
@ -3,12 +3,14 @@ import { defineMiddleware, sequence } from "astro:middleware";
|
|||
const protectedRoutes = defineMiddleware(async (ctx, next) => {
|
||||
const pathname = ctx.url.pathname;
|
||||
if (
|
||||
!["/", "/register", "/logout"].includes(pathname) &&
|
||||
!["/login", "/register"].includes(pathname) &&
|
||||
!pathname.startsWith("/api")
|
||||
) {
|
||||
const redirect = `/login?redirect=${pathname}`;
|
||||
|
||||
const jwt = ctx.cookies.get("jwt");
|
||||
if (!jwt) {
|
||||
return ctx.redirect(`/?redirect=${pathname}`);
|
||||
return ctx.redirect(redirect);
|
||||
}
|
||||
|
||||
const jwtValue = jwt.value;
|
||||
|
@ -20,7 +22,7 @@ const protectedRoutes = defineMiddleware(async (ctx, next) => {
|
|||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
return ctx.redirect(`/?redirect=${pathname}`);
|
||||
return ctx.redirect(redirect);
|
||||
}
|
||||
|
||||
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,46 +0,0 @@
|
|||
---
|
||||
import MoviesSearchInput from "../../components/MoviesSearchInput";
|
||||
import CardMoviesList from "../../components/lists/movies/CardMoviesList";
|
||||
import HomeLayout from "../../layouts/HomeLayout.astro";
|
||||
import type { MovieDetail } from "../../types";
|
||||
|
||||
const jwt = Astro.cookies.get("jwt")?.value as string;
|
||||
|
||||
const url = new URL(Astro.request.url);
|
||||
const query = url.searchParams.get("query");
|
||||
const limit = url.searchParams.get("limit") ?? "18";
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
limit: limit,
|
||||
});
|
||||
|
||||
if (query) {
|
||||
queryParams.append("query", 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: MovieDetail[] };
|
||||
---
|
||||
|
||||
<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,68 +0,0 @@
|
|||
---
|
||||
import HomeLayout from "../../../layouts/HomeLayout.astro";
|
||||
import type { Movie, MovieDetail } 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 item = (await res.json()).movie as MovieDetail;
|
||||
---
|
||||
|
||||
<HomeLayout title={item.movie.title}>
|
||||
<>
|
||||
<h1>{item.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(${item.movie.backdrop_path})`,
|
||||
backgroundPosition: "center",
|
||||
backgroundSize: "cover",
|
||||
minHeight: "512px",
|
||||
}}
|
||||
>
|
||||
<div class="row" style={{ height: "512px" }}>
|
||||
<div class="col align-self-center">
|
||||
<p>{item.movie.overview}</p>
|
||||
</div>
|
||||
<div class="col align-self-center">
|
||||
<div class="d-grid">
|
||||
{
|
||||
item.stats ? (
|
||||
<a
|
||||
href={`/home/movies/${item.movie.id}`}
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Watch
|
||||
</a>
|
||||
) : (
|
||||
<span class="badge text-bg-warning">
|
||||
Bientot disponible
|
||||
</span>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<hr />
|
||||
|
||||
<div class="d-grid">
|
||||
<a
|
||||
href={`/home/movies/${item.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 EmptyLayout from "../layouts/EmptyLayout.astro";
|
||||
import HomeLayout from "../layouts/HomeLayout.astro";
|
||||
---
|
||||
|
||||
<EmptyLayout title="Trepa - Connexion">
|
||||
<LoginFormData client:load />
|
||||
</EmptyLayout>
|
||||
<HomeLayout title="Accueil">
|
||||
<h1>Accueil</h1>
|
||||
</HomeLayout>
|
||||
|
|
8
src/pages/login.astro
Normal file
8
src/pages/login.astro
Normal file
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
import LoginFormData from "../components/forms/login/LoginFormData.astro";
|
||||
import EmptyLayout from "../layouts/EmptyLayout.astro";
|
||||
---
|
||||
|
||||
<EmptyLayout title="Trepa - Connexion">
|
||||
<LoginFormData />
|
||||
</EmptyLayout>
|
|
@ -1,4 +0,0 @@
|
|||
---
|
||||
Astro.cookies.delete("jwt");
|
||||
return Astro.redirect("/");
|
||||
---
|
Loading…
Reference in a new issue