Compare commits

..

5 commits

Author SHA1 Message Date
smartfrigde
758b97e63e chore: linting 2026-07-23 13:43:14 +02:00
smartfrigde
0f7ac5449c feat: load quick css editor locally
faster loads, more secure
2026-07-23 13:41:10 +02:00
smartfrigde
bd3f8de6bc feat: unify sanitization 2026-07-23 13:41:10 +02:00
smartfrigde
5d6cc64db7 fix: URL substring sanitization 2026-07-23 13:41:10 +02:00
youtsuho
d0dda79fbf
feat: redesign/about window (#1121) 2026-07-23 13:40:43 +02:00
10 changed files with 457 additions and 159 deletions

File diff suppressed because one or more lines are too long

View file

@ -46,6 +46,7 @@
"electron": "43.2.0", "electron": "43.2.0",
"electron-builder": "26.8.1", "electron-builder": "26.8.1",
"lucide-solid": "^1.16.0", "lucide-solid": "^1.16.0",
"monaco-editor": "0.52.2",
"rolldown": "1.0.3", "rolldown": "1.0.3",
"rollup-plugin-copy": "^3.5.0", "rollup-plugin-copy": "^3.5.0",
"solid-js": "^1.9.14", "solid-js": "^1.9.14",

8
pnpm-lock.yaml generated
View file

@ -84,6 +84,9 @@ importers:
lucide-solid: lucide-solid:
specifier: ^1.16.0 specifier: ^1.16.0
version: 1.16.0(solid-js@1.9.14) version: 1.16.0(solid-js@1.9.14)
monaco-editor:
specifier: 0.52.2
version: 0.52.2
rolldown: rolldown:
specifier: 1.0.3 specifier: 1.0.3
version: 1.0.3 version: 1.0.3
@ -2104,6 +2107,9 @@ packages:
mlly@1.8.2: mlly@1.8.2:
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
monaco-editor@0.52.2:
resolution: {integrity: sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==}
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@ -4845,6 +4851,8 @@ snapshots:
pkg-types: 1.3.1 pkg-types: 1.3.1
ufo: 1.6.4 ufo: 1.6.4
monaco-editor@0.52.2: {}
ms@2.1.3: {} ms@2.1.3: {}
neo-async@2.6.2: {} neo-async@2.6.2: {}

View file

@ -34,6 +34,8 @@ export default defineConfig([
{ src: "src/**/**/*.js", dest: "ts-out/js/" }, { src: "src/**/**/*.js", dest: "ts-out/js/" },
{ src: "package.json", dest: "ts-out/" }, { src: "package.json", dest: "ts-out/" },
{ src: "assets/**/**", dest: "ts-out/assets/" }, { src: "assets/**/**", dest: "ts-out/assets/" },
// Monaco AMD tree for the offline Quick CSS editor (next to editor.html)
{ src: "node_modules/monaco-editor/min/vs/**/*", dest: "ts-out/html/monaco/vs" },
], ],
}), }),
], ],

View file

