Started to work the shortcodes

This commit is contained in:
cdricms
2025-01-24 18:07:04 +01:00
parent 78ce2b533b
commit f1a49eea83
19 changed files with 713 additions and 38 deletions

View File

@@ -3,6 +3,7 @@ package core
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
@@ -137,14 +138,43 @@ type WebsiteSettings struct {
}
type Media struct {
ID uuid.UUID `bun:"type:uuid,pk,default:gen_random_uuid()" json:"id"`
AuthorID uuid.UUID `bun:"author_id,type:uuid,notnull" json:"authorID"`
Author *User `bun:"rel:belongs-to,join:author_id=user_id" json:"author,omitempty"`
Type string `bun:"media_type" json:"type"` // Image, Video, GIF etc. Add support for PDFs?
Alt string `bun:"media_alt" json:"alt"`
Path string `bun:"media_path" json:"path"`
Size int64 `bun:"media_size" json:"size"`
URL string `bun:"-" json:"url"`
bun.BaseModel `bun:"table:media"`
ID uuid.UUID `bun:"type:uuid,pk,default:gen_random_uuid()" json:"id"`
AuthorID uuid.UUID `bun:"author_id,type:uuid,notnull" json:"authorID"`
Author *User `bun:"rel:belongs-to,join:author_id=user_id" json:"author,omitempty"`
Type string `bun:"media_type" json:"type"` // Image, Video, GIF etc. Add support for PDFs?
Alt string `bun:"media_alt" json:"alt"`
Path string `bun:"media_path" json:"path"`
Size int64 `bun:"media_size" json:"size"`
URL string `bun:"-" json:"url"`
}
type ShortcodeType string
const (
ShortcodeMedia ShortcodeType = "media"
ShortcodeValue ShortcodeType = "value"
)
type Shortcode struct {
bun.BaseModel `bun:"table:shortcodes,alias:sc"`
ID int64 `bun:"id,pk,autoincrement" json:"id"` // Primary key
Code string `bun:"code,notnull,unique" json:"code"` // The shortcode value
Type ShortcodeType `bun:"shortcode_type,notnull" json:"type"`
Value *string `bun:"value" json:"value,omitempty"`
MediaID *uuid.UUID `bun:"media_id,type:uuid" json:"media_id,omitempty"` // Nullable reference to another table's ID
Media *Media `bun:"rel:belongs-to,join:media_id=id" json:"media,omitempty"` // Relation to Media
}
func (s *Shortcode) Validate() error {
if s.Value != nil && s.MediaID != nil {
return errors.New("both value and media_id cannot be set at the same time")
}
if s.Value == nil && s.MediaID == nil {
return errors.New("either value or media_id must be set")
}
return nil
}
func InitDatabase(dsn DSN) (*bun.DB, error) {
@@ -164,6 +194,7 @@ func InitDatabase(dsn DSN) (*bun.DB, error) {
_, err = db.NewCreateTable().Model((*Blog)(nil)).IfNotExists().Exec(ctx)
_, err = db.NewCreateTable().Model((*WebsiteSettings)(nil)).IfNotExists().Exec(ctx)
_, err = db.NewCreateTable().Model((*Media)(nil)).IfNotExists().Exec(ctx)
_, err = db.NewCreateTable().Model((*Shortcode)(nil)).IfNotExists().Exec(ctx)
if err != nil {
return nil, err
}

View File

@@ -0,0 +1,28 @@
package api
import (
"context"
"net/http"
core "fr.latosa-escrima/api/core"
)
func HandleDeleteShortcode(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("shortcode")
_, err := core.DB.NewDelete().
Model((*core.Shortcode)(nil)).
Where("code = ?", code).
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: "Shortcode deleted",
}.Respond(w, http.StatusOK)
}

View File

@@ -0,0 +1,31 @@
package api
import (
"context"
"net/http"
"fr.latosa-escrima/api/core"
)
func HandleGetShortcode(w http.ResponseWriter, r *http.Request) {
code := r.PathValue("shortcode")
var shortcode core.Shortcode
err := core.DB.NewSelect().
Model(&shortcode).
Where("code = ?", code).
Limit(1).
Scan(context.Background())
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
core.JSONSuccess{
Status: core.Success,
Message: "Shortcode found",
Data: shortcode,
}.Respond(w, http.StatusOK)
}

View File

@@ -0,0 +1,26 @@
package api
import (
"context"
"net/http"
"fr.latosa-escrima/api/core"
)
func HandleGetShortcodes(w http.ResponseWriter, r *http.Request) {
var shortcodes []core.Shortcode
err := core.DB.NewSelect().Model(&shortcodes).Scan(context.Background())
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
core.JSONSuccess{
Status: core.Success,
Message: "Shortcodes retrieved.",
Data: shortcodes,
}.Respond(w, http.StatusOK)
}

View File

@@ -0,0 +1,44 @@
package api
import (
"context"
"encoding/json"
"net/http"
"fr.latosa-escrima/api/core"
)
func HandleCreateShortcode(w http.ResponseWriter, r *http.Request) {
var shortcode core.Shortcode
err := json.NewDecoder(r.Body).Decode(&shortcode)
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
err = shortcode.Validate()
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
_, err = core.DB.NewInsert().Model(&shortcode).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: "Shortcode inserted.",
}.Respond(w, http.StatusCreated)
}

