diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 98caad3..ff33e0b 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -347,10 +347,14 @@ dependencies = [ [[package]] name = "comicinfo-editor-v2" -version = "0.0.0" +version = "0.1.0" dependencies = [ + "quick-xml", "serde", + "serde-xml-rs", "serde_json", + "strum", + "strum_macros", "tauri", "tauri-build", ] @@ -1996,6 +2000,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81b9228215d82c7b61490fec1de287136b5de6f5700f6e58ea9ad61a7964ca51" dependencies = [ "memchr", + "serde", ] [[package]] @@ -2272,6 +2277,18 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-xml-rs" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb3aa78ecda1ebc9ec9847d5d3aba7d618823446a049ba2491940506da6e2782" +dependencies = [ + "log", + "serde", + "thiserror", + "xml-rs", +] + [[package]] name = "serde_derive" version = "1.0.188" @@ -2497,6 +2514,25 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strum" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" + +[[package]] +name = "strum_macros" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.38", +] + [[package]] name = "syn" version = "1.0.109" @@ -3651,3 +3687,9 @@ checksum = "f4686009f71ff3e5c4dbcf1a282d0a44db3f021ba69350cd42086b3e5f1c6985" dependencies = [ "libc", ] + +[[package]] +name = "xml-rs" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fcb9cbac069e033553e8bb871be2fbdffcab578eb25bd0f7c508cedc6dcd75a" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ebec1a2..584ec19 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "comicinfo-editor-v2" -version = "0.0.0" -description = "A Tauri App" -authors = ["you"] +version = "0.1.0" +description = "App for creating Comicinfo.xml files" +authors = ["Neshura"] license = "" repository = "" edition = "2021" @@ -14,8 +14,12 @@ tauri-build = { version = "1.4", features = [] } [dependencies] tauri = { version = "1.4", features = ["shell-open"] } +quick-xml = { version = "0.29.0", features = ["serde", "serialize"] } serde = { version = "1.0", features = ["derive"] } +serde-xml-rs = "0.6.0" serde_json = "1.0" +strum = "0.25.0" +strum_macros = "0.25.0" [features] # this feature is used for production builds or when `devPath` points to the filesystem diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 523550d..602d3c8 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,6 +1,11 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +use serde::{Deserialize, Serialize}; + +use crate::metadata::{*}; +mod metadata; + // Learn more about Tauri commands at https://tauri.app/v1/guides/features/command #[tauri::command] fn greet(name: &str) -> String { @@ -9,7 +14,13 @@ fn greet(name: &str) -> String { fn main() { tauri::Builder::default() - .invoke_handler(tauri::generate_handler![greet]) + .invoke_handler(tauri::generate_handler![greet, test]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } + +#[tauri::command] +fn test(message: metadata::Metadata) -> String { + message.save_to_xml("/home/neshura/Repositories/comicinfo-editor-v2/ComicInfo.xml"); + format!("Series: '{}' | Title: '{}'", message.series_title, message.title) +} diff --git a/src-tauri/src/metadata/mod.rs b/src-tauri/src/metadata/mod.rs new file mode 100644 index 0000000..b3d87e0 --- /dev/null +++ b/src-tauri/src/metadata/mod.rs @@ -0,0 +1,206 @@ +use std::{fs::File, io::{Write, Cursor}}; +use quick_xml::{se::Serializer, events::BytesStart}; +use serde::{Serialize, Deserialize}; +use serde::ser::{SerializeSeq, SerializeStruct}; + +use serde_xml_rs::{to_string, to_writer}; + +#[derive(Debug, Deserialize, PartialEq, Clone)] +pub(crate) struct Metadata { + pub(crate) title: String, + pub(crate) series_title: String, + + pub(crate) chapter_number: u16, + + pub(crate) total_chapter_count: i16, + + pub(crate) volume_number: i16, + + pub(crate) summary: String, + + pub(crate) release_date: ReleaseDate, + + pub(crate) writer: String, + pub(crate) translator: String, + pub(crate) letterer: String, + pub(crate) editor: String, + + pub(crate) publisher: String, + + pub(crate) genre: String, + + pub(crate) tags: Vec<String>, + + pub(crate) page_count: u16, + + pub(crate) language: String, + + pub(crate) characters: Vec<String>, + + pub(crate) age_rating: String +} + +impl Default for Metadata { + fn default() -> Self { + Self { + title: "".into(), + series_title: "".into(), + chapter_number: 0, + total_chapter_count: -1, + volume_number: 1, + summary: "".into(), + release_date: ReleaseDate { + year: -1, + month: -1, + day: -1, + }, + writer: "".into(), + letterer: "".into(), + editor: "".into(), + translator: "".into(), + publisher: "".into(), + genre: "".into(), + tags: vec![], + page_count: 0, + language: "en".into(), + characters: vec![], + age_rating: "Unknown".into() + } + } +} + +impl Serialize for Metadata { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: serde::Serializer { + let mut out_state = serializer.serialize_struct("Metadata", 22)?; + out_state.serialize_field("@xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance")?; + out_state.serialize_field("@xsi:noNamespaceSchemaLocation", "ComicInfo.xsd")?; + out_state.serialize_field("Series", &self.series_title)?; + out_state.serialize_field("Title", &self.title)?; + + out_state.serialize_field("Number", &self.chapter_number)?; + out_state.serialize_field("Count", &self.total_chapter_count)?; + out_state.serialize_field("Volume", &self.volume_number)?; + + if self.summary != "" { + out_state.serialize_field("Summary", &self.summary)?; + } + + out_state.serialize_field("Year", &self.release_date.year)?; + out_state.serialize_field("Month", &self.release_date.month)?; + out_state.serialize_field("Day", &self.release_date.day)?; + + if self.writer != "" { + out_state.serialize_field("Writer", &self.writer)?; + } + + if self.letterer != "" { + out_state.serialize_field("Letterer", &self.letterer)?; + } + + if self.editor != "" { + out_state.serialize_field("Editor", &self.editor)?; + } + + if self.translator != "" { + out_state.serialize_field("Translator", &self.translator)?; + } + + if self.publisher != "" { + out_state.serialize_field("Publisher", &self.publisher)?; + } + + out_state.serialize_field("Genre", &self.genre)?; + + if self.tags.len() != 0 { + out_state.serialize_field("Tags", &self.tags.join(", "))?; + } + + out_state.serialize_field("PageCount", &self.page_count)?; + + out_state.serialize_field("LanguageISO", &self.language)?; + + if self.characters.len() != 0 { + out_state.serialize_field("Characters", &self.characters.join(", "))?; + } + + out_state.serialize_field("AgeRating", &self.age_rating)?; + + out_state.end() + } +} + +impl Metadata { + pub(crate) fn save_to_xml(&self, path: &str) { + let mut file = File::create(path).unwrap(); + let mut buffer = String::new(); + let root = "ComicInfo"; + + let mut ser = Serializer::with_root(&mut buffer, Some(root)).unwrap(); + ser.indent(' ', 4); + + self.serialize(ser).unwrap(); + file.write(buffer.as_bytes()).unwrap(); + } +} + +#[derive(Debug, Deserialize, PartialEq, Clone)] +pub(crate) struct ReleaseDate { + year: i16, + month: i8, + day: i8 +} + +/*#[derive(strum_macros::Display, Debug, PartialEq, strum_macros::EnumIter, Serialize, Deserialize, Clone)] +#[serde(tag = "AgeRating")] +pub(crate) enum AgeRating { + #[strum(serialize="Unknown")] + Unknown, + #[strum(serialize="Adults Only 18+")] + #[serde(rename="Adults Only 18+")] + AdultsOnly, + #[strum(serialize="Early Childhood")] + #[serde(rename="Early Childhood")] + EarlyChildhood, + #[strum(serialize="Everyone")] + Everyone, + #[strum(serialize="Everyone 10+")] + #[serde(rename="Everyone 10+")] + Everyone10, + #[strum(serialize="G")] + G, + #[strum(serialize="Kids to Adults")] + #[serde(rename="Kids to Adults")] + KidsAdults, + #[strum(serialize="M")] + M, + #[strum(serialize="MA15+")] + #[serde(rename="MA15+")] + MA15, + #[strum(serialize="Mature 17+")] + #[serde(rename="Mature 17+")] + Mature, + #[strum(serialize="PG")] + PG, + #[strum(serialize="R18+")] + #[serde(rename="R18+")] + R18, + #[strum(serialize="Rating Pending")] + #[serde(rename="Rating Pending")] + RatingPending, + #[strum(serialize="Teen")] + Teen, + #[strum(serialize="X18+")] + #[serde(rename="X18+")] + X18 +} + +#[derive(strum_macros::Display, Debug, PartialEq, strum_macros::EnumIter, Serialize, Deserialize, Clone)] +#[serde(tag = "LanguageISO")] +pub(crate) enum LanguageISO { + #[strum(serialize="en")] + #[serde(rename="en")] + EN, + #[strum(serialize="jp")] + #[serde(rename="jp")] + JP +}*/ \ No newline at end of file diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index e32feae..686dc01 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -8,7 +8,7 @@ }, "package": { "productName": "comicinfo-editor-v2", - "version": "0.0.0" + "version": "0.1.0" }, "tauri": { "allowlist": { diff --git a/src/App.svelte b/src/App.svelte index 0da109c..d97f7cd 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -22,9 +22,6 @@ Settings go here </p> {/if} - <div class="row"> - <Greet /> - </div> </div> diff --git a/src/lib/Dropdown/Dropdown.svelte b/src/lib/Dropdown/Dropdown.svelte new file mode 100644 index 0000000..b4c8534 --- /dev/null +++ b/src/lib/Dropdown/Dropdown.svelte @@ -0,0 +1,48 @@ +<script lang="ts"> + let open = false; + + export let list: Array<string>; + export let activeElement: string; + export let id: string; + + function handleDropdownOpen() { + open = !open; + } + + function handleSelect(newSelection: string) { + console.log("newSelection") + activeElement = newSelection; + open = false; + } +</script> + +<div {id} class="dropdown-container"> + <button on:click|preventDefault={handleDropdownOpen}>{activeElement}</button> + + {#if open} + <div class="dropdown-list"> + {#each list as listElem} + <div class="dropdown-element" on:click|preventDefault={() => {handleSelect(listElem)}}>{listElem}</div> + {/each} + </div> + {/if} +</div> + +<style> + .dropdown-container { + position: relative; + } + + .dropdown-list { + position: absolute; + + max-height: 10rem; + + overflow-y: auto; + + z-index: 100; + + background-color: var(--color-bg); + border: 1px solid var(--color-border) + } +</style> \ No newline at end of file diff --git a/src/lib/IntegerInput.svelte b/src/lib/IntegerInput.svelte index 76d6696..bd1a0f3 100644 --- a/src/lib/IntegerInput.svelte +++ b/src/lib/IntegerInput.svelte @@ -13,10 +13,6 @@ function inputInteger(event: Event & {currentTarget: (EventTarget & HTMLInputElement)}) { let newValue = event.currentTarget.value; - if (newValue.startsWith("0") && newValue.length > 1) { - newValue = newValue.slice(1, newValue.length) - } - if (newValue != "" && !isNaN(Number(newValue))) { value = Number(newValue); } @@ -25,17 +21,13 @@ event.currentTarget.value = value.toString(); } } - else if (newValue === "") { - event.currentTarget.value = defaultValue.toString(); - value = defaultValue; - } else { event.currentTarget.value = value.toString(); } } </script> -<input {id} type="text" class="digitInput" style="--valuelen: {value.toString().length + 3}ch" on:input|preventDefault={inputInteger} {value}> +<input {id} type="number" class="digitInput" style="--valuelen: {value.toString().length + 4}ch" on:input|preventDefault={inputInteger} {value}> <style> .digitInput { diff --git a/src/lib/ListTextInput/ListTextInputElement.svelte b/src/lib/ListTextInput/ListTextInputElement.svelte index e595ba5..42f22e1 100644 --- a/src/lib/ListTextInput/ListTextInputElement.svelte +++ b/src/lib/ListTextInput/ListTextInputElement.svelte @@ -16,7 +16,7 @@ </script> <input id="tag-{id}" type="text" class="letterInput" style="--valuelen: {width}ch" bind:value={value}> -<button on:click={handleClick}>X</button> +<button on:click|preventDefault={handleClick}>X</button> <style> .letterInput { diff --git a/src/lib/ListTextInput/NewListTextInput.svelte b/src/lib/ListTextInput/NewListTextInput.svelte index 5151eed..946423e 100644 --- a/src/lib/ListTextInput/NewListTextInput.svelte +++ b/src/lib/ListTextInput/NewListTextInput.svelte @@ -7,7 +7,7 @@ } </script> -<button {id} class="letterInput" on:click={handleClick}>+</button> +<button {id} class="letterInput" on:click|preventDefault={handleClick}>+</button> <style> .letterInput { diff --git a/src/lib/MetadataInput.svelte b/src/lib/MetadataInput.svelte index 466598c..08c75b9 100644 --- a/src/lib/MetadataInput.svelte +++ b/src/lib/MetadataInput.svelte @@ -1,125 +1,166 @@ <script lang="ts"> - import {writable} from "svelte/store"; - import type {Writable} from "svelte/store"; import IntegerInput from "./IntegerInput.svelte"; import TextInput from "./TextInput.svelte"; import NewListTextInput from "./ListTextInput/NewListTextInput.svelte"; import ListTextInputElement from "./ListTextInput/ListTextInputElement.svelte"; + import {invoke} from "@tauri-apps/api/tauri"; + import {AgeRating, LanguageISO, type Metadata} from "./metadata"; + import Dropdown from "./Dropdown/Dropdown.svelte"; - let seriesTitle = ""; - let volumeTitle = ""; - let volumeNumber = 0; - let chapterNumber = 0; - let pageCount = 1; - let chapterCount = 0; - let summary = ""; - let releaseYear: number; - let releaseMonth: number; - let releaseDay: number; - let author: string; - let typesetter: string; - let editor: string; - let translator: string; - let publisher: string; - let tags: Array<string> = []; - let genre = "Hentai"; - let lang = "en"; - let ageRating = "18+"; + let returnMessage = ""; + + let metadata: Metadata = { + series_title: "", + title: "", + + chapter_number: 0, + + total_chapter_count: -1, + + volume_number: -1, + + summary: "", + + release_date: { + year: new Date().getFullYear(), + month: -1, + day: -1, + }, + + writer: "", + translator: "", + letterer: "", + editor: "", + + publisher: "", + + genre: "", + + tags: [], + + page_count: 1, + + language: LanguageISO.EN, + + characters: [], + + age_rating: AgeRating.Unknown + }; $: { - if (releaseYear < 0) { - releaseMonth = -1; + if (metadata.release_date.year < 0) { + metadata.release_date.month = -1; } - if (releaseMonth < 0) { - releaseDay = -1; + if (metadata.release_date.month < 0) { + metadata.release_date.day = -1; } } function deleteTag(event: any) { - console.log("deleted") - tags.splice(event.detail.tagId, 1); - tags = tags; + metadata.tags.splice(event.detail.tagId, 1); + metadata.tags = metadata.tags; + } + + async function saveMetadata() { + console.log(metadata); + returnMessage = await invoke("test", { message: metadata }) } </script> -<div class="metadataInput"> +<div class="metadataInputContainer"> <h1>Metadata</h1> - <label for="series">Series:</label> - <TextInput id="series" bind:value={seriesTitle} placeholder="Series Title" /><br> + <form class="metadataInput" on:submit|preventDefault={saveMetadata}> - <label for="title">Title:</label> - <TextInput id="title" bind:value={volumeTitle} placeholder="Volume Title" /><br> - <label for="volume">Volume:</label> - <IntegerInput id="volume" bind:value={volumeNumber} /> - <label for="chapter">Chapter:</label> - <IntegerInput id="chapter" bind:value={chapterNumber} /> / + <label for="series">Series:</label> + <TextInput id="series" bind:value={metadata.series_title} placeholder="Series Title" /><br> - <IntegerInput id="chapter_count" bind:value={chapterCount} negative={true}/><br> + <label for="title">Title:</label> + <TextInput id="title" bind:value={metadata.title} placeholder="Volume Title" /><br> - <label for="page_count">Page Count</label> - <IntegerInput id="page_count" bind:value={pageCount} defaultValue={1} /><br> + <label for="volume">Volume:</label> + <IntegerInput id="volume" bind:value={metadata.volume_number} defaultValue={-1}/> + <label for="chapter">Chapter:</label> + <IntegerInput id="chapter" bind:value={metadata.chapter_number} /> / - <label for="summary">Summary:</label><br> - <label for="summary"></label><input id="series" type="text"><br> + <IntegerInput id="chapter_count" bind:value={metadata.total_chapter_count} negative={true}/><br> - <label for="release_date">Release Date:</label> - <IntegerInput id="release_year" bind:value={releaseYear} defaultValue={new Date().getFullYear()} /> - {#if releaseYear > 0} - <IntegerInput id="release_month" bind:value={releaseMonth} defaultValue={new Date().getMonth()} /> - {#if releaseMonth > 0} - <IntegerInput id="release_day" bind:value={releaseDay} defaultValue={new Date().getDay()} /> + <label for="page_count">Page Count</label> + <IntegerInput id="page_count" bind:value={metadata.page_count} /><br> + + <label for="summary">Summary:</label><br> + <label for="summary"></label><textarea rows="3" cols="60" id="series" name="text" placeholder="Summary..." bind:value={metadata.summary}></textarea><br> + + <label for="release_date">Release Date:</label> + <IntegerInput id="release_year" bind:value={metadata.release_date.year} /> + {#if metadata.release_date.year > 0} + <IntegerInput id="release_month" bind:value={metadata.release_date.month} /> + {#if metadata.release_date.month > 0} + <IntegerInput id="release_day" bind:value={metadata.release_date.day} /> + {/if} {/if} - {/if} - <br> - <!--TODO: if date element is 0 do not display finer element, also set finer element to -1--> + <br> + <!--TODO: if date element is 0 do not display finer element, also set finer element to -1--> - <label for="author">Author:</label> - <TextInput id="author" bind:value={author} placeholder="Author" /><br> + <label for="author">Author:</label> + <TextInput id="author" bind:value={metadata.writer} placeholder="Author" /><br> - <label for="typesetter">Typesetter:</label> - <TextInput id="typesetter" bind:value={typesetter} placeholder="Typesetter" /><br> + <label for="translator">Translator:</label> + <TextInput id="translator" bind:value={metadata.translator} placeholder="Translator" /><br> - <label for="editor">Editor:</label> - <TextInput id="editor" bind:value={editor} placeholder="Editor" /><br> + <label for="typesetter">Letterer:</label> + <TextInput id="typesetter" bind:value={metadata.letterer} placeholder="Letterer" /><br> - <label for="translator">Translator:</label> - <TextInput id="translator" bind:value={translator} placeholder="Translator" /><br> + <label for="editor">Editor:</label> + <TextInput id="editor" bind:value={metadata.editor} placeholder="Editor" /><br> - <label for="publisher">Publisher:</label> - <TextInput id="publisher" bind:value={publisher} placeholder="Publisher" /><br> + <label for="publisher">Publisher:</label> + <TextInput id="publisher" bind:value={metadata.publisher} placeholder="Publisher" /><br> - <label for="new-tag">Tags:</label> - <!-- List of Tags, New Button --> - {#each tags as tag, id} - <ListTextInputElement {id} bind:value={tag} on:deleted={deleteTag}/> - {/each} - <NewListTextInput id="new-tag" bind:value={tags[tags.length]}/><br> + <label for="new-tag">Tags:</label> + <!-- List of Tags, New Button --> + {#each metadata.tags as tag, id} + <ListTextInputElement {id} bind:value={tag} on:deleted={deleteTag}/> + {/each} + <NewListTextInput id="new-tag" bind:value={metadata.tags[metadata.tags.length]}/><br> - <label for="genre">Genre</label> - <TextInput id="genre" bind:value={genre} placeholder="Genre" /> - <label for="lang">Language</label> - <input id="lang" type="text"><br> + <div class="row-left"> + <label for="genre">Genre:</label> + <TextInput id="genre" bind:value={metadata.genre} placeholder="Genre" /> - <label for="age_rating">Age Rating</label> - <input id="age_rating" type="text"><br> + <label for="language-select">Language:</label> + <Dropdown id="language-select" bind:activeElement={metadata.language} list={Object.values(LanguageISO)}></Dropdown> + </div> - <input type="submit" value="Save"/> + <div class="row-left"> + <label for="age-rating">Age Rating:</label> + <Dropdown id="" bind:activeElement={metadata.age_rating} list={Object.values(AgeRating)}></Dropdown> + </div> + + <input type="submit" value="Save"/> + <p>{returnMessage}</p> + </form> </div> + <style> - .metadataInput { + .metadataInputContainer { height: 100%; + max-height: 100%; overflow: auto; } - .metadataInput label, input[type="submit"] { + .metadataInput { margin-left: 4rem; } + .metadataInput label, input[type="submit"] { + + } + .metadataInput h1 { position: sticky; top: 0; diff --git a/src/lib/metadata.ts b/src/lib/metadata.ts new file mode 100644 index 0000000..ea60c0e --- /dev/null +++ b/src/lib/metadata.ts @@ -0,0 +1,62 @@ +type Metadata = { + title: string, + series_title: string, + + chapter_number: number, + total_chapter_count: number, + volume_number: number, + + summary: string, + + release_date: ReleaseDate, + + writer: string, + translator: string, + letterer: string + editor: string, + + publisher: string, + + genre: string, + + tags: string[], + + page_count: number, + + language: LanguageISO, + + characters: string[] + + age_rating: AgeRating +} + +type ReleaseDate = { + year: number, + month: number, + day: number, +} + +enum AgeRating { + Unknown = "Unknown", + AdultsOnly = "Adults Only 18+", + EarlyChildhood = "Early Childhood", + Everyone = "Everyone", + Everyone10 = "Everyone 10+", + G = "G", + KidsAdults = "Kids to Adults", + M = "M", + MA15 = "MA15+", + Mature = "Mature 17+", + PG = "PG", + R18 = "R18+", + RatingPending = "Rating Pending", + X18 = "X18+" +} + +enum LanguageISO { + EN = "en", + JP = "jp" +} + +export { LanguageISO, AgeRating} +export type {Metadata, ReleaseDate} \ No newline at end of file diff --git a/src/styles.css b/src/styles.css index 0b27055..d734541 100644 --- a/src/styles.css +++ b/src/styles.css @@ -5,6 +5,7 @@ font-weight: 400; --color-bg: #f6f6f6; + --color-border: #000000; color: #0f0f0f; background-color: #f6f6f6; @@ -58,6 +59,11 @@ body, html { justify-content: center; } +.row-left { + display: flex; + gap: 0.5rem; +} + a { font-weight: 500; color: #646cff;