@ -0,0 +1,88 @@
/** True when hostname is exactly `domain` or a subdomain of it (case-insensitive). */
export function hostnameMatches(hostname: string, domain: string): boolean {
const host = hostname.toLowerCase();
const target = domain.toLowerCase();
return host === target || host.endsWith(`.${target}`);
}
export function tryParseUrl(urlString: string): URL | null {
try {
return new URL(urlString);
} catch {
return null;
}
}
/** True when the frame is a YouTube embed (or Discord Activities YouTube proxy). */
export function isYouTubeEmbedOrProxyFrame(frameUrl: string): boolean {
const url = tryParseUrl(frameUrl);
if (!url) return false;
if (hostnameMatches(url.hostname, "youtube.com") && url.pathname.includes("/embed/")) {
return true;
}
// Discord Activities may proxy YouTube through *.discordsays.com (youtube.com appears in the path/query)
if (
hostnameMatches(url.hostname, "discordsays.com") &&
(url.pathname.includes("youtube.com") || url.search.includes("youtube.com"))
) {
return true;
}
return false;
}
/** Telemetry / noise hosts and Discord science endpoints we cancel in webRequest. */
export function isTelemetryBlockedUrl(requestUrl: string): boolean {
const url = tryParseUrl(requestUrl);
if (url?.protocol !== "https:") return false;
if (/^\/api\/v\d+\/science(?:\/|$)/.test(url.pathname)) return true;
if (hostnameMatches(url.hostname, "sentry.io")) return true;
if (hostnameMatches(url.hostname, "nel.cloudflare.com")) return true;
return false;
}
/**
* Blob downloads for Discord calendar (.ics) use `blob:https://discord.com/<uuid>`.
* Parse the blob payload URL so `discord.com.evil` / userinfo tricks cannot match.
*/
export function isDiscordIcsBlobUrl(urlString: string): boolean {
const blobUrl = tryParseUrl(urlString);
if (blobUrl?.protocol !== "blob:") return false;
const inner = tryParseUrl(blobUrl.pathname);
if (!inner || (inner.protocol !== "https:" && inner.protocol !== "http:")) return false;
return hostnameMatches(inner.hostname, "discord.com");
}
/** Discord stream popout windows (stable / canary / PTB). */
export function isDiscordPopoutUrl(urlString: string): boolean {
const url = tryParseUrl(urlString);
if (url?.protocol !== "https:" || url.pathname !== "/popout") return false;
const host = url.hostname.toLowerCase();
return host === "discord.com" || host === "canary.discord.com" || host === "ptb.discord.com";
}
const DEFAULT_ALLOWED_LOCALHOST_WS_PORTS = new Set([1211, 1112, 6888]);
/**
* Block stray localhost WebSocket probes, except known Legcord/local RPC ports.
* Uses URL parsing instead of substring checks on the raw request URL.
*/
export function isBlockedLocalhostWebSocket(
requestUrl: string,
allowedPorts: ReadonlySet<number> = DEFAULT_ALLOWED_LOCALHOST_WS_PORTS,
): boolean {
const url = tryParseUrl(requestUrl);
if (!url || (url.protocol !== "ws:" && url.protocol !== "wss:")) return false;
if (url.hostname !== "127.0.0.1" && url.hostname !== "localhost") return false;
const port = url.port ? Number(url.port) : url.protocol === "wss:" ? 443 : 80;
if (!Number.isFinite(port)) return true;
return !allowedPorts.has(port);
}

View file

@ -6,20 +6,23 @@
<link rel="stylesheet" href="legcord://assets/css/editor.css"> <link rel="stylesheet" href="legcord://assets/css/editor.css">
</head> </head>
<body> <body>
<script type="text/javascript" src="legcord://assets/js/monacoLoader.js"></script> <script type="text/javascript" src="./monaco/vs/loader.js"></script>
<div id="editorCode"></div> <div id="editorCode"></div>
<script> <script>
const cssCode = cssEditor.get; const cssCode = cssEditor.get;
const monacoRoot = new URL("./monaco/", window.location.href).href;
const vs = new URL("./monaco/vs", window.location.href).href;
require.config({ require.config({
paths: { vs: "https://cdn.jsdelivr.net/npm/monaco-editor/min/vs" } paths: { vs }
}); });
window.MonacoEnvironment = { window.MonacoEnvironment = {
getWorkerUrl: (_workerId, _label) => `data:text/javascript;charset=utf-8,${encodeURIComponent(` getWorkerUrl: () =>
self.MonacoEnvironment = { `data:text/javascript;charset=utf-8,${encodeURIComponent(`
baseUrl: 'https://cdn.jsdelivr.net/npm/monaco-editor/min/' self.MonacoEnvironment = { baseUrl: ${JSON.stringify(monacoRoot)} };
}; importScripts(${JSON.stringify(`${vs}/base/worker/workerMain.js`)});
importScripts('https://cdn.jsdelivr.net/npm/monaco-editor/min/vs/base/worker/workerMain.js');`)}` `)}`,
}; };
// Monaco init // Monaco init
@ -49,8 +52,6 @@
cssEditor.set(editor.getValue()); cssEditor.set(editor.getValue());
}); });
} }
</script> </script>
</body> </body>
</html> </html>

View file