View File

@@ -3,7 +3,6 @@ package api
import (
"context"
"encoding/json"
"io"
"log"
"net/http"
@@ -11,26 +10,15 @@ import (
)
func HandleCreateUser(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
core.JSONError{
Status: core.Error,
Message: "The body of your message is invalid.",
}.Respond(w, http.StatusNotAcceptable)
return
}
log.Println("body : ", body )
var user core.User
err = json.Unmarshal(body, &user)
err := json.NewDecoder(r.Body).Decode(&user)
if err != nil {
core.JSONError{
Status: core.Error,
Message: "It seems your body in invalid JSON.",
Message: err.Error(),
}.Respond(w, http.StatusNotAcceptable)
return
}
log.Println("User : ", user)
res, err := user.Insert(context.Background())

View File

@@ -0,0 +1,71 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"reflect"
"strings"
core "fr.latosa-escrima/api/core"
"github.com/google/uuid"
)
type UpdateShortcodeArgs struct {
Code *string `json:"code"` // The shortcode value
Type *core.ShortcodeType `json:"type"`
Value *string `json:"value"`
MediaID *uuid.UUID `json:"media_id"` // Nullable reference to another table's ID
}
func HandleUpdateShortcode(w http.ResponseWriter, r *http.Request) {
var updateArgs UpdateShortcodeArgs
err := json.NewDecoder(r.Body).Decode(&updateArgs)
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
var shortcode core.Shortcode
updateQuery := core.DB.NewUpdate().Model(&shortcode)
val := reflect.ValueOf(updateArgs)
typ := reflect.TypeOf(updateArgs)
for i := 0; i < val.NumField(); i++ {
field := val.Field(i)
tag := typ.Field(i).Tag.Get("bun")
if tag == "" {
tag = typ.Field(i).Tag.Get("json")
}
// Only add fields that are non-nil and non-zero
if field.IsValid() && !field.IsNil() && !field.IsZero() {
updateQuery.Set(fmt.Sprintf("%s = ?", strings.Split(tag, ",")[0]), field.Interface())
}
}
code := r.PathValue("shortcode")
_, err = updateQuery.
Where("code = ?", code).
Returning("*").
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: "Shortcode updated.",
Data: shortcode,
}.Respond(w, http.StatusOK)
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"strings"
@@ -24,15 +23,7 @@ type UpdateUserArgs struct {
func HandleUpdateUser(w http.ResponseWriter, r *http.Request) {
var updateArgs UpdateUserArgs
body, err := io.ReadAll(r.Body)
if err != nil {
core.JSONError{
Status: core.Error,
Message: err.Error(),
}.Respond(w, http.StatusInternalServerError)
return
}
err = json.Unmarshal(body, &updateArgs)
err := json.NewDecoder(r.Body).Decode(&updateArgs)
if err != nil {
core.JSONError{
Status: core.Error,

View File

@@ -143,6 +143,26 @@ func main() {
Handler: api.HandleDeleteMedia,
Middlewares: []core.Middleware{api.Methods("DELETE"), api.AuthJWT},
},
"/shortcodes/new": {
Handler: api.HandleCreateShortcode,
Middlewares: []core.Middleware{api.Methods("POST"), api.AuthJWT},
},
"/shortcodes/": {
Handler: api.HandleGetShortcodes,
Middlewares: []core.Middleware{api.Methods("GET"), api.AuthJWT},
},
"/shortcodes/{shortcode}": {
Handler: api.HandleGetShortcode,
Middlewares: []core.Middleware{api.Methods("GET")},
},
"/shortcodes/{shortcode}/delete": {
Handler: api.HandleDeleteShortcode,
Middlewares: []core.Middleware{api.Methods("DELETE"), api.AuthJWT},
},
"/shortcodes/{shortcode}/update": {
Handler: api.HandleUpdateShortcode,
Middlewares: []core.Middleware{api.Methods("PATCH"), api.AuthJWT},
},
"/contact": {
Handler: api.HandleContact,
Middlewares: []core.Middleware{api.Methods("POST"), CSRFMiddleware},

View File

@@ -0,0 +1,65 @@
"use client";
import { useState } from "react";
import { ShortcodeTable } from "@/components/shortcodes-table";
import type IShortcode from "@/interfaces/IShortcode";
import { request, useApi } from "@/hooks/use-api";
import { Loader2 } from "lucide-react";
export default function ShortcodesPage() {
const {
data: shortcodes,
error,
isLoading,
mutate,
success,
} = useApi<IShortcode[]>("/shortcodes", undefined, true);
const handleUpdate = async (updatedShortcode: IShortcode) => {
const res = await request<IShortcode>(
`/shortcodes/${updatedShortcode.code}/update`,
{
method: "PATCH",
requiresAuth: true,
body: updatedShortcode,
},
);
mutate();
// Implement update logic here
console.log("Update shortcode:", updatedShortcode);
};
const handleDelete = async (code: string) => {
const res = await request<undefined>(`/shortcodes/${code}/delete`, {
requiresAuth: true,
method: "DELETE",
});
mutate();
};
const handleAdd = async (newShortcode: Omit<IShortcode, "id">) => {
const res = await request<IShortcode>(`/shortcodes/new`, {
body: newShortcode,
method: "POST",
requiresAuth: true,
});
console.log(res);
mutate();
};
return (
<div className="container mx-auto py-10">
<h1 className="text-2xl font-bold mb-5">Shortcodes</h1>
{isLoading && (
<Loader2 className="flex w-full min-w-0 flex-col gap-1 justify-center animate-spin" />
)}
{error && <p>{error}</p>}
<ShortcodeTable
shortcodes={shortcodes ?? []}
onUpdate={handleUpdate}
onDelete={handleDelete}
onAdd={handleAdd}
/>
</div>
);
}

View File

@@ -95,7 +95,12 @@ const data = {
items: [
{
title: "Media",
url: "/dashboard/media",
url: "/dashboard/settings/media",
icon: Camera,
},
{
title: "Shortcodes",
url: "/dashboard/settings/shortcodes",
icon: Camera,
},
],

View File

@@ -1,3 +1,4 @@
"use client";
import { Book, Menu, Sunset, Trees, Zap } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -18,6 +19,8 @@ import {
SheetTrigger,
} from "@/components/ui/sheet";
import Link from "next/link";
import { deleteCookie, getCookie } from "cookies-next";
import { useEffect, useState } from "react";
const subMenuItemsOne = [
{
@@ -67,6 +70,11 @@ const subMenuItemsTwo = [
];
const Navbar = () => {
const [cookie, setCookie] = useState<string | null>(null);
useEffect(() => {
const _cookie = getCookie("auth_token");
setCookie(_cookie?.toString() ?? null);
}, []);
return (
<section className="sticky top-0 z-50 bg-background p-4">
<div>
@@ -133,11 +141,26 @@ const Navbar = () => {
</a>
</div>
</div>
<div className="flex gap-2">
<div className="flex gap-2 animate-in ease-in-out">
<Button variant="outline">
<Link href="/login">Se connecter</Link>
{cookie ? (
<Link href="/dashboard">Compte</Link>
) : (
<Link href="/login">Se connecter</Link>
)}
</Button>
<Button>Sign up</Button>
{cookie ? (
<Button
onClick={() => {
deleteCookie("auth_token");
setCookie(null);
}}
>
Se déconnecter
</Button>
) : (
<Button>Créer un compte</Button>
)}
</div>
</nav>
<div className="block lg:hidden">

View File

@@ -0,0 +1,120 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type IShortcode from "@/interfaces/IShortcode";
interface ShortcodeDialogProps {
onSave: (shortcode: Omit<IShortcode, "id">) => void;
}
export default function ShortcodeDialog({ onSave }: ShortcodeDialogProps) {
const [open, setOpen] = useState(false);
const [code, setCode] = useState("");
const [type, setType] = useState<"value" | "media">("value");
const [value, setValue] = useState("");
const [mediaId, setMediaId] = useState("");
const handleSave = () => {
onSave({ code, type, value, media_id: mediaId });
setOpen(false);
resetForm();
};
const resetForm = () => {
setCode("");
setType("value");
setValue("");
setMediaId("");
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">Add New Shortcode</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Add New Shortcode</DialogTitle>
<DialogDescription>
Create a new shortcode here. Click save when you're
done.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="code" className="text-right">
Code
</Label>
<Input
id="code"
value={code}
onChange={(e) => setCode(e.target.value)}
className="col-span-3"
/>
</div>
<Tabs
defaultValue={type}
onValueChange={(v) => setType(v as "value" | "media")}
className="w-full"
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="value">Value</TabsTrigger>
<TabsTrigger value="media">Media</TabsTrigger>
</TabsList>
<TabsContent value="value">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="value" className="text-right">
Value
</Label>
<Input
id="value"
value={value}
onChange={(e) => setValue(e.target.value)}
className="col-span-3"
/>
</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={mediaId}
onChange={(e) => setMediaId(e.target.value)}
className="col-span-3"
/>
</div>
</TabsContent>
</Tabs>
</div>
<DialogFooter>
<Button type="submit" onClick={handleSave}>
Save shortcode
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,102 @@
"use client";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { MoreHorizontal } from "lucide-react";
import type IShortcode from "@/interfaces/IShortcode";
import ShortcodeDialog from "@/components/shortcode-dialogue";
interface ShortcodeTableProps {
shortcodes: IShortcode[];
onUpdate: (shortcode: IShortcode) => void;
onDelete: (id: string) => void;
onAdd: (shortcode: Omit<IShortcode, "id">) => void;
}
export function ShortcodeTable({
shortcodes,
onUpdate,
onDelete,
onAdd,
}: ShortcodeTableProps) {
return (
<div>
<div className="mb-4">
<ShortcodeDialog onSave={onAdd} />
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Code</TableHead>
<TableHead>Type</TableHead>
<TableHead>Value</TableHead>
<TableHead>Media ID</TableHead>
<TableHead className="w-[80px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{shortcodes.map((shortcode) => (
<TableRow key={shortcode.id}>
<TableCell>{shortcode.id}</TableCell>
<TableCell>{shortcode.code}</TableCell>
<TableCell>{shortcode.type}</TableCell>
<TableCell>
{shortcode.value || "N/A"}
</TableCell>
<TableCell>
{shortcode.media_id || "N/A"}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() =>
onUpdate(shortcode)
}
>
Update
</DropdownMenuItem>
<DropdownMenuItem
onClick={() =>
onDelete(shortcode.code)
}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
));
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className,
)}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className,
)}
{...props}
/>
));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
/>
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

View File

@@ -11,7 +11,7 @@ export interface ApiResponse<T> {
}
export async function request<T>(
url: string,
endpoint: string,
options: {
method?: "GET" | "POST" | "PATCH" | "DELETE";
body?: any;
@@ -39,7 +39,7 @@ export async function request<T>(
headers.Authorization = `Bearer ${authToken}`;
}
const response = await fetch(`${API_URL}${url}`, {
const response = await fetch(`${API_URL}${endpoint}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,

View File

@@ -0,0 +1,10 @@
import Media from "./Media";
export default interface IShortcode {
id: number;
code: string;
type: string;
value?: string;
media_id?: string;
media?: Media;
}