Shortcodes

This commit is contained in:
cdricms
2025-02-10 08:52:32 +01:00
parent a7ad045631
commit 8e87d834bc
20 changed files with 485 additions and 71 deletions

View File

@@ -36,7 +36,8 @@ func HandleMedia(w http.ResponseWriter, r *http.Request) {
Model((*models.Media)(nil)).
Count(context.Background())
totalPages := int(math.Max(1, float64(total/limit)))
upperBound := float64(total) / float64(limit)
totalPages := int(math.Max(1, math.Ceil(upperBound)))
var media []models.Media
err = core.DB.NewSelect().

View File

@@ -1,3 +1,52 @@
package media
// TODO
import (
"context"
"encoding/json"
"net/http"
"fr.latosa-escrima/core"
"fr.latosa-escrima/core/models"
"github.com/google/uuid"
)
func HandleUpdate(w http.ResponseWriter, r *http.Request) {
var media models.Media
err := json.NewDecoder(r.Body).Decode(&media)
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusBadRequest)
return
}
media_uuid := r.PathValue("media_uuid")
media.ID, err = uuid.Parse(media_uuid)
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusBadRequest)
return
}
_, err = core.DB.NewUpdate().
Model(&media).
OmitZero().
WherePK().
Exec(context.Background())
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
core.JSONSuccess{
Status: core.Success,
Message: "Media updated",
Data: media,
}.Respond(w, http.StatusOK)
}

View File

