From 09b0c9b14253cf215e77bbe51cb81825988d488f Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Wed, 5 Feb 2025 09:11:17 +0100 Subject: [PATCH 01/24] Member page --- backend/Dockerfile | 11 +- docker-compose.yaml | 1 + .../(auth)/dashboard/members/[uuid]/page.tsx | 221 ++++++++++++++++++ .../(auth)/dashboard/settings/media/page.tsx | 3 +- .../(auth)/dashboard/settings/roles/page.tsx | 3 +- .../dashboard/settings/shortcodes/page.tsx | 3 +- frontend/components/hero.tsx | 19 +- frontend/components/members-table.tsx | 25 +- frontend/components/planning.tsx | 3 +- frontend/hooks/use-api.tsx | 55 +---- frontend/lib/request.ts | 56 +++++ frontend/types/types.tsx | 67 +++--- 12 files changed, 372 insertions(+), 95 deletions(-) create mode 100644 frontend/app/(auth)/dashboard/members/[uuid]/page.tsx create mode 100644 frontend/lib/request.ts diff --git a/backend/Dockerfile b/backend/Dockerfile index 3fc3480..2364cb8 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,12 +1,15 @@ -FROM golang:alpine +FROM golang:alpine AS build WORKDIR /app COPY . . RUN go mod download -RUN go mod tidy -RUN go build main.go +RUN go build -o /app . -CMD ["./main"] +FROM scratch AS final + +COPY --from=build /app /app + +CMD ["/app"] diff --git a/docker-compose.yaml b/docker-compose.yaml index f8839af..2e688ba 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -5,6 +5,7 @@ services: build: context: ./frontend/ dockerfile: Dockerfile + target: final depends_on: - latosa-escrima.fr-backend env_file: .env diff --git a/frontend/app/(auth)/dashboard/members/[uuid]/page.tsx b/frontend/app/(auth)/dashboard/members/[uuid]/page.tsx new file mode 100644 index 0000000..faa50ca --- /dev/null +++ b/frontend/app/(auth)/dashboard/members/[uuid]/page.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { UserIcon, Building, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Role, User } from "@/types/types"; +import { useParams } from "next/navigation"; +import { useApi } from "@/hooks/use-api"; +import { useState } from "react"; +import request from "@/lib/request"; + +export default function UserDetailsPage() { + const { uuid } = useParams<{ uuid: string }>(); + const user = useApi(`/users/${uuid}`, {}, true); + + const availableRoles = useApi("/roles", {}, true); + availableRoles.data ??= []; + const [selectedRole, setSelectedRole] = useState(null); + // const [selectedOrg, setSelectedOrg] = useState(""); + + const addRole = async (role: Role) => { + const res = await request( + `/users/${user.data?.userId}/roles/${role.id}/add`, + { method: "PATCH", requiresAuth: true }, + ); + if (res.status === "Success") { + setSelectedRole(null); + user.mutate(); + } + }; + + const removeRole = async (role: Role) => { + const res = await request( + `/users/${user.data?.userId}/roles/${role.id}/remove`, + { method: "PATCH", requiresAuth: true }, + ); + if (res.status === "Success") user.mutate(); + }; + + const addOrganization = () => { + // if (selectedOrg && !user.organizations.includes(selectedOrg)) { + // setUser((prevUser) => ({ + // ...prevUser, + // organizations: [...prevUser.organizations, selectedOrg], + // })); + // setSelectedOrg(""); + // } + }; + + const removeOrganization = (orgToRemove: string) => { + // setUser((prevUser) => ({ + // ...prevUser, + // organizations: prevUser.organizations.filter( + // (org) => org !== orgToRemove, + // ), + // })); + }; + + if (!user.data || !user.success) return

Error

; + + return ( +
+ + +
+
+ +
+

+ {user.data.firstname} {user.data.lastname} +

+

+ {user.data.email} +

+
+
+ +
+
+

+ Rôles +

+
+ {user.data.roles?.map((role) => ( + + {role.name} + + + ))} +
+ +
+ + +
+
+ + {/*
+

+ Organizations +

+
+ {user.data.organizations.map((org) => ( + + {org} + + + ))} +
+
+ + +
+
*/} +
+
+
+
+
+ ); +} diff --git a/frontend/app/(auth)/dashboard/settings/media/page.tsx b/frontend/app/(auth)/dashboard/settings/media/page.tsx index e332e63..0a0c2a6 100644 --- a/frontend/app/(auth)/dashboard/settings/media/page.tsx +++ b/frontend/app/(auth)/dashboard/settings/media/page.tsx @@ -16,7 +16,8 @@ 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, { request } from "@/hooks/use-api"; +import useApiMutation from "@/hooks/use-api"; +import request from "@/lib/request"; export default function PhotoGallery() { const { diff --git a/frontend/app/(auth)/dashboard/settings/roles/page.tsx b/frontend/app/(auth)/dashboard/settings/roles/page.tsx index 6e3702f..e31e8b4 100644 --- a/frontend/app/(auth)/dashboard/settings/roles/page.tsx +++ b/frontend/app/(auth)/dashboard/settings/roles/page.tsx @@ -15,7 +15,8 @@ import { } from "@/components/ui/dialog"; import { ChevronDown, ChevronRight, Plus, Trash2 } from "lucide-react"; import { toTitleCase } from "@/lib/utils"; -import { request, useApi } from "@/hooks/use-api"; +import { useApi } from "@/hooks/use-api"; +import request from "@/lib/request"; type Action = string; diff --git a/frontend/app/(auth)/dashboard/settings/shortcodes/page.tsx b/frontend/app/(auth)/dashboard/settings/shortcodes/page.tsx index cd508f9..35daa33 100644 --- a/frontend/app/(auth)/dashboard/settings/shortcodes/page.tsx +++ b/frontend/app/(auth)/dashboard/settings/shortcodes/page.tsx @@ -3,7 +3,8 @@ import { useState } from "react"; import { ShortcodeTable } from "@/components/shortcodes-table"; import type IShortcode from "@/interfaces/IShortcode"; -import { request, useApi } from "@/hooks/use-api"; +import { useApi } from "@/hooks/use-api"; +import request from "@/lib/request"; import { Loader2 } from "lucide-react"; export default function ShortcodesPage() { diff --git a/frontend/components/hero.tsx b/frontend/components/hero.tsx index f9e3cb4..d26f3bf 100644 --- a/frontend/components/hero.tsx +++ b/frontend/components/hero.tsx @@ -2,12 +2,25 @@ import { ExternalLink } from "lucide-react"; import { Button } from "@/components/ui/button"; 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`; return (
-
+ Hero image + {/* Gradient and Blur Overlay */} +
{ />

- Trouvez votre équilibre + Trouvez votre équilibre avec
- avec Latosa-Escrima + Latosa-Escrima

Une évolution des arts martiaux Philippins diff --git a/frontend/components/members-table.tsx b/frontend/components/members-table.tsx index dacb454..898d619 100644 --- a/frontend/components/members-table.tsx +++ b/frontend/components/members-table.tsx @@ -13,8 +13,8 @@ import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { ScrollArea } from "@/components/ui/scroll-area"; import MemberDialog, { Member } from "./member-dialog"; -import * as z from "zod"; -import { request, useApi } from "@/hooks/use-api"; +import { useApi } from "@/hooks/use-api"; +import request from "@/lib/request"; import { CircleX, Loader2, @@ -22,6 +22,7 @@ import { UserRoundPen, UserRoundPlus, } from "lucide-react"; +import Link from "next/link"; export default function MembersTable() { const { @@ -107,7 +108,7 @@ export default function MembersTable() { {selectMode && ( - Selectionner + Sélectionner )} Prénom @@ -140,9 +141,23 @@ export default function MembersTable() { )} - {member.firstname} + + + {member.firstname} + + + + + + + {member.lastname} + + - {member.lastname} {member.email} {member.phone} {member.role} diff --git a/frontend/components/planning.tsx b/frontend/components/planning.tsx index 772aca8..57fe324 100644 --- a/frontend/components/planning.tsx +++ b/frontend/components/planning.tsx @@ -1,6 +1,7 @@ "use client"; -import { ApiResponse, request } from "@/hooks/use-api"; +import { ApiResponse } from "@/types/types"; +import request from "@/lib/request"; import "@schedule-x/theme-shadcn/dist/index.css"; import { useNextCalendarApp, ScheduleXCalendar } from "@schedule-x/react"; import { createEventsServicePlugin } from "@schedule-x/events-service"; diff --git a/frontend/hooks/use-api.tsx b/frontend/hooks/use-api.tsx index e531136..2097f8b 100644 --- a/frontend/hooks/use-api.tsx +++ b/frontend/hooks/use-api.tsx @@ -1,60 +1,9 @@ "use client"; -import { API_URL } from "@/lib/constants"; -import { getCookie } from "cookies-next"; +import request from "@/lib/request"; +import { ApiResponse } from "@/types/types"; import useSWR, { SWRConfiguration } from "swr"; import useSWRMutation, { type SWRMutationConfiguration } from "swr/mutation"; -export interface ApiResponse { - status: "Error" | "Success"; - message: string; - data?: T; -} - -export async function request( - endpoint: string, - options: { - method?: "GET" | "POST" | "PATCH" | "DELETE"; - body?: any; - requiresAuth?: boolean; - csrfToken?: boolean; - } = {}, -): Promise> { - const { method = "GET", body, requiresAuth = true } = options; - const headers: Record = { - "Content-Type": "application/json", - }; - - if (options.csrfToken) { - const res: ApiResponse<{ csrf: string }> = await ( - await fetch(`${API_URL}/csrf-token`) - ).json(); - if (res.data) headers["X-CSRF-Token"] = res.data.csrf; - } - - if (requiresAuth) { - const authToken = getCookie("auth_token"); - if (!authToken) { - throw new Error("User is not authenticated"); - } - headers.Authorization = `Bearer ${authToken}`; - } - - const response = await fetch(`${API_URL}${endpoint}`, { - method, - headers, - body: body ? JSON.stringify(body) : undefined, - credentials: options.csrfToken ? "include" : "omit", - }); - - const apiResponse: ApiResponse = await response.json(); - - if (apiResponse.status === "Error") { - throw new Error(apiResponse.message || "An unexpected error occurred"); - } - - return apiResponse; -} - async function fetcher( url: string, requiresAuth: boolean = true, diff --git a/frontend/lib/request.ts b/frontend/lib/request.ts new file mode 100644 index 0000000..377ac09 --- /dev/null +++ b/frontend/lib/request.ts @@ -0,0 +1,56 @@ +import { API_URL } from "@/lib/constants"; +import { ApiResponse } from "@/types/types"; +import { getCookie } from "cookies-next"; +import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies"; +export default async function request( + endpoint: string, + options: { + method?: "GET" | "POST" | "PATCH" | "DELETE"; + body?: any; + requiresAuth?: boolean; + csrfToken?: boolean; + cookies?: () => Promise; + } = {}, +): Promise> { + console.log("Hello everyone"); + const { method = "GET", body, requiresAuth = true } = options; + const headers: Record = { + "Content-Type": "application/json", + }; + + if (options.csrfToken) { + const res: ApiResponse<{ csrf: string }> = await ( + await fetch(`${API_URL}/csrf-token`) + ).json(); + if (res.data) headers["X-CSRF-Token"] = res.data.csrf; + } + + if (requiresAuth) { + let authToken; + if (!options.cookies) { + authToken = getCookie("auth_token"); + } else { + authToken = (await options.cookies()).get("auth_token")?.value; + } + + if (!authToken) { + throw new Error("User is not authenticated"); + } + headers.Authorization = `Bearer ${authToken}`; + } + + const response = await fetch(`${API_URL}${endpoint}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + credentials: options.csrfToken ? "include" : "omit", + }); + + const apiResponse: ApiResponse = await response.json(); + + if (apiResponse.status === "Error") { + throw new Error(apiResponse.message || "An unexpected error occurred"); + } + + return apiResponse; +} diff --git a/frontend/types/types.tsx b/frontend/types/types.tsx index f880fbb..6a630db 100644 --- a/frontend/types/types.tsx +++ b/frontend/types/types.tsx @@ -1,43 +1,58 @@ +export interface Permission { + resource: string; + action: string; +} // Role type as a string literal -export type Role = 'admin' | 'user'; +export interface Role { + id: string; + name: string; + permissions: Permission[]; +} // Status type as a string literal -export type Status = 'Active' | 'Inactive'; +export type Status = "Active" | "Inactive"; // Event type (you can expand this type as needed based on your schema) export interface Event { - eventID: string; - title: string; - date: string; // Assuming ISO date string + eventID: string; + title: string; + date: string; // Assuming ISO date string } // Blog type (you may already have this defined as shown in your previous example) export interface Blog { - blogID: string; - slug: string; - content: string; - label?: string; - authorID: string; - published: string; - summary?: string; - image?: string; - href?: string; + blogID: string; + slug: string; + content: string; + label?: string; + authorID: string; + published: string; + summary?: string; + image?: string; + href?: string; - author: User; // Relation to User + author: User; // Relation to User } // User type definition export interface User { - userID: string; // UUID represented as a string - firstName: string; - lastName: string; - email: string; - password?: string; // Optional field, since it's omitted in the JSON - phone: string; - role: Role; // 'admin' or 'user' - createdAt: string; // ISO date string - updatedAt: string; // ISO date string + userId: string; // UUID represented as a string + firstname: string; + lastname: string; + email: string; + password?: string; // Optional field, since it's omitted in the JSON + phone: string; + role: Role; // 'admin' or 'user' + createdAt: string; // ISO date string + updatedAt: string; // ISO date string - events?: Event[]; // Many-to-many relation with Event (optional) - articles?: Blog[]; // One-to-many relation with Blog (optional) + events?: Event[]; // Many-to-many relation with Event (optional) + articles?: Blog[]; // One-to-many relation with Blog (optional) + roles?: Role[]; +} + +export interface ApiResponse { + status: "Error" | "Success"; + message: string; + data?: T; } From 8e87d834bc30917a63a122a2f269f4ce9bb33a21 Mon Sep 17 00:00:00 2001 From: cdricms <36056008+cdricms@users.noreply.github.com> Date: Mon, 10 Feb 2025 08:52:32 +0100 Subject: [PATCH 02/24] Shortcodes --- backend/api/media/media.go | 3 +- backend/api/media/update.go | 51 ++++- backend/api/media_routes.go | 8 +- backend/api/shortcodes/shortcode.go | 13 ++ .../20250205081449_add_events_title.down.sql | 1 + .../20250205081449_add_events_title.up.sql | 1 + backend/core/models/events.go | 2 +- .../app/(auth)/dashboard/blogs/new/page.tsx | 141 +++++++++++++ .../(auth)/dashboard/members/[uuid]/page.tsx | 2 +- .../(auth)/dashboard/settings/media/old.tsx | 30 --- .../(auth)/dashboard/settings/media/page.tsx | 17 +- frontend/app/(main)/page.tsx | 27 ++- frontend/components/hero.tsx | 15 +- frontend/components/photo-dialog.tsx | 4 +- frontend/components/shortcode-dialogue.tsx | 192 ++++++++++++++++-- frontend/lib/getShortcode.ts | 13 ++ frontend/lib/request.ts | 1 - frontend/package-lock.json | 30 +++ frontend/package.json | 2 + frontend/tailwind.config.ts | 3 + 20 files changed, 485 insertions(+), 71 deletions(-) create mode 100644 backend/cmd/migrate/migrations/20250205081449_add_events_title.down.sql create mode 100644 backend/cmd/migrate/migrations/20250205081449_add_events_title.up.sql create mode 100644 frontend/app/(auth)/dashboard/blogs/new/page.tsx delete mode 100644 frontend/app/(auth)/dashboard/settings/media/old.tsx create mode 100644 frontend/lib/getShortcode.ts diff --git a/backend/api/media/media.go b/backend/api/media/media.go index ab9a842..5d8dc1f 100644 --- a/backend/api/media/media.go +++ b/backend/api/media/media.go @@ -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(). diff --git a/backend/api/media/update.go b/backend/api/media/update.go index 83e39a0..7a52895 100644 --- a/backend/api/media/update.go +++ b/backend/api/media/update.go @@ -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) + +} diff --git a/backend/api/media_routes.go b/backend/api/media_routes.go index 87db6f6..7a6cbf0 100644 --- a/backend/api/media_routes.go +++ b/backend/api/media_routes.go @@ -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}, diff --git a/backend/api/shortcodes/shortcode.go b/backend/api/shortcodes/shortcode.go index 893a020..5eb94c9 100644 --- a/backend/api/shortcodes/shortcode.go +++ b/backend/api/shortcodes/shortcode.go @@ -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", diff --git a/backend/cmd/migrate/migrations/20250205081449_add_events_title.down.sql b/backend/cmd/migrate/migrations/20250205081449_add_events_title.down.sql new file mode 100644 index 0000000..08f950c --- /dev/null +++ b/backend/cmd/migrate/migrations/20250205081449_add_events_title.down.sql @@ -0,0 +1 @@ +ALTER TABLE events DROP COLUMN title; diff --git a/backend/cmd/migrate/migrations/20250205081449_add_events_title.up.sql b/backend/cmd/migrate/migrations/20250205081449_add_events_title.up.sql new file mode 100644 index 0000000..d1da967 --- /dev/null +++ b/backend/cmd/migrate/migrations/20250205081449_add_events_title.up.sql @@ -0,0 +1 @@ +ALTER TABLE events ADD COLUMN title text not null default ''; diff --git a/backend/core/models/events.go b/backend/core/models/events.go index f350f13..7c5a5c7 100644 --- a/backend/core/models/events.go +++ b/backend/core/models/events.go @@ -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"` diff --git a/frontend/app/(auth)/dashboard/blogs/new/page.tsx b/frontend/app/(auth)/dashboard/blogs/new/page.tsx new file mode 100644 index 0000000..1c51103 --- /dev/null +++ b/frontend/app/(auth)/dashboard/blogs/new/page.tsx @@ -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(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, + ) => { + 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) => { + 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 ( +

+
+
+ + + + + {/* */} +
+