Chore: README Update
@@ -37,5 +37,7 @@ src-tauri/gen/
|
|||||||
# --- Flatpak build artifacts ---
|
# --- Flatpak build artifacts ---
|
||||||
build-dir/
|
build-dir/
|
||||||
repo/
|
repo/
|
||||||
|
dist/
|
||||||
|
packaging/frontend-dist.tar.gz
|
||||||
*.flatpak
|
*.flatpak
|
||||||
.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
|
.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
|
||||||
|
|||||||
@@ -1,46 +1,113 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="src/assets/moku-icon.svg" width="96" />
|
<img src="docs/banner.svg" width="100%" alt="Moku" />
|
||||||
<h1>Moku</h1>
|
</div>
|
||||||
<p>A fast, minimal manga reader for <a href="https://github.com/Suwayomi/Suwayomi-Server">Suwayomi-Server</a>.<br/>Built with Tauri v2 and Svelte.</p>
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://github.com/Youwes09/Moku/releases/latest)
|
||||||
|
[](https://github.com/Youwes09/Moku/releases/latest)
|
||||||
|
[](./LICENSE)
|
||||||
|
[](https://discord.gg/cfncTbJ2)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br/>
|
||||||
|
|
||||||
|
Moku is a fast, minimal manga reader frontend for [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server). It wraps Suwayomi's GraphQL API in a native desktop app — no browser required, no Electron overhead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="docs/screenshots/Moku-Home.png" width="49%" alt="Home" />
|
||||||
|
<img src="docs/screenshots/Moku-Discover.png" width="49%" alt="Discover" />
|
||||||
|
<img src="docs/screenshots/Moku-Reader.png" width="49%" alt="Reader" />
|
||||||
|
<img src="docs/screenshots/Moku-Preview.png" width="49%" alt="Preview" />
|
||||||
|
<img src="docs/screenshots/Moku-Tracker.png" width="49%" alt="Tracker" />
|
||||||
|
<img src="docs/screenshots/Moku-Settings.png" width="49%" alt="Settings" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<a href="docs/screenshots">View all screenshots →</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Requirements
|
## Features
|
||||||
|
|
||||||
[Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running. By default Moku expects it at `http://127.0.0.1:4567`.
|
- **Library management** — organize manga into folders, track unread counts, filter by genre
|
||||||
|
- **Built-in reader** — single page, long strip, configurable fit modes, customizable keybinds
|
||||||
> Moku will attempt to launch the server automatically on startup if the `suwayomi-server` binary is on your `PATH`.
|
- **Extension support** — install and manage Suwayomi extensions directly from the app
|
||||||
|
- **Download management** — queue and monitor chapter downloads with progress toasts
|
||||||
|
- **Tracker integration** — sync reading progress with AniList, MyAnimeList, Kitsu, and more
|
||||||
|
- **Auto-start server** — optionally launch Suwayomi in the background on startup
|
||||||
|
- **Multiple themes** — Dark, Light, Midnight, Warm, High Contrast, and more
|
||||||
|
- **Auto-updates** — in-app update checker with silent background notifications
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
**Nix (recommended)**
|
### Flatpak (Linux, recommended)
|
||||||
|
|
||||||
|
Suwayomi-Server and a bundled JRE are included — no separate install needed.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix run github:Youwes09/moku
|
flatpak install moku.flatpak
|
||||||
|
flatpak run dev.moku.app
|
||||||
|
```
|
||||||
|
|
||||||
|
Download the latest `moku.flatpak` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
|
||||||
|
|
||||||
|
### Nix
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix run github:Youwes09/Moku
|
||||||
```
|
```
|
||||||
|
|
||||||
Add to your flake:
|
Add to your flake:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
inputs.moku.url = "github:Youwes09/moku";
|
inputs.moku.url = "github:Youwes09/Moku";
|
||||||
```
|
```
|
||||||
|
|
||||||
**From source**
|
### Windows
|
||||||
|
|
||||||
```bash
|
Download the `.exe` installer from the [releases page](https://github.com/Youwes09/Moku/releases/latest). Suwayomi-Server and a JRE are bundled.
|
||||||
git clone https://github.com/Youwes09/moku
|
|
||||||
cd moku
|
### macOS
|
||||||
nix build
|
|
||||||
./result/bin/moku
|
Download the `.dmg` from the [releases page](https://github.com/Youwes09/Moku/releases/latest).
|
||||||
```
|
|
||||||
|
> **Note:** Builds are ad-hoc signed. On first launch you may need to run:
|
||||||
|
> ```bash
|
||||||
|
> xattr -rd com.apple.quarantine /Applications/Moku.app
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
If you're not using the bundled Flatpak or Windows installer, [Suwayomi-Server](https://github.com/Suwayomi/Suwayomi-Server) must be running separately. By default Moku connects to `http://127.0.0.1:4567`.
|
||||||
|
|
||||||
|
You can point Moku at any Suwayomi instance — local or remote — via **Settings → General → Server URL**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
|
**Prerequisites:** [Rust](https://rustup.rs), [Node.js](https://nodejs.org), [pnpm](https://pnpm.io), and [Tauri v2 prerequisites](https://tauri.app/start/prerequisites/).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Youwes09/Moku
|
||||||
|
cd Moku
|
||||||
|
pnpm install
|
||||||
|
pnpm tauri:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Or with Nix:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
nix develop
|
nix develop
|
||||||
pnpm install
|
pnpm install
|
||||||
@@ -54,12 +121,20 @@ pnpm tauri:dev
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [Tauri v2](https://tauri.app) | Native app shell |
|
| [Tauri v2](https://tauri.app) | Native app shell |
|
||||||
| [Svelte](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
| [Svelte 5](https://svelte.dev) + [TypeScript](https://www.typescriptlang.org) | UI |
|
||||||
| [Vite](https://vitejs.dev) | Frontend bundler |
|
| [Vite](https://vitejs.dev) | Frontend bundler |
|
||||||
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
|
| [Crane](https://github.com/ipetkov/crane) | Nix Rust builds |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Community
|
||||||
|
|
||||||
|
Questions, feedback, or just want to hang out — join the Discord.
|
||||||
|
|
||||||
|
[](https://discord.gg/cfncTbJ2)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Distributed under the [Apache 2.0 License](./LICENSE).
|
Distributed under the [Apache 2.0 License](./LICENSE).
|
||||||
@@ -68,4 +143,4 @@ Distributed under the [Apache 2.0 License](./LICENSE).
|
|||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
Moku does not host or distribute any content. The developers have no affiliation with any content providers accessible through connected sources.
|
Moku does not host or distribute any content. The developers have no affiliation with any content providers accessible through connected sources.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 320" width="1280" height="320">
|
||||||
|
<defs>
|
||||||
|
|
||||||
|
<linearGradient id="leafHero" x1="0.3" y1="0" x2="0.7" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#52b888"/>
|
||||||
|
<stop offset="100%" stop-color="#1e5840"/>
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<clipPath id="roundedBounds">
|
||||||
|
<rect width="1280" height="320" rx="18" ry="18"/>
|
||||||
|
</clipPath>
|
||||||
|
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<g clip-path="url(#roundedBounds)">
|
||||||
|
|
||||||
|
<rect width="1280" height="320" fill="#070e09"/>
|
||||||
|
|
||||||
|
<!-- Icon — rotate(7) from moku-icon-splash.svg -->
|
||||||
|
<g transform="translate(640, 148) rotate(7) scale(0.065,-0.065) translate(-5000,-4800)"
|
||||||
|
fill="url(#leafHero)" opacity="0.97">
|
||||||
|
<path d="M4908 6615 c-597 -629 -935 -1264 -934 -1755 0 -508 195 -778 790
|
||||||
|
-1098 l194 -104 22 -114 c33 -164 76 -271 140 -347 80 -94 106 -62 77 94 -47
|
||||||
|
255 43 443 294 613 797 539 714 1392 -250 2551 -230 278 -224 275 -333 160z
|
||||||
|
m52 -945 c0 -472 -8 -850 -17 -850 -36 0 -693 703 -692 740 2 48 167 321 309
|
||||||
|
509 175 233 359 451 380 451 13 0 20 -304 20 -850z m183 775 c120 -126 357
|
||||||
|
-447 356 -482 0 -18 -47 -83 -105 -144 -57 -61 -151 -163 -208 -225 -151 -166
|
||||||
|
-146 -180 -146 406 0 286 7 520 16 520 9 0 48 -34 87 -75z m541 -750 c86 -150
|
||||||
|
196 -391 196 -430 0 -8 -25 -42 -55 -75 -31 -33 -149 -163 -264 -290 -339
|
||||||
|
-374 -480 -520 -501 -520 -13 0 -20 167 -20 465 l1 465 151 170 c83 94 193
|
||||||
|
217 244 275 122 138 137 135 248 -60z m-1098 -633 l374 -378 0 -462 c0 -254
|
||||||
|
-5 -462 -11 -462 -6 0 -55 23 -110 50 l-99 51 0 208 c0 259 -11 288 -176 457
|
||||||
|
-196 200 -188 199 -326 62 l-117 -116 -35 79 c-71 161 -39 569 64 816 42 100
|
||||||
|
20 116 436 -305z m1349 23 c109 -390 -87 -848 -475 -1111 -207 -139 -305 -262
|
||||||
|
-338 -420 -7 -31 -21 -56 -32 -56 -36 -1 -46 86 -48 405 l-2 313 123 137 c68
|
||||||
|
75 214 236 325 357 454 493 421 466 447 375z m-1292 -785 c12 -29 15 -118 7
|
||||||
|
-215 l-14 -166 -73 44 c-174 104 -403 341 -403 416 0 15 47 70 105 123 l104
|
||||||
|
98 127 -125 c70 -69 136 -147 147 -175z"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Stack text pinned to bottom -->
|
||||||
|
<text
|
||||||
|
x="640" y="300"
|
||||||
|
text-anchor="middle"
|
||||||
|
font-family="'SF Mono', 'JetBrains Mono', 'Fira Code', monospace"
|
||||||
|
font-size="14"
|
||||||
|
letter-spacing="5"
|
||||||
|
fill="#a8c4a8"
|
||||||
|
opacity="0.32">TAURI v2 · SVELTE 5 · TYPESCRIPT</text>
|
||||||
|
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 7.1 MiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 914 KiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 648 KiB |
|
After Width: | Height: | Size: 609 KiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 151 KiB |
@@ -274,8 +274,7 @@ fn resolve_server_binary(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Windows: use bundled JRE + JAR
|
#[cfg(not(target_os = "macos"))]
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
{
|
||||||
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
let bundle_dir = resource_dir.join("binaries").join("suwayomi-bundle");
|
||||||
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
let jar = bundle_dir.join("bin").join("Suwayomi-Server.jar");
|
||||||
@@ -308,46 +307,27 @@ fn resolve_server_binary(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// macOS: use the arch-specific sidecar shell script registered via externalBin.
|
|
||||||
// Tauri's shell plugin resolves externalBin names relative to Contents/MacOS/,
|
|
||||||
// so we look there directly rather than in resource_dir.
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
// Contents/MacOS/ is the parent of resource_dir's parent on macOS:
|
|
||||||
// resource_dir = Moku.app/Contents/Resources
|
|
||||||
// macos_dir = Moku.app/Contents/MacOS
|
|
||||||
let macos_dir = resource_dir
|
|
||||||
.parent() // Contents/
|
|
||||||
.map(|p| p.join("MacOS"))
|
|
||||||
.unwrap_or_else(|| resource_dir.clone());
|
|
||||||
|
|
||||||
let candidates = [
|
let candidates = [
|
||||||
"suwayomi-server-aarch64-apple-darwin",
|
"suwayomi-server-aarch64-apple-darwin",
|
||||||
"suwayomi-server-x86_64-apple-darwin",
|
"suwayomi-server-x86_64-apple-darwin",
|
||||||
"suwayomi-server",
|
"suwayomi-server",
|
||||||
];
|
];
|
||||||
|
|
||||||
for name in &candidates {
|
for name in &candidates {
|
||||||
// Check Contents/MacOS/ first (where externalBin sidecars live)
|
let p = resource_dir.join(name);
|
||||||
let in_macos = macos_dir.join(name);
|
do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists()));
|
||||||
// Also check resource_dir root as a fallback
|
if p.exists() {
|
||||||
let in_resources = resource_dir.join(name);
|
do_log(log, &format!("[resolve] using macOS candidate: {:?}", p));
|
||||||
|
return Ok(ServerInvocation {
|
||||||
for p in &[in_macos, in_resources] {
|
bin: p.to_string_lossy().into_owned(),
|
||||||
do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists()));
|
args: vec![],
|
||||||
if p.exists() {
|
working_dir: None,
|
||||||
do_log(log, &format!("[resolve] using macOS candidate: {:?}", p));
|
});
|
||||||
return Ok(ServerInvocation {
|
|
||||||
bin: p.to_string_lossy().into_owned(),
|
|
||||||
args: vec![],
|
|
||||||
working_dir: None,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Linux / PATH fallback for all platforms
|
|
||||||
do_log(log, "[resolve] trying PATH fallback");
|
do_log(log, "[resolve] trying PATH fallback");
|
||||||
for name in &["suwayomi-server", "tachidesk-server"] {
|
for name in &["suwayomi-server", "tachidesk-server"] {
|
||||||
let found = std::process::Command::new("which")
|
let found = std::process::Command::new("which")
|
||||||
|
|||||||
@@ -104,10 +104,29 @@
|
|||||||
getVersion(),
|
getVersion(),
|
||||||
invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"),
|
invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"),
|
||||||
]);
|
]);
|
||||||
if (!releases.length) return;
|
|
||||||
|
|
||||||
const latestTag = releases[0].tag_name.replace(/^v/, "");
|
// Filter out drafts / incomplete releases that have no tag_name
|
||||||
if (latestTag !== currentVersion) {
|
const valid = releases.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
|
||||||
|
if (!valid.length) return;
|
||||||
|
|
||||||
|
const parse = (tag: string): number[] =>
|
||||||
|
tag.replace(/^v/, "").split(".").map(Number);
|
||||||
|
|
||||||
|
const compare = (a: number[], b: number[]): number => {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const latestTag = valid
|
||||||
|
.map(r => r.tag_name)
|
||||||
|
.sort((a, b) => compare(parse(a), parse(b)))[0]
|
||||||
|
.replace(/^v/, "");
|
||||||
|
|
||||||
|
// Only toast if latest is strictly newer than installed
|
||||||
|
const isNewer = compare(parse(latestTag), parse(currentVersion)) < 0;
|
||||||
|
if (isNewer) {
|
||||||
addToast({
|
addToast({
|
||||||
kind: "info",
|
kind: "info",
|
||||||
title: `Update available — v${latestTag}`,
|
title: `Update available — v${latestTag}`,
|
||||||
|
|||||||
@@ -336,16 +336,26 @@
|
|||||||
let dlTotal = $state<number | null>(null);
|
let dlTotal = $state<number | null>(null);
|
||||||
let targetTag = $state<string | null>(null); // tag being installed
|
let targetTag = $state<string | null>(null); // tag being installed
|
||||||
|
|
||||||
|
let releasesLoaded = false; // plain var — not $state, so effect doesn't re-run on change
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (tab !== "about") return;
|
if (tab !== "about") return;
|
||||||
getVersion().then(v => appVersion = v).catch(() => appVersion = "unknown");
|
getVersion().then(v => appVersion = v).catch(() => appVersion = "unknown");
|
||||||
if (releases.length === 0 && !releasesLoading) loadReleases();
|
if (!releasesLoaded) { releasesLoaded = true; loadReleases(); }
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadReleases() {
|
async function loadReleases() {
|
||||||
releasesLoading = true; releasesError = null;
|
releasesLoading = true; releasesError = null;
|
||||||
try {
|
try {
|
||||||
releases = await invoke<ReleaseInfo[]>("list_releases");
|
const timeout = new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error("Request timed out after 10s")), 10_000)
|
||||||
|
);
|
||||||
|
const all = await Promise.race([
|
||||||
|
invoke<ReleaseInfo[]>("list_releases"),
|
||||||
|
timeout,
|
||||||
|
]);
|
||||||
|
// Filter out drafts / incomplete entries with no tag_name
|
||||||
|
releases = all.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
releasesError = e instanceof Error ? e.message : String(e);
|
releasesError = e instanceof Error ? e.message : String(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -358,6 +368,25 @@
|
|||||||
|
|
||||||
function isCurrentVersion(tag: string) { return stripV(tag) === appVersion; }
|
function isCurrentVersion(tag: string) { return stripV(tag) === appVersion; }
|
||||||
|
|
||||||
|
function parseSemver(v: string): number[] {
|
||||||
|
return stripV(v).split(".").map(Number);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSemver(a: string, b: string): number {
|
||||||
|
const pa = parseSemver(a), pb = parseSemver(b);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pb[i] ?? 0) - (pa[i] ?? 0);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when releases are loaded and installed version is >= highest published tag
|
||||||
|
let onLatestVersion = $derived((() => {
|
||||||
|
if (releasesLoading || releases.length === 0 || !appVersion || appVersion === "…") return false;
|
||||||
|
const sorted = releases.slice().sort((a, b) => compareSemver(a.tag_name, b.tag_name));
|
||||||
|
return compareSemver(appVersion, sorted[0].tag_name) >= 0;
|
||||||
|
})());
|
||||||
|
|
||||||
function fmtDate(iso: string): string {
|
function fmtDate(iso: string): string {
|
||||||
if (!iso) return "";
|
if (!iso) return "";
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -1044,10 +1073,15 @@
|
|||||||
<span class="toggle-desc">v{appVersion}</span>
|
<span class="toggle-desc">v{appVersion}</span>
|
||||||
</div>
|
</div>
|
||||||
<button class="step-btn" style="width:auto;padding:0 var(--sp-3);font-size:var(--text-xs);letter-spacing:var(--tracking-wide)"
|
<button class="step-btn" style="width:auto;padding:0 var(--sp-3);font-size:var(--text-xs);letter-spacing:var(--tracking-wide)"
|
||||||
onclick={loadReleases} disabled={releasesLoading}>
|
onclick={() => { releasesError = null; loadReleases(); }} disabled={releasesLoading}>
|
||||||
{releasesLoading ? "Loading…" : "Refresh"}
|
{releasesLoading ? "Loading…" : "Refresh"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{#if onLatestVersion}
|
||||||
|
<div class="step-row">
|
||||||
|
<span class="toggle-desc" style="padding:0 var(--sp-3);color:var(--accent-fg)">✓ You're on the latest version.</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- active download progress -->
|
<!-- active download progress -->
|
||||||
{#if updatePhase === "downloading" && IS_WINDOWS}
|
{#if updatePhase === "downloading" && IS_WINDOWS}
|
||||||
@@ -1157,6 +1191,7 @@
|
|||||||
<p class="section-title">Links</p>
|
<p class="section-title">Links</p>
|
||||||
<div class="about-block">
|
<div class="about-block">
|
||||||
<a href="https://github.com/Youwes09/Moku" target="_blank" class="about-line" style="color:var(--accent-fg);text-decoration:none">GitHub →</a>
|
<a href="https://github.com/Youwes09/Moku" target="_blank" class="about-line" style="color:var(--accent-fg);text-decoration:none">GitHub →</a>
|
||||||
|
<a href="https://discord.gg/cfncTbJ2" target="_blank" class="about-line" style="color:var(--accent-fg);text-decoration:none;margin-top:var(--sp-1)">Discord →</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||