@@ -28,10 +28,10 @@ var MediaRoutes = map[string]core.Handler{
Handler: media.HandleMediaFile,
Middlewares: []core.Middleware{Methods("GET")},
},
// "/media/{media_uuid}/update": {
// Handler: HandleGetMediaFile,
// Middlewares: []core.Middleware{Methods("PATCH"), AuthJWT},
// },
"/media/{media_uuid}/update": {
Handler: media.HandleUpdate,
Middlewares: []core.Middleware{Methods("PATCH"), AuthJWT},
},
"/media/{media_uuid}/delete": {
Handler: media.HandleDelete,
Middlewares: []core.Middleware{Methods("DELETE"), AuthJWT},

View File

@@ -2,6 +2,7 @@ package shortcodes
import (
"context"
"fmt"
"net/http"
"fr.latosa-escrima/core"
@@ -14,6 +15,7 @@ func HandleShortcode(w http.ResponseWriter, r *http.Request) {
err := core.DB.NewSelect().
Model(&shortcode).
Where("code = ?", code).
Relation("Media").
Limit(1).
Scan(context.Background())
if err != nil {
@@ -24,6 +26,17 @@ func HandleShortcode(w http.ResponseWriter, r *http.Request) {
return
}
scheme := "http"
if r.TLS != nil { // Check if the request is over HTTPS
scheme = "https"
}
// Extract the host
host := r.Host
baseURL := fmt.Sprintf("%s://%s", scheme, host)
if shortcode.Media != nil {
shortcode.Media.URL = fmt.Sprintf("%s/media/%s/file", baseURL, shortcode.Media.ID)
}
core.JSONSuccess{
Status: core.Success,
Message: "Shortcode found",

View File

@@ -0,0 +1 @@
ALTER TABLE events DROP COLUMN title;

View File

@@ -0,0 +1 @@
ALTER TABLE events ADD COLUMN title text not null default '';

View File

@@ -18,7 +18,7 @@ type Event struct {
bun.BaseModel `bun:"table:events"`
EventID uuid.UUID `bun:"event_id,type:uuid,pk,default:gen_random_uuid()" json:"id"`
Title string `bun:"title,notnull" json:"title"`
Title string `bun:"title,notnull" json:"title"`
CreationDate time.Time `bun:"creation_date,notnull,default:current_timestamp" json:"creationDate"`
ScheduleStart time.Time `bun:"schedule_start,notnull" json:"start"`
ScheduleEnd time.Time `bun:"schedule_end,notnull" json:"end"`

View File

@@ -0,0 +1,141 @@
"use client";
import { Textarea } from "@/components/ui/textarea";
import { useEffect, useRef, useState } from "react";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { Button } from "@/components/ui/button";
import { Bold, Italic, Link, Strikethrough, Underline } from "lucide-react";
enum Command {
Italic = "*",
Bold = "**",
Strikethrough = "~~",
Underline = "__",
}
export default function NewBlog() {
const ref = useRef<HTMLTextAreaElement>(null);
const [text, setText] = useState("");
const [cursor, setCursor] = useState<{ line: number; column: number }>({
line: 0,
column: 0,
});
const [selection, setSelection] = useState<{
start: number;
end: number;
} | null>(null);
const getCursorPosition = (
event: React.ChangeEvent<HTMLTextAreaElement>,
) => {
const textarea = event.target;
const text = textarea.value;
const cursorPos = textarea.selectionStart;
const lines = text.substring(0, cursorPos).split("\n");
const line = lines.length; // Current line number (1-based)
const column = lines[lines.length - 1].length + 1; // Current column (1-based)
setCursor({ line, column });
};
const onSelect = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const { selectionStart, selectionEnd } = event.currentTarget;
if (selectionStart === selectionEnd) return;
setSelection({ start: selectionStart, end: selectionEnd });
};
useEffect(() => {
setSelection(null);
}, [text]);
const moveCursor = (newPos: number) => {
if (!ref.current) return;
ref.current.selectionEnd = newPos;
ref.current.focus();
};
const execCommand = (command: Command, symetry: boolean = true) => {
if (selection) {
const selectedText = text.substring(selection.start, selection.end);
const pre = text.slice(0, selection.start);
const post = text.slice(selection.end);
const newSelectedText = `${command}${selectedText}${symetry ? command : ""}`;
setText(pre + newSelectedText + post);
return;
}
const pre = text.slice(0, cursor.column);
const post = text.slice(cursor.column);
if (!symetry) setText(pre + command + post);
else {
const t = pre + command + command + post;
setText(t);
}
console.log(pre.length + command.length);
moveCursor(cursor.column + 2);
};
return (
<section className="flex">
<div>
<div className="flex gap-2 mb-2 border-b pb-2">
<Button
variant="outline"
size="icon"
onClick={() => execCommand(Command.Bold)}
>
<Bold size={16} />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => execCommand(Command.Italic)}
>
<Italic size={16} />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => execCommand(Command.Underline)}
>
<Underline size={16} />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => execCommand(Command.Strikethrough)}
>
<Strikethrough size={16} />
</Button>
{/*<Button variant="outline" size="icon" onClick={handleLink}>
<Link size={16} />
</Button> */}
</div>
<Textarea
ref={ref}
value={text}
onSelect={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
getCursorPosition(e);
onSelect(e);
}}
onChange={(e) => setText(e.currentTarget.value)}
/>
<div>
Line: {cursor.line}; Column: {cursor.column}
<br />
Selection: Start {selection?.start} End {selection?.end}
</div>
</div>
<div
className="mt-4 p-2 bg-gray-100 border rounded-md text-sm text-black"
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(marked(text, { async: false })),
}}
></div>
</section>
);
}

View File

@@ -123,7 +123,7 @@ export default function UserDetailsPage() {
}}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select an organization" />
<SelectValue placeholder="Sélectionner un rôle" />
</SelectTrigger>
<SelectContent>
{availableRoles.data

View File

@@ -1,30 +0,0 @@
"use client";
import useFileUpload from "@/hooks/use-file-upload";
import { ChangeEvent } from "react";
const MyComponent = () => {
const { progress, isUploading, error, uploadFile, cancelUpload } =
useFileUpload();
const handleFileUpload = (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
uploadFile(file, "/media/upload", (response) => {
console.log("Upload success:", response);
});
}
};
return (
<div>
<input type="file" onChange={handleFileUpload} />
{isUploading && <p>Uploading... {progress}%</p>}
{error && <p>Error: {error}</p>}
<button onClick={cancelUpload} disabled={!isUploading}>
Cancel Upload
</button>
</div>
);
};
export default MyComponent;