@ -14,7 +14,7 @@ export function openCssEditor(file: string) {
preload: path.join(import.meta.dirname, "cssEditor", "preload.mjs"), preload: path.join(import.meta.dirname, "cssEditor", "preload.mjs"),
}, },
}); });
cssWindow.loadURL("legcord://html/editor.html"); void cssWindow.loadFile(path.join(import.meta.dirname, "html", "editor.html"));
ipcMain.on("editor-setCSS", (_event, css: string) => { ipcMain.on("editor-setCSS", (_event, css: string) => {
fs.writeFileSync(file, css); fs.writeFileSync(file, css);

View file

@ -19,6 +19,13 @@ import { navigateTo } from "../common/dom.js";
import { forceQuit, setForceQuit } from "../common/forceQuit.js"; import { forceQuit, setForceQuit } from "../common/forceQuit.js";
import { handleCommands, passedValidArgument } from "../common/handleCommands.js"; import { handleCommands, passedValidArgument } from "../common/handleCommands.js";
import { getLang } from "../common/lang.js"; import { getLang } from "../common/lang.js";
import {
isBlockedLocalhostWebSocket,
isDiscordIcsBlobUrl,
isDiscordPopoutUrl,
isTelemetryBlockedUrl,
isYouTubeEmbedOrProxyFrame,
} from "../common/sanitization.js";
import { injectThemesMain } from "../common/themes.js"; import { injectThemesMain } from "../common/themes.js";
import { import {
DEFAULT_WINDOW_HEIGHT, DEFAULT_WINDOW_HEIGHT,
@ -199,10 +206,7 @@ function doAfterDefiningTheWindow(passedWindow: BrowserWindow): void {
return; return;
} }
frame.once("dom-ready", async () => { frame.once("dom-ready", async () => {
if ( if (isYouTubeEmbedOrProxyFrame(frame.url)) {
frame.url.includes("youtube.com/embed/") ||
(frame.url.includes("discordsays") && frame.url.includes("youtube.com"))
) {
await frame.executeJavaScript(readFileSync(path.join(__dirname, "assets/js/adguard.js"), "utf-8")); await frame.executeJavaScript(readFileSync(path.join(__dirname, "assets/js/adguard.js"), "utf-8"));
} }
}); });
@ -211,18 +215,14 @@ function doAfterDefiningTheWindow(passedWindow: BrowserWindow): void {
// Allow about:blank (used by Vencord & Equicord QuickCss popup) // Allow about:blank (used by Vencord & Equicord QuickCss popup)
if (url === "about:blank") return { action: "allow" }; if (url === "about:blank") return { action: "allow" };
// Saving ics files on future events // Saving ics files on future events
if (url.startsWith("blob:https://discord.com/")) { if (isDiscordIcsBlobUrl(url)) {
return { return {
action: "allow", action: "allow",
overrideBrowserWindowOptions: { show: false }, overrideBrowserWindowOptions: { show: false },
}; };
} }
// Allow Discord stream popout // Allow Discord stream popout
if ( if (isDiscordPopoutUrl(url))
url === "https://discord.com/popout" ||
url === "https://canary.discord.com/popout" ||
url === "https://ptb.discord.com/popout"
)
return { return {
action: "allow", action: "allow",
overrideBrowserWindowOptions: { overrideBrowserWindowOptions: {
@ -267,21 +267,8 @@ function doAfterDefiningTheWindow(passedWindow: BrowserWindow): void {
registerCustomHandler(); registerCustomHandler();
const blockedPatterns = [
/https:\/\/.*\/api\/v\d+\/science/,
/https:\/\/sentry\.io\/.*/,
/https:\/\/.*\.nel\.cloudflare\.com\/.*/,
];
passedWindow.webContents.session.webRequest.onBeforeRequest((details, callback) => { passedWindow.webContents.session.webRequest.onBeforeRequest((details, callback) => {
if (blockedPatterns.some((pattern) => pattern.test(details.url))) { if (isTelemetryBlockedUrl(details.url) || isBlockedLocalhostWebSocket(details.url)) {
return callback({ cancel: true });
}
if (
details.url.includes("ws://127.0.0.1:") &&
!details.url.includes("127.0.0.1:1211") &&
!details.url.includes("127.0.0.1:1112") &&
!details.url.includes("127.0.0.1:6888")
) {
return callback({ cancel: true }); return callback({ cancel: true });
} }
return callback({}); return callback({});

View file

@ -41,17 +41,60 @@ export const AboutPopup = (props: { close: () => void }) => {
<ModalBody> <ModalBody>
<div class={classes.aboutContainer}> <div class={classes.aboutContainer}>
<div class={classes.aboutHeader}> <div class={classes.aboutHeader}>
<h2 class={classes.aboutTitle}>Legcord</h2> <div class={classes.aboutLogo}>
<svg
width="200"
height="90"
viewBox="0 0 2249 1020"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
fill-rule="evenodd"
fill="#111725"
d="m102.2 705v-478.6h66.8v478.6zm375.5-196.1q-1.2-29-12.2-49-10.5-20.6-29.6-31.6-19.2-11.6-44.1-11.6-27.9 0-48.8 14.2-20.3 14.2-31.9 40-7.9 16.9-10.4 38zm-166 85.2q12.7 27 34.8 41.9 22.6 14.8 51.7 14.8 51.6 0 87-40.6l35.5 39.3q-22.7 27.8-55.2 43.2-32.5 14.9-72.6 14.9-47.6 0-83-22-35.4-21.9-55.1-60.6-19.8-39.3-19.8-90.9 0-51.6 19.8-90.3 19.7-39.4 55.1-61.3 35.4-21.9 81.3-22.6 54.6 0 87.7 25.2 33 24.5 47 69.6 13.9 45.2 9.8 107.1h-233.7q3.1 17.6 9.7 32.3zm565.9-231.6v308.3q0 49.7-19.7 85.8-19.8 36.1-55.8 56.1-36 19.4-84.7 19.4-36 0-67.9-11.6-32-12.3-60.4-33.6l28.4-50.9q22.1 18 45.3 28.3 23.2 9.7 51.7 9.7 29.6 0 51.1-12.2 22-12.9 33.6-35.5 12.2-22.6 12.2-53.6v-41.6q-13.3 23.7-34.2 38.4-27.9 19.4-67.9 19.4-41.8 0-73.2-20.7-31.3-21.3-48.8-58.7-17.4-37.4-17.4-86.4 0-48.4 16.9-84.5 17.4-36.8 48.2-57.4 31.3-21.3 71.9-21.3 40.7-0.6 69.1 19.4 21.6 14.3 35.4 38.3v-55.1zm-155 54.2q-26.1 0-46.4 14.2-20.3 13.5-31.9 38-11.1 24.5-11.6 56.1 0.5 31.6 11.6 56.2 11.6 24.5 31.3 38.7 20.3 13.5 47 13.5 26.2 0 45.9-13.5 20.3-14.2 31.3-38.7 11.6-24.6 11.6-56.2 0-32.2-11.6-56.1-11-24.5-31.3-38-19.7-14.2-45.9-14.2z"
/>
<path
fill-rule="evenodd"
fill="#9538e6"
d="m1218.3 412.8l-36.5 47.7q-16.3-18.7-38.4-29-22-10.3-49.9-10.3-26.7 0-47.6 14.2-20.9 14.2-32.5 40-11.6 25.1-11.6 58.7 0 33.5 11.6 59.3 11.6 25.2 32.5 40 20.9 14.2 47.6 14.2 29 0 52.3-10.3 23.2-11 38.9-31.6l37.1 40.6q-20.9 29-54.6 45.2-33 16.1-76.6 16.1-45.8 0-81.3-22-34.8-21.9-54.5-60.6-19.8-39.3-19.8-90.9 0-51.6 19.8-90.3 19.7-39.4 54.5-61.3 35.5-22.6 81.3-22.6 41.8 0 73.7 14.2 32.6 13.6 54 38.7zm186.9-52.9q47.6 0 83.6 21.9 36.6 22 56.9 61.3 20.3 38.7 20.3 90.3 0 51.6-20.3 91-20.3 39.3-56.9 61.2-36 22-83.6 22-47.6 0-84.2-22-36.6-21.9-56.9-61.2-20.3-39.4-20.3-91 0-51.6 20.3-90.3 20.3-39.3 56.9-61.3 36.6-21.9 84.2-21.9zm0 60q-27.9 0-49.3 14.8-21.5 14.2-33.7 40-11.6 25.8-11.6 59.4 0 34.2 11.6 60 12.2 25.8 33.7 40.6 21.4 14.2 49.3 14.2 27.9 0 48.8-14.2 21.4-14.8 33-40.6 12.2-25.8 12.2-60 0-33.6-12.2-59.4-11.6-25.8-33-40-20.9-14.8-48.8-14.8zm397.6-60v67.1q-33.1-1.9-57.5 12.9-23.8 14.2-37.1 40-10.1 20.3-12.2 45.4v179.7h-66.8v-342.5h66.8v67.7q14.6-32.2 39.4-49.6 28.5-20.7 67.4-20.7zm166.5-0.6q43 0 72.6 21.3 22.7 15.5 37.1 41.5v-195.7h66.8v478.6h-66.8v-58.5q-14.3 25.4-36.5 41.1-29.6 20.6-72 20.6-44.1 0-77.8-21.9-33.1-22.6-51.7-61.9-18.5-40-18.5-91.6 0-51.6 18.5-90.3 18.6-39.4 51.7-61.3 33.1-21.9 76.6-21.9zm15.1 60q-27.8 0-49.3 14.8-21.5 14.8-33.7 41.3-11.6 25.8-12.2 59.3 0.6 33.6 12.2 59.4 12.2 25.8 33.7 40.6 21.5 14.8 49.3 14.8 27.9 0 49.4-14.8 21.4-14.8 33.1-40.6 12.1-25.8 12.1-59.4 0-34.2-12.1-60-11.7-25.8-33.1-40.6-21.5-14.8-49.4-14.8z"
/>
<path
fill="#cdc6fd"
stroke="#111725"
stroke-miterlimit="10"
stroke-width="20"
d="m528.7 775.2l-52.3 27.2c-28.7 14.9-44.2 26.8-34.2 44.9 9 18.7 27.5 12.9 56.2-2l52.4-27.3"
/>
<path
fill="#cdc6fd"
stroke="#111725"
stroke-miterlimit="10"
stroke-width="20"
d="m598.2 910l-52.4 27.2c-28.6 14.9-44.1 26.8-34.1 44.9 9 18.6 27.5 12.8 56.2-2.1l52.4-27.2"
/>
<path
fill="#9538e6"
stroke="#111725"
stroke-miterlimit="10"
stroke-width="20"
d="m495.6 757.8c1.7-1 27.6-14.6 30.6-16.2 34.9-18.6 94.6-32.2 140.1-4.9 25.2 15.2 38.5 36.2 48.6 54.4 9.3 19.4 18.4 41.8 16.2 71.3-4 53.1-49.6 94.2-84.9 112.1-2.9 1.5-29 14.9-30.8 15.7z"
/>
</svg>
</div>
<span class={classes.aboutVersion}>
{window.legcord?.version === "0.0.0"
? "Dev Build"
: `v${window.legcord?.version ?? "1.3.0"}`}
</span>
<p class={classes.aboutDescription}> <p class={classes.aboutDescription}>
Legcord is a free and open-source Discord client that offers a personalized experience with A free and open-source Discord client crafted by the community, for the community
advanced features, better performance, and a modern design. Developed by the community, for delivering advanced features, better performance, and a modern design.
the community.
</p> </p>
</div> </div>
{/* Quick Actions */}
<div class={classes.quickActions}> <div class={classes.quickActions}>
<h3 class={classes.quickActionsTitle}>Quick Actions</h3> <h3 class={classes.sectionTitle}>Quick Actions</h3>
<div class={classes.quickActionsList}> <div class={classes.quickActionsList}>
<button <button
type="button" type="button"
@ -68,13 +111,30 @@ export const AboutPopup = (props: { close: () => void }) => {
stroke-width="2" stroke-width="2"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
aria-hidden="true"
> >
<title>View Source Code</title>
<polyline points="16 18 22 12 16 6" /> <polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" /> <polyline points="8 6 2 12 8 18" />
</svg> </svg>
</span> </span>
View Source Code <span class={classes.quickActionButtonLabel}>
<span class={classes.quickActionButtonTitle}>View Source Code</span>
<span class={classes.quickActionButtonDesc}>github.com/Legcord/Legcord</span>
</span>
<svg
class={classes.quickActionButtonArrow}
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button> </button>
<button <button
type="button" type="button"
@ -91,13 +151,30 @@ export const AboutPopup = (props: { close: () => void }) => {
stroke-width="2" stroke-width="2"
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
aria-hidden="true"
> >
<title>Donate</title>
<line x1="12" y1="1" x2="12" y2="23" /> <line x1="12" y1="1" x2="12" y2="23" />
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" /> <path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg> </svg>
</span> </span>
Donate <span class={classes.quickActionButtonLabel}>
<span class={classes.quickActionButtonTitle}>Donate</span>
<span class={classes.quickActionButtonDesc}>Support the project</span>
</span>
<svg
class={classes.quickActionButtonArrow}
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button> </button>
<button <button
type="button" type="button"
@ -105,19 +182,44 @@ export const AboutPopup = (props: { close: () => void }) => {
onClick={() => window.open("https://discord.gg/JatCnhKufc", "_blank")} onClick={() => window.open("https://discord.gg/JatCnhKufc", "_blank")}
> >
<span class={classes.quickActionButtonIcon}> <span class={classes.quickActionButtonIcon}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" stroke="none"> <svg
<title>Join Discord Server</title> width="20"
height="20"
viewBox="0 0 24 24"
fill="currentColor"
stroke="none"
aria-hidden="true"
>
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z" /> <path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515a.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0a12.64 12.64 0 0 0-.617-1.25a.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057a19.9 19.9 0 0 0 5.993 3.03a.078.078 0 0 0 .084-.028a14.09 14.09 0 0 0 1.226-1.994a.076.076 0 0 0-.041-.106a13.107 13.107 0 0 1-1.872-.892a.077.077 0 0 1-.008-.128a10.2 10.2 0 0 0 .372-.292a.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127a12.299 12.299 0 0 1-1.873.892a.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028a19.839 19.839 0 0 0 6.002-3.03a.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.956-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419c0-1.333.955-2.419 2.157-2.419c1.21 0 2.176 1.096 2.157 2.42c0 1.333-.946 2.418-2.157 2.418z" />
</svg> </svg>
</span> </span>
Join Discord Server <span class={classes.quickActionButtonLabel}>
<span class={classes.quickActionButtonTitle}>Join Discord Server</span>
<span class={classes.quickActionButtonDesc}>Connect with the community</span>
</span>
<svg
class={classes.quickActionButtonArrow}
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button> </button>
</div> </div>
</div> </div>
{/* Contributors Section */}
<div class={classes.contributorsSection}> <div class={classes.contributorsSection}>
<h3 class={classes.contributorsTitle}>Contributors</h3> <h3 class={classes.sectionTitle}>Contributors</h3>
{!loading() && !error() && (
<span class={classes.contributorBadge}>{contributors().length} contributors</span>
)}
{loading() && <div class={classes.loadingState}>Loading contributors...</div>} {loading() && <div class={classes.loadingState}>Loading contributors...</div>}
{error() && <div class={classes.errorState}>Failed to load contributors: {error()}</div>} {error() && <div class={classes.errorState}>Failed to load contributors: {error()}</div>}
{!loading() && !error() && ( {!loading() && !error() && (
@ -147,6 +249,22 @@ export const AboutPopup = (props: { close: () => void }) => {
</div> </div>
)} )}
</div> </div>
<div class={classes.techSection}>
<h3 class={classes.sectionTitle}>Technologies</h3>
<div class={classes.techList}>
<span class={classes.techTag}>Electron</span>
<span class={classes.techTag}>SolidJS</span>
<span class={classes.techTag}>TypeScript</span>
<span class={classes.techTag}>Rolldown</span>
<span class={classes.techTag}>shelter</span>
<span class={classes.techTag}>Biome</span>
</div>
</div>
<div class={classes.aboutFooter}>
<span>Copyright &copy; Legcord 2024-2026 &middot; OSL-3.0</span>
</div>
</div> </div>
</ModalBody> </ModalBody>
</ModalRoot> </ModalRoot>

View file

@ -93,75 +93,75 @@
min-height: auto; min-height: auto;
} }
.aboutHeader { .aboutLogo {
text-align: center; display: flex;
margin-bottom: 32px; justify-content: center;
padding: 24px; margin-bottom: 20px;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-accent);
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
} }
.aboutTitle { .aboutLogo svg {
font-size: 32px; opacity: 0.95;
font-weight: 700; }
margin: 0 0 20px 0;
color: var(--header-primary); .aboutHeader {
letter-spacing: -0.5px; text-align: center;
margin-bottom: 24px;
padding: 32px 24px 24px;
background: linear-gradient(180deg, var(--background-secondary-alt) 0%, var(--background-secondary) 100%);
border: 1px solid var(--background-modifier-accent);
border-radius: 12px;
}
.aboutVersion {
font-size: 13px;
color: var(--text-muted);
margin: 0 0 16px 0;
padding: 6px 14px;
background: var(--background-tertiary);
border-radius: 20px;
display: inline-block;
font-family: var(--font-mono);
border: 1px solid var(--background-modifier-accent);
font-weight: 500;
letter-spacing: 0.3px;
} }
.aboutDescription { .aboutDescription {
font-size: 16px; font-size: 14px;
line-height: 1.6; line-height: 1.5;
color: var(--text-muted); color: var(--text-muted);
margin: 0 0 24px 0; margin: 0;
max-width: 600px; padding: 0 8px;
max-width: 520px;
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
font-weight: 400; font-weight: 400;
} }
.aboutVersion { .sectionTitle {
font-size: 14px; font-size: 16px;
color: var(--text-muted); font-weight: 600;
margin: 0; margin: 0 0 16px 0;
padding: 10px 18px; color: var(--header-primary);
background: var(--background-tertiary); letter-spacing: -0.2px;
border-radius: 8px; text-transform: uppercase;
display: inline-block; opacity: 0.7;
font-family: var(--font-mono);
border: 1px solid var(--background-modifier-accent);
font-weight: 500;
} }
.quickActions { .quickActions {
background: var(--background-secondary); margin-bottom: 24px;
border: 1px solid var(--background-modifier-accent);
border-radius: 12px;
padding: 20px;
margin: 0 0 32px 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.quickActionsTitle {
font-size: 20px;
font-weight: 600;
margin: 0 0 20px 0;
color: var(--header-primary);
letter-spacing: -0.3px;
} }
.quickActionsList { .quickActionsList {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 8px;
} }
.quickActionButton { .quickActionButton {
background: var(--background-secondary-alt); background: var(--background-secondary);
border: 1px solid var(--background-modifier-accent); border: 1px solid var(--background-modifier-accent);
border-radius: 8px; border-radius: 10px;
padding: 12px 16px; padding: 12px 16px;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
@ -171,73 +171,110 @@
align-items: center; align-items: center;
text-align: left; text-align: left;
width: 100%; width: 100%;
transition: all 0.15s ease; transition: all 0.2s ease;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.quickActionButton::before {
content: "";
position: absolute;
inset: 0;
background: var(--brand-experiment);
opacity: 0;
transition: opacity 0.2s ease;
}
.quickActionButton:hover { .quickActionButton:hover {
background: var(--background-tertiary); border-color: var(--brand-experiment);
border-color: var(--background-modifier-hover); transform: translateX(4px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); }
transform: translateY(-1px);
.quickActionButton:hover::before {
opacity: 0.04;
} }
.quickActionButton:active { .quickActionButton:active {
transform: translateY(0); transform: translateX(2px);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
} }
.quickActionButtonIcon { .quickActionButtonIcon {
margin-right: 12px; margin-right: 12px;
font-size: 18px;
color: var(--interactive-normal);
transition: color 0.15s ease;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 20px; width: 36px;
height: 20px; height: 36px;
border-radius: 8px;
background: var(--background-tertiary);
color: var(--interactive-normal);
flex-shrink: 0;
transition: all 0.2s ease;
} }
.quickActionButton:hover .quickActionButtonIcon { .quickActionButton:hover .quickActionButtonIcon {
color: var(--interactive-hover); color: var(--brand-experiment);
background: var(--background-secondary-alt);
}
.quickActionButtonLabel {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.quickActionButtonTitle {
font-size: 14px;
font-weight: 600;
color: var(--header-primary);
}
.quickActionButtonDesc {
font-size: 12px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.quickActionButtonArrow {
margin-left: 8px;
color: var(--text-muted);
flex-shrink: 0;
opacity: 0;
transition: all 0.2s ease;
}
.quickActionButton:hover .quickActionButtonArrow {
opacity: 1;
transform: translateX(2px);
color: var(--brand-experiment);
} }
.contributorsSection { .contributorsSection {
padding: 20px; padding: 20px;
background: var(--background-secondary); background: var(--background-secondary);
border: 1px solid var(--background-accent); border: 1px solid var(--background-modifier-accent);
border-radius: 12px; border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.contributorsTitle {
font-size: 20px;
font-weight: 600;
margin: 0 0 24px 0;
color: var(--header-primary);
text-align: center;
letter-spacing: -0.3px;
} }
.contributorsGrid { .contributorsGrid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 16px; gap: 10px;
padding: 0; padding: 0;
max-width: 100%; max-width: 100%;
width: 100%; width: 100%;
justify-items: center; max-height: 280px;
max-height: 40vh;
overflow-y: auto; overflow-y: auto;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--background-modifier-accent) transparent; scrollbar-color: var(--background-modifier-accent) transparent;
} }
.contributorsGrid::-webkit-scrollbar { .contributorsGrid::-webkit-scrollbar {
width: 6px; width: 4px;
} }
.contributorsGrid::-webkit-scrollbar-track { .contributorsGrid::-webkit-scrollbar-track {
@ -246,7 +283,7 @@
.contributorsGrid::-webkit-scrollbar-thumb { .contributorsGrid::-webkit-scrollbar-thumb {
background: var(--background-modifier-accent); background: var(--background-modifier-accent);
border-radius: 3px; border-radius: 2px;
} }
.contributorsGrid::-webkit-scrollbar-thumb:hover { .contributorsGrid::-webkit-scrollbar-thumb:hover {
@ -256,27 +293,23 @@
.contributorCard { .contributorCard {
background: var(--background-primary); background: var(--background-primary);
border: 1px solid var(--background-modifier-accent); border: 1px solid var(--background-modifier-accent);
border-radius: 12px; border-radius: 10px;
padding: 20px; padding: 14px 12px;
text-align: center; text-align: center;
transition: all 0.15s ease; transition: all 0.2s ease;
min-width: 180px;
max-width: 100%;
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
position: relative;
} }
.contributorCard:hover { .contributorCard:hover {
background: var(--background-secondary); background: var(--background-secondary-alt);
border-color: var(--background-modifier-hover); border-color: var(--brand-experiment);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
} }
.contributorLink { .contributorLink {
@ -290,46 +323,98 @@
} }
.contributorAvatar { .contributorAvatar {
width: 64px; width: 48px;
height: 64px; height: 48px;
border-radius: 50%; border-radius: 50%;
margin-bottom: 16px; margin-bottom: 10px;
max-width: 100%; max-width: 100%;
border: 3px solid var(--background-modifier-accent); border: 2px solid var(--background-modifier-accent);
object-fit: cover; object-fit: cover;
transition: all 0.15s ease; transition: all 0.2s ease;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
} }
.contributorCard:hover .contributorAvatar { .contributorCard:hover .contributorAvatar {
border-color: var(--brand-experiment); border-color: var(--brand-experiment);
transform: scale(1.05); transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
} }
.contributorName { .contributorName {
font-weight: 600; font-weight: 600;
font-size: 16px; font-size: 13px;
margin-bottom: 8px; margin-bottom: 4px;
color: var(--header-primary); color: var(--header-primary);
word-break: break-word; word-break: break-word;
overflow-wrap: break-word; overflow-wrap: break-word;
text-align: center; text-align: center;
transition: color 0.15s ease; transition: color 0.2s ease;
letter-spacing: -0.2px;
} }
.contributorCard:hover .contributorName { .contributorCard:hover .contributorName {
color: var(--interactive-hover); color: var(--brand-experiment);
} }
.contributorInfo { .contributorInfo {
font-size: 13px; font-size: 11px;
color: var(--text-muted); color: var(--text-muted);
margin: 0; margin: 0;
text-align: center; text-align: center;
font-weight: 400; font-weight: 400;
line-height: 1.4; line-height: 1.3;
}
.contributorBadge {
display: inline-block;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
background: var(--background-tertiary);
border: 1px solid var(--background-modifier-accent);
border-radius: 20px;
padding: 3px 12px;
margin-bottom: 16px;
letter-spacing: 0.2px;
}
.techSection {
margin-top: 24px;
padding: 20px;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-accent);
border-radius: 12px;
}
.techList {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.techTag {
display: inline-block;
font-size: 12px;
font-weight: 500;
color: var(--text-normal);
background: var(--background-primary);
border: 1px solid var(--background-modifier-accent);
border-radius: 6px;
padding: 4px 10px;
transition: all 0.15s ease;
}
.techTag:hover {
border-color: var(--brand-experiment);
color: var(--brand-experiment);
transform: translateY(-1px);
}
.aboutFooter {
margin-top: 24px;
text-align: center;
padding: 8px 0 4px;
font-size: 12px;
color: var(--text-muted);
opacity: 0.7;
letter-spacing: 0.1px;
} }
.loadingState, .loadingState,
@ -348,6 +433,25 @@
border-radius: 8px; border-radius: 8px;
margin: 16px; margin: 16px;
} }
.modal {
max-height: 500px; .loadingState::after {
content: "";
display: block;
width: 24px;
height: 24px;
margin: 16px auto 0;
border: 3px solid var(--background-modifier-accent);
border-top-color: var(--brand-experiment);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.modal {
max-height: 640px;
} }