View File

@@ -16,7 +16,6 @@ import { PhotoDialog } from "@/components/photo-dialog";
import useFileUpload from "@/hooks/use-file-upload";
import useMedia from "@/hooks/use-media";
import Media from "@/interfaces/Media";
import useApiMutation from "@/hooks/use-api";
import request from "@/lib/request";
export default function PhotoGallery() {
@@ -41,8 +40,22 @@ export default function PhotoGallery() {
});
};
const handleUpdatePhoto = (updatedPhoto: Omit<Media, "id">) => {
const handleUpdatePhoto = async (
body: Media | Omit<Media, "id">,
file: File,
) => {
if (selectedPhoto) {
const res = await request<Media>(
`/media/${selectedPhoto.id}/update`,
{
method: "PATCH",
requiresAuth: true,
body,
},
);
if (res.status === "Success") {
mutate();
}
}
setSelectedPhoto(null);
};

View File

@@ -7,6 +7,7 @@ import Testimonial from "@/components/testimonial";
import { CarouselItem } from "@/components/ui/carousel";
import YouTubeEmbed from "@/components/youtube-embed";
import { IYoutube } from "@/interfaces/youtube";
import getShortcode from "@/lib/getShortcode";
export default async function Home() {
let videos: IYoutube | null = null;
@@ -15,9 +16,18 @@ export default async function Home() {
const res = await fetch(query);
videos = await res.json();
}
const hero = await getShortcode("hero_image");
const systemEvolution = await getShortcode("evolution_systeme");
const fondations = await getShortcode("fondements");
const todaysPrinciples = await getShortcode("aujourdhui");
return (
<main>
<Hero />
<Hero
background={
hero.media?.url ??
"https://shadcnblocks.com/images/block/placeholder-2.svg"
}
/>
<div className="p-12">
<YouTubeEmbed
loadIframe
@@ -36,7 +46,10 @@ export default async function Home() {
<FeatureItem
title="Les Fondements de Latosa Escrima Concepts"
position="left"
image="https://shadcnblocks.com/images/block/placeholder-2.svg"
image={
fondations.media?.url ??
"https://shadcnblocks.com/images/block/placeholder-2.svg"
}
>
<ol className="flex list-decimal flex-col gap-4 text-justify">
<li>
@@ -72,7 +85,10 @@ export default async function Home() {
<FeatureItem
title="LÉvolution du Système"
position="right"
image="https://shadcnblocks.com/images/block/placeholder-2.svg"
image={
systemEvolution.media?.url ??
"https://shadcnblocks.com/images/block/placeholder-2.svg"
}
>
<ol className="flex list-none flex-col gap-4 text-justify">
<li>
@@ -117,7 +133,10 @@ export default async function Home() {
<FeatureItem
title="Les Principes du Système Aujourdhui"
position="left"
image="https://shadcnblocks.com/images/block/placeholder-2.svg"
image={
todaysPrinciples.media?.url ??
"https://shadcnblocks.com/images/block/placeholder-2.svg"
}
>
Latosa Escrima Concepts repose sur cinq concepts
fondamentaux :

View File

@@ -5,19 +5,18 @@ import Link from "next/link";
import { API_URL } from "@/lib/constants";
import Image from "next/image";
const Hero = () => {
const background = `${API_URL}/media/591ab183-c72d-46ff-905c-ec04fed1bb34/file`;
const Hero: React.FC<{ background: string }> = ({ background }) => {
return (
<section className="relative flex h-[calc(100vh-68px)] items-center justify-center overflow-hidden py-32">
<div className="">
<Image
src={background}
layout="fill"
objectFit="cover"
// objectFit="cover"
priority
alt="Hero image"
unoptimized
className="grayscale"
className="grayscale object-cover "
/>
{/* Gradient and Blur Overlay */}
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-transparent to-transparent bg-opacity-30 backdrop-blur-sm"></div>
@@ -29,10 +28,12 @@ const Hero = () => {
className="h-16"
/>
<div>
<h1 className="mb-6 text-pretty text-2xl font-bold text-primary lg:text-5xl">
Trouvez votre équilibre avec
<h1 className="mb-6 text-pretty text-2xl font-bold text-primary lg:text-5xl font-times">
Trouvez votre <em>équilibre</em> avec
<br />
Latosa-Escrima
<span className="font-extrabold text-3xl lg:text-6xl">
Latosa Escrima
</span>
</h1>
<p className="text-muted-foreground lg:text-xl">
Une évolution des arts martiaux Philippins

View File

@@ -23,7 +23,7 @@ interface PhotoDialogProps {
isOpen: boolean;
onClose: () => void;
onDelete?: (id: Media["id"]) => void;
onSave: (photo: Omit<Media, "id">, file: File) => void;
onSave: (photo: Omit<Media, "id"> | Media, file: File) => void;
}
export function PhotoDialog({
@@ -34,7 +34,7 @@ export function PhotoDialog({
onSave,
}: PhotoDialogProps) {
const [file, setFile] = useState<File | null>(null);
const [newPhoto, setNewPhoto] = useState<Omit<Media, "id">>({
const [newPhoto, setNewPhoto] = useState<Omit<Media, "id"> | Media>({
url: "",
alt: "",
path: "",

View File

@@ -14,6 +14,18 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type IShortcode from "@/interfaces/IShortcode";
import Image from "next/image";
import Media from "@/interfaces/Media";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "./ui/pagination";
import useMedia from "@/hooks/use-media";
import { Loader2 } from "lucide-react";
interface ShortcodeDialogProps {
onSave: (shortcode: IShortcode) => void;
@@ -33,6 +45,8 @@ export default function ShortcodeDialog({
);
const handleSave = () => {
if (!(_shortcode?.code && (_shortcode.media_id || _shortcode.value)))
return;
onSave(_shortcode);
setOpen();
resetForm();
@@ -92,6 +106,7 @@ export default function ShortcodeDialog({
setShortcode((p) => ({
...p,
value: e.target.value,
media_id: undefined,
}))
}
className="col-span-3"
@@ -99,27 +114,29 @@ export default function ShortcodeDialog({
</div>
</TabsContent>
<TabsContent value="media">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="mediaId" className="text-right">
Media ID
</Label>
<Input
id="mediaId"
value={_shortcode.media_id}
onChange={(e) =>
setShortcode((p) => ({
...p,
media_id: e.target.value,
}))
}
className="col-span-3"
/>
</div>
<PhotoGrid
onSelect={(photo) => {
setShortcode((p) => ({
...p,
media_id: photo.id,
value: undefined,
}));
}}
/>
</TabsContent>
</Tabs>
</div>
<DialogFooter>
<Button type="submit" onClick={handleSave}>
<Button
disabled={
!(
_shortcode?.code &&
(_shortcode.media_id || _shortcode.value)
)
}
type="submit"
onClick={handleSave}
>
Enregistrer
</Button>
</DialogFooter>
@@ -127,3 +144,144 @@ export default function ShortcodeDialog({
</Dialog>
);
}
const LazyImage = ({
photo,
onClick,
isSelected,
}: {
photo: Media;
onClick: () => void;
isSelected: boolean;
}) => {
return (
<div
className={`aspect-square ${isSelected ? "ring-4 ring-primary" : ""}`}
>
<Image
src={photo.url || "/placeholder.svg"}
alt={photo.alt}
width={300}
height={300}
className="object-cover w-full h-full cursor-pointer transition-transform hover:scale-105"
onClick={onClick}
/>
</div>
);
};
const PhotoGrid: React.FC<{ onSelect: (photo: Media) => void }> = ({
onSelect,
}) => {
const { data, error, isLoading, success, setPage, setLimit, mutate } =
useMedia(5);
const [selectedPhoto, setSelectedPhoto] = useState<Media | null>(null);
const handlePhotoClick = (photo: Media) => {
setSelectedPhoto(photo);
onSelect(photo);
};
const handleChangeSelection = () => {
setSelectedPhoto(null);
// if (!photos) return;
// const currentIndex = photos.findIndex(
// (photo) => photo.id === selectedPhoto?.id,
// );
// const nextIndex = (currentIndex + 1) % photos.length;
// setSelectedPhoto(photos[nextIndex]);
};
if (isLoading) return <Loader2 className="animate-spin" />;
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-4">Gallerie Photo</h1>
{selectedPhoto ? (
<div className="flex flex-col items-center">
<Image
src={selectedPhoto.url || "/placeholder.svg"}
alt={selectedPhoto.alt}
width={600}
height={600}
className="w-full max-w-2xl h-auto mb-4 rounded-lg"
/>
<div className="flex gap-4 mt-4">
<Button onClick={handleChangeSelection}>
Changer de sélection
</Button>
</div>
</div>
) : (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{data?.items?.map((photo) => {
return (
<LazyImage
photo={photo}
key={photo.id}
onClick={() => handlePhotoClick(photo)}
isSelected={false}
/>
);
})}
</div>
<Pagination className="mt-8">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault();
setPage((prev) =>
Math.max(prev - 1, 1),
);
}}
className={
data?.page === 1
? "pointer-events-none opacity-50"
: ""
}
/>
</PaginationItem>
{[...Array(data?.totalPages)].map((_, i) => (
<PaginationItem key={i + 1}>
<PaginationLink
href="#"
onClick={(e) => {
e.preventDefault();
setPage(i + 1);
}}
isActive={data?.page === i + 1}
>
{i + 1}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
setPage((prev) =>
Math.min(
prev + 1,
data?.totalPages ?? 1,
),
);
}}
className={
data?.page === data?.totalPages
? "pointer-events-none opacity-50"
: ""
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,13 @@
import IShortcode from "@/interfaces/IShortcode";
import request from "./request";
export default async function getShortcode(code: string): Promise<IShortcode> {
const res = await request<IShortcode>(`/shortcodes/${code}`, {
method: "GET",
requiresAuth: false,
});
if (res.status === "Error" || !res.data)
throw new Error("Shortcode doesn't exist.");
return res.data;
}

View File

@@ -12,7 +12,6 @@ export default async function request<T>(
cookies?: () => Promise<ReadonlyRequestCookies>;
} = {},
): Promise<ApiResponse<T>> {
console.log("Hello everyone");
const { method = "GET", body, requiresAuth = true } = options;
const headers: Record<string, string> = {
"Content-Type": "application/json",

View File

@@ -37,8 +37,10 @@
"clsx": "^2.1.1",
"cookies-next": "^5.1.0",
"date-fns": "^3.0.0",
"dompurify": "^3.2.4",
"embla-carousel-react": "^8.5.2",
"lucide-react": "^0.471.1",
"marked": "^15.0.6",
"next": "15.1.4",
"next-themes": "^0.4.4",
"react": "^19.0.0",
@@ -2125,6 +2127,13 @@
"@types/react": "^19.0.0"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.19.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.19.1.tgz",
@@ -3208,6 +3217,15 @@
"node": ">=0.10.0"
}
},
"node_modules/dompurify": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz",
"integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -5116,6 +5134,18 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/marked": {
"version": "15.0.6",
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.6.tgz",
"integrity": "sha512-Y07CUOE+HQXbVDCGl3LXggqJDbXDP2pArc2C1N1RRMN0ONiShoSsIInMd5Gsxupe7fKLpgimTV+HOJ9r7bA+pg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

@@ -38,8 +38,10 @@
"clsx": "^2.1.1",
"cookies-next": "^5.1.0",
"date-fns": "^3.0.0",
"dompurify": "^3.2.4",
"embla-carousel-react": "^8.5.2",
"lucide-react": "^0.471.1",
"marked": "^15.0.6",
"next": "15.1.4",
"next-themes": "^0.4.4",
"react": "^19.0.0",

View File

@@ -9,6 +9,9 @@ export default {
],
theme: {
extend: {
fontFamily: {
times: ["Times New Roman", "Times", "serif"],
},
colors: {
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",