= ({onOpen}) => (
+
+
+
+
+
+
Settings
+
+
+
+
Preferences
+
+
+
+
Reference Language
+
+
+
+
+
+
+
+
+
+
Universal Language
+
+
+
+
+
+
+
+
+
Selection
+
+
+
+
+);
+
+export default SettingsPanel;
diff --git a/orbitmines.com/src/@ether/UI/pages/library/Socials.tsx b/orbitmines.com/src/@ether/UI/pages/library/Socials.tsx
new file mode 100644
index 00000000..d7e263ae
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/library/Socials.tsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import type {SocialLink} from './data';
+
+const Socials: React.FC<{links: SocialLink[]}> = ({links}) => (
+
+);
+
+export default Socials;
diff --git a/orbitmines.com/src/@ether/UI/pages/library/data.ts b/orbitmines.com/src/@ether/UI/pages/library/data.ts
new file mode 100644
index 00000000..bacf364c
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/library/data.ts
@@ -0,0 +1,112 @@
+import type {Entry} from './types';
+
+// PROJECTS dataset — currently a static demo dataset matching the ray
+// prototype. The deeper architectural goal is to drive this from the
+// EtherAPI; for now we ship the same demo data so the page looks alive.
+
+export const PROJECTS: Entry[] = [
+ {
+ name: 'Ray',
+ language: {name: 'Ray', icon: 'circle'},
+ versions: [
+ {
+ tag: 'v1.0.0',
+ children: [
+ {
+ type: 'file',
+ name: 'UUID.ray',
+ icon: 'document',
+ snippet: 'UUID: UUID("asadasdasdasdasdddaaaaaaaaaaaaa"',
+ },
+ {
+ type: 'libraries',
+ count: 10000,
+ entries: [
+ {name: 'Library', snippet: 'UUID: UUID("asadasdasdasdasdddaaaaaaaaaaaaa"'},
+ {
+ name: 'Library',
+ reference: {name: 'Language'},
+ snippet: 'UUID: UUID("asadasdasdasdasdddaaaaaaaaaaaaa"',
+ },
+ ],
+ },
+ ],
+ },
+ {tag: 'v1.0.0', language: 'Set Theory'},
+ {tag: 'v0.9.0', language: 'Set Theory'},
+ {tag: 'v0.9.0'},
+ ],
+ },
+ {
+ name: 'Set Theory',
+ language: {name: 'Set Theory', icon: 'circle'},
+ versions: [
+ {
+ tag: 'v2.0.0',
+ language: 'Ray',
+ children: [
+ {
+ type: 'library',
+ name: 'set.mm',
+ snippet: 'UUID: UUID("asadasdasdasdasdddaaaaaaaaaaaaa"',
+ },
+ ],
+ },
+ {tag: 'v2.0.0'},
+ {
+ tag: 'v1.0.0',
+ children: [
+ {
+ type: 'library',
+ name: 'set.mm',
+ versions: [
+ {tag: 'v1.0.0', language: 'Ray'},
+ {tag: 'v1.0.0'},
+ ],
+ snippet: 'UUID: UUID("asadasdasdasdasdddaaaaaaaaaaaaa"',
+ },
+ ],
+ },
+ {tag: 'v1.0.0', language: 'Ray'},
+ ],
+ },
+ {
+ name: 'UUID',
+ language: {name: 'UUID', icon: 'circle'},
+ versions: [
+ {
+ tag: 'v1.0.0',
+ language: 'Ray',
+ children: [
+ {
+ type: 'file',
+ name: 'UUID.ray',
+ library: 'Ray',
+ versions: [
+ {tag: 'v1.0.0', language: 'Ray'},
+ {tag: 'v1.0.0'},
+ ],
+ snippet: 'UUID: UUID("asadasdasdasdasdddaaaaaaaaaaaaa"',
+ },
+ ],
+ },
+ {tag: 'v1.0.0'},
+ {tag: 'v0.1.0'},
+ ],
+ },
+];
+
+export interface SocialLink {
+ name: string;
+ url: string;
+ label: string;
+}
+
+export const SOCIALS: SocialLink[] = [
+ {name: 'Discord', url: 'https://discord.orbitmines.com', label: 'discord.orbitmines.com'},
+ {
+ name: 'GitHub',
+ url: 'https://github.com/orbitmines/ray/tree/main/Ether/library',
+ label: 'orbitmines',
+ },
+];
diff --git a/orbitmines.com/src/@ether/UI/pages/library/icons.tsx b/orbitmines.com/src/@ether/UI/pages/library/icons.tsx
new file mode 100644
index 00000000..42447520
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/library/icons.tsx
@@ -0,0 +1,150 @@
+import React from 'react';
+
+// Library-specific glyphs. Tiny inline SVGs — kept here rather than in the
+// shared icons/ folder because they're only used by the Library page.
+
+type P = {className?: string};
+
+export const Circle: React.FC = ({className, size = 14}) => (
+
+);
+
+export const Document: React.FC
= ({className}) => (
+
+);
+
+export const Repo: React.FC
= ({className}) => (
+
+);
+
+export const Settings: React.FC
= ({className}) => (
+
+);
+
+export const Branch: React.FC
= ({className}) => (
+
+);
+
+export const CaretDown: React.FC
= ({className}) => (
+
+);
+
+export const Add: React.FC
= ({className}) => (
+
+);
+
+export const Edit: React.FC
= ({className}) => (
+
+);
+
+export const ArrowLeft: React.FC
= ({className}) => (
+
+);
+
+export function iconForName(name?: string): React.ReactNode {
+ switch (name) {
+ case 'document':
+ return ;
+ case 'git-repo':
+ return ;
+ case 'settings':
+ return ;
+ case 'circle':
+ default:
+ return ;
+ }
+}
+
+export function iconSmallForName(_name?: string): React.ReactNode {
+ return ;
+}
diff --git a/orbitmines.com/src/@ether/UI/pages/library/types.ts b/orbitmines.com/src/@ether/UI/pages/library/types.ts
new file mode 100644
index 00000000..2fdcebd5
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/library/types.ts
@@ -0,0 +1,68 @@
+export type LanguageRef = string | {name: string; icon?: string};
+
+export interface Entry {
+ type?: 'file' | 'library';
+ name: string;
+ icon?: string;
+ language?: LanguageRef;
+ library?: string;
+ reference?: {name: string; icon?: string};
+ versions?: Version[];
+ snippet?: string;
+}
+
+export interface Version {
+ tag: string;
+ language?: LanguageRef;
+ children?: VersionChild[];
+}
+
+export type VersionChild = Entry | LibrariesGroup;
+
+export interface LibrariesGroup {
+ type: 'libraries';
+ count?: number;
+ entries: LibraryEntryData[];
+}
+
+export interface LibraryEntryData {
+ name: string;
+ icon?: string;
+ snippet?: string;
+ reference?: {name: string; icon?: string};
+}
+
+export interface TagGroup {
+ tag: string;
+ langs: {name: string; icon: string}[];
+}
+
+export function resolveLanguageRef(
+ ref?: LanguageRef,
+): {name?: string; icon?: string} {
+ if (!ref) return {};
+ if (typeof ref === 'string') return {name: ref};
+ return ref;
+}
+
+export function resolveLanguage(
+ ref?: LanguageRef,
+ defaultRef?: LanguageRef,
+): {name: string; icon: string} {
+ const resolved = resolveLanguageRef(ref);
+ const defaults = resolveLanguageRef(defaultRef);
+ return {
+ name: resolved.name || defaults.name || '',
+ icon: resolved.icon || defaults.icon || 'circle',
+ };
+}
+
+export function entryKey(entry: Entry): string {
+ if (entry.library) return `${entry.library}//${entry.name}`;
+ if (entry.reference) return `${entry.name}->${entry.reference.name}`;
+ return entry.name;
+}
+
+export function isLibrariesGroup(child: VersionChild): child is LibrariesGroup {
+ return (child as LibrariesGroup).type === 'libraries';
+}
diff --git a/orbitmines.com/src/@ether/UI/pages/pullrequests/CategoryView.tsx b/orbitmines.com/src/@ether/UI/pages/pullrequests/CategoryView.tsx
new file mode 100644
index 00000000..dbef0317
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/pullrequests/CategoryView.tsx
@@ -0,0 +1,107 @@
+import React, {useEffect, useMemo, useState} from 'react';
+import {useNavigate} from 'react-router-dom';
+import classnames from 'classnames';
+import type {PRParams} from '../../router/types';
+import {getAPI} from '../../data';
+import type {InlinePR} from '../../data';
+import {ArrowLeftIcon, BranchIcon, MergeIcon} from '../../icons';
+import Header from './Header';
+import {buildPullsUrl, displayPath} from './urls';
+import {timeAgo} from './timeAgo';
+
+// `/-/@/pulls` or `/-/~/pulls` — PRs across all sub-paths under that
+// category prefix.
+const CategoryView: React.FC<{params: PRParams}> = ({params}) => {
+ const navigate = useNavigate();
+ const [prs, setPrs] = useState([]);
+ const [filter, setFilter] = useState<'open' | 'closed'>(() =>
+ new URLSearchParams(window.location.search).get('filter') === 'closed' ? 'closed' : 'open',
+ );
+
+ useEffect(() => {
+ let cancelled = false;
+ getAPI()
+ .getCategoryPullRequests(params.repoPath, params.category!)
+ .then((p) => !cancelled && setPrs(p));
+ return () => {
+ cancelled = true;
+ };
+ }, [params.repoPath, params.category]);
+
+ const {open, closed} = useMemo(() => {
+ const o = prs.filter(({pr}) => pr.status === 'open');
+ const c = prs.filter(({pr}) => pr.status !== 'open');
+ return {open: o, closed: c};
+ }, [prs]);
+
+ const visible = filter === 'open' ? open : closed;
+ const mainList = {...params, category: null as '@' | '~' | null};
+
+ return (
+
+ );
+};
+
+export default CategoryView;
diff --git a/orbitmines.com/src/@ether/UI/pages/pullrequests/CommitDiff.tsx b/orbitmines.com/src/@ether/UI/pages/pullrequests/CommitDiff.tsx
new file mode 100644
index 00000000..91822083
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/pullrequests/CommitDiff.tsx
@@ -0,0 +1,123 @@
+import React, {useEffect, useState} from 'react';
+import {useNavigate} from 'react-router-dom';
+import type {PRParams} from '../../router/types';
+import {getAPI} from '../../data';
+import type {FileDiff, PullRequest, PRCommit} from '../../data';
+import Header from './Header';
+import {ArrowLeftIcon, FileDiffIcon} from '../../icons';
+import {buildPullsUrl} from './urls';
+import {DiffView} from '../../util';
+
+// `/-/pulls//commits/` — file-by-file diff for one commit.
+const CommitDiffView: React.FC<{params: PRParams}> = ({params}) => {
+ const navigate = useNavigate();
+ const [pr, setPr] = useState(null);
+ const [view, setView] = useState<'unified' | 'side-by-side'>('unified');
+
+ useEffect(() => {
+ let cancelled = false;
+ if (params.prId === null) return;
+ getAPI()
+ .getPullRequest(params.repoPath, params.prId)
+ .then((p) => !cancelled && setPr(p));
+ return () => {
+ cancelled = true;
+ };
+ }, [params.repoPath, params.prId]);
+
+ const commit: PRCommit | null = pr?.commits.find((c) => c.id === params.commitId) ?? null;
+
+ return (
+
+ );
+};
+
+const FileDiffSection: React.FC<{diff: FileDiff; view: 'unified' | 'side-by-side'}> = ({
+ diff,
+ view,
+}) => (
+
+
+
+ {diff.path}
+
+ {diff.type}
+
+
+
+
+);
+
+export default CommitDiffView;
diff --git a/orbitmines.com/src/@ether/UI/pages/pullrequests/DetailView.tsx b/orbitmines.com/src/@ether/UI/pages/pullrequests/DetailView.tsx
new file mode 100644
index 00000000..aa38d303
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/pullrequests/DetailView.tsx
@@ -0,0 +1,138 @@
+import React, {useEffect, useState} from 'react';
+import {useNavigate} from 'react-router-dom';
+import type {PRParams} from '../../router/types';
+import {getAPI} from '../../data';
+import type {PullRequest} from '../../data';
+import {ArrowLeftIcon, BranchIcon, MergeIcon} from '../../icons';
+import Header from './Header';
+import {buildPullsUrl} from './urls';
+import {timeAgo} from './timeAgo';
+import {MarkdownView} from '../../util';
+
+const DetailView: React.FC<{params: PRParams}> = ({params}) => {
+ const navigate = useNavigate();
+ const [pr, setPr] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ let cancelled = false;
+ if (params.prId === null) {
+ setLoading(false);
+ return;
+ }
+ setLoading(true);
+ getAPI()
+ .getPullRequest(params.repoPath, params.prId)
+ .then((p) => {
+ if (cancelled) return;
+ setPr(p);
+ setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [params.repoPath, params.prId]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (!pr) {
+ return (
+
+ );
+ }
+
+ const StatusIcon = pr.status === 'merged' ? MergeIcon : BranchIcon;
+
+ return (
+
+
+
+
+
+ {pr.title}
+ {pr.status}
+
+
+ #{pr.id} by @{pr.author} · {timeAgo(pr.createdAt)} · {pr.sourceLabel} → {pr.targetLabel}
+
+
+
+
+
{
+ e.preventDefault();
+ navigate(buildPullsUrl(params));
+ }}
+ >
+ Back to pull requests
+
+
+
+
Description
+
+ {pr.description ? : No description.}
+
+
+
+
+
Commits ({pr.commits.length})
+
+ {pr.commits.length === 0 ? (
+
No commits yet.
+ ) : (
+ pr.commits.map((c) => (
+
+ {c.id.slice(0, 7)}{' '}
+ {c.message}{' '}
+
+ · @{c.author} · {timeAgo(c.createdAt)}
+
+
+ ))
+ )}
+
+
+
+
+
Activity ({pr.activity.length})
+
+ {pr.activity.length === 0 ? (
+
No activity yet — comment thread will land with the Chat port.
+ ) : (
+ pr.activity.map((a, i) => (
+
+ · {a.type} {('author' in a ? `by @${a.author}` : '')} ·{' '}
+ {timeAgo(a.createdAt)}
+
+ ))
+ )}
+
+
+
+
+ );
+};
+
+export default DetailView;
diff --git a/orbitmines.com/src/@ether/UI/pages/pullrequests/Header.tsx b/orbitmines.com/src/@ether/UI/pages/pullrequests/Header.tsx
new file mode 100644
index 00000000..6950a022
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/pullrequests/Header.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import {useNavigate} from 'react-router-dom';
+import type {PRParams} from '../../router/types';
+import {displaySegment} from './urls';
+
+// Breadcrumb chain at the top of every PR view:
+// @user / repo / sub / path
+// Each segment links to that level's PR list.
+const Header: React.FC<{params: PRParams}> = ({params}) => {
+ const navigate = useNavigate();
+ const base = params.base || '';
+
+ return (
+
+ );
+};
+
+export default Header;
diff --git a/orbitmines.com/src/@ether/UI/pages/pullrequests/ListView.tsx b/orbitmines.com/src/@ether/UI/pages/pullrequests/ListView.tsx
new file mode 100644
index 00000000..1d37728f
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/pullrequests/ListView.tsx
@@ -0,0 +1,186 @@
+import React, {useEffect, useMemo, useState} from 'react';
+import {useNavigate} from 'react-router-dom';
+import classnames from 'classnames';
+import type {PRParams} from '../../router/types';
+import {getAPI} from '../../data';
+import type {CategoryPRSummary, InlinePR} from '../../data';
+import {
+ ArrowLeftIcon,
+ BranchIcon,
+ MergeIcon,
+ PullRequestIcon,
+} from '../../icons';
+import Header from './Header';
+import {buildPullsUrl, buildRepoUrl, displayPath} from './urls';
+import {timeAgo} from './timeAgo';
+
+const ListView: React.FC<{params: PRParams}> = ({params}) => {
+ const navigate = useNavigate();
+ const [inline, setInline] = useState([]);
+ const [playerSummary, setPlayerSummary] = useState(null);
+ const [worldSummary, setWorldSummary] = useState(null);
+ const [filter, setFilter] = useState<'open' | 'closed'>(() => {
+ return new URLSearchParams(window.location.search).get('filter') === 'closed' ? 'closed' : 'open';
+ });
+
+ useEffect(() => {
+ let cancelled = false;
+ const api = getAPI();
+ Promise.all([
+ api.getInlinePullRequests(params.repoPath),
+ api.getCategoryPRSummary(params.repoPath, '@'),
+ api.getCategoryPRSummary(params.repoPath, '~'),
+ ]).then(([prs, p, w]) => {
+ if (cancelled) return;
+ setInline(prs);
+ setPlayerSummary(p);
+ setWorldSummary(w);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [params.repoPath]);
+
+ const {open, closed} = useMemo(() => {
+ const o = inline
+ .filter(({pr}) => pr.status === 'open')
+ .sort((a, b) => new Date(b.pr.updatedAt).getTime() - new Date(a.pr.updatedAt).getTime());
+ const c = inline
+ .filter(({pr}) => pr.status !== 'open')
+ .sort((a, b) => new Date(b.pr.updatedAt).getTime() - new Date(a.pr.updatedAt).getTime());
+ return {open: o, closed: c};
+ }, [inline]);
+
+ const visiblePRs = filter === 'open' ? open : closed;
+ const isEmpty = inline.length === 0 && !playerSummary && !worldSummary;
+
+ return (
+
+ );
+};
+
+export default ListView;
+
+const PRRow: React.FC<{pr: InlinePR['pr']; relPath: string; params: PRParams}> = ({pr, relPath, params}) => {
+ const navigate = useNavigate();
+ const href = buildPullsUrl(params, String(pr.id));
+ const StatusIcon = pr.status === 'merged' ? MergeIcon : BranchIcon;
+ return (
+ {
+ e.preventDefault();
+ navigate(href);
+ }}
+ >
+
+
+
+
+
+ {relPath && {displayPath(relPath)}}
+ {pr.title}
+ {pr.status}
+
+
+ #{pr.id} opened by @{pr.author} · updated {timeAgo(pr.updatedAt)}
+
+
+
+ );
+};
+
+const CategoryRow: React.FC<{
+ summary: CategoryPRSummary;
+ prefix: '@' | '~';
+ params: PRParams;
+ filter: 'open' | 'closed';
+}> = ({summary, prefix, params, filter}) => {
+ const navigate = useNavigate();
+ const count = filter === 'open' ? summary.openCount : summary.closedCount;
+ if (count === 0) return null;
+ const label = prefix === '@' ? '@{: String}' : '#{: String}';
+ const kindLabel =
+ prefix === '@'
+ ? summary.itemCount === 1 ? 'Player' : 'Players'
+ : summary.itemCount === 1 ? 'World' : 'Worlds';
+ const verb = filter === 'open' ? 'open across' : 'closed in';
+ const base = params.base || '';
+ const pathPart = params.path.length > 0 ? '/' + params.path.join('/') : '';
+ const href = `${base}${pathPart}/-/${prefix}/pulls${filter === 'closed' ? '?filter=closed' : ''}`;
+
+ return (
+ {
+ e.preventDefault();
+ navigate(href);
+ }}
+ >
+
+
+
+
+
{label}
+
+ {count} {verb} {summary.itemCount} {kindLabel}
+
+
+
+ );
+};
diff --git a/orbitmines.com/src/@ether/UI/pages/pullrequests/NewPRForm.tsx b/orbitmines.com/src/@ether/UI/pages/pullrequests/NewPRForm.tsx
new file mode 100644
index 00000000..a3d00b66
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/pullrequests/NewPRForm.tsx
@@ -0,0 +1,109 @@
+import React, {useState} from 'react';
+import {useNavigate} from 'react-router-dom';
+import type {PRParams} from '../../router/types';
+import {getAPI} from '../../data';
+import Header from './Header';
+import {ArrowLeftIcon} from '../../icons';
+import {buildPullsUrl} from './urls';
+import {getName} from '../../storage';
+
+// `/-/pulls/new` — submit goes through the API. With the dummy backend
+// it accepts the call but doesn't persist; that lands when there's a real
+// server.
+const NewPRForm: React.FC<{params: PRParams}> = ({params}) => {
+ const navigate = useNavigate();
+ const [title, setTitle] = useState('');
+ const [description, setDescription] = useState('');
+ const [sourceLabel, setSourceLabel] = useState('');
+ const [targetLabel, setTargetLabel] = useState('main');
+ const [submitting, setSubmitting] = useState(false);
+
+ const onSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!title.trim()) return;
+ setSubmitting(true);
+ await getAPI().createPullRequest(
+ params.repoPath,
+ title.trim(),
+ description.trim(),
+ sourceLabel.trim() || `${getName()}/branch`,
+ targetLabel.trim() || 'main',
+ getName(),
+ );
+ navigate(buildPullsUrl(params));
+ };
+
+ return (
+
+ );
+};
+
+export default NewPRForm;
diff --git a/orbitmines.com/src/@ether/UI/pages/pullrequests/PullRequests.scss b/orbitmines.com/src/@ether/UI/pages/pullrequests/PullRequests.scss
new file mode 100644
index 00000000..1c5fa052
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/pullrequests/PullRequests.scss
@@ -0,0 +1,297 @@
+$phosphor: #ffffff;
+
+.pr-page {
+ max-width: 1120px;
+ margin: 0 auto;
+ padding: 32px 24px;
+ font-family: 'Courier New', Courier, monospace;
+ color: $phosphor;
+ box-sizing: border-box;
+
+ .repo-header {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ font-size: 22px;
+ margin-bottom: 8px;
+ text-shadow: 0 0 4px rgba(255, 255, 255, 0.5), 0 0 11px rgba(255, 255, 255, 0.22);
+
+ .user { color: rgba(255, 255, 255, 0.55); }
+ .sep { color: rgba(255, 255, 255, 0.25); }
+ .repo-name { color: $phosphor; font-weight: bold; }
+
+ a {
+ color: inherit;
+ text-decoration: none;
+ cursor: pointer;
+ &:hover { text-decoration: underline; }
+ }
+ }
+}
+
+.pr-back-link {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ color: rgba(255, 255, 255, 0.5);
+ text-decoration: none;
+ font-size: 13px;
+ margin: 8px 0 18px;
+ cursor: pointer;
+
+ &:hover { color: $phosphor; }
+ svg { width: 16px; height: 16px; }
+}
+
+// ---- List view ----
+
+.pr-list-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+}
+
+.pr-new-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ background: none;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 4px;
+ color: rgba(255, 255, 255, 0.65);
+ font-family: inherit;
+ font-size: 12px;
+ padding: 6px 12px;
+ text-decoration: none;
+ cursor: pointer;
+
+ &:hover { color: $phosphor; border-color: rgba(255, 255, 255, 0.3); }
+ svg { width: 14px; height: 14px; }
+}
+
+.pr-filter-tabs {
+ display: flex;
+ gap: 12px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ margin-bottom: 12px;
+}
+
+.pr-filter-tab {
+ background: none;
+ border: none;
+ color: rgba(255, 255, 255, 0.45);
+ cursor: pointer;
+ font-family: inherit;
+ font-size: 13px;
+ padding: 8px 4px;
+ border-bottom: 2px solid transparent;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+
+ &:hover { color: rgba(255, 255, 255, 0.7); }
+ &--active { color: $phosphor; border-bottom-color: $phosphor; }
+}
+
+.pr-filter-count {
+ font-size: 11px;
+ padding: 1px 6px;
+ background: rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ color: rgba(255, 255, 255, 0.6);
+}
+
+.pr-list { display: flex; flex-direction: column; }
+
+.pr-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ padding: 10px 12px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+ text-decoration: none;
+ color: inherit;
+ cursor: pointer;
+
+ &:hover { background: rgba(255, 255, 255, 0.04); }
+
+ &__icon {
+ flex: 0 0 18px;
+ margin-top: 2px;
+ width: 18px;
+ height: 18px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &--open { color: #56d364; }
+ &--closed { color: #f85149; }
+ &--merged { color: #b87cf0; }
+ svg { width: 18px; height: 18px; }
+ }
+
+ &__body { flex: 1; min-width: 0; }
+ &__title {
+ color: $phosphor;
+ font-size: 14px;
+ margin-bottom: 2px;
+ }
+ &__meta {
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.45);
+ }
+
+ &__path {
+ color: rgba(255, 255, 255, 0.4);
+ margin-right: 6px;
+ }
+}
+
+.pr-status-badge {
+ display: inline-block;
+ font-size: 10px;
+ padding: 1px 6px;
+ border-radius: 8px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-left: 4px;
+
+ &.open { background: rgba(86, 211, 100, 0.15); color: #56d364; }
+ &.closed { background: rgba(248, 81, 73, 0.15); color: #f85149; }
+ &.merged { background: rgba(184, 124, 240, 0.15); color: #b87cf0; }
+}
+
+.pr-empty {
+ padding: 32px;
+ text-align: center;
+ color: rgba(255, 255, 255, 0.3);
+ font-size: 13px;
+}
+
+// ---- Category rows ----
+
+.pr-category {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 12px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+ text-decoration: none;
+ color: inherit;
+ cursor: pointer;
+
+ &:hover { background: rgba(255, 255, 255, 0.04); }
+
+ &__icon { width: 18px; height: 18px; color: rgba(255, 255, 255, 0.55); }
+ &__body { flex: 1; }
+ &__name { color: $phosphor; font-size: 14px; }
+ &__meta { font-size: 11px; color: rgba(255, 255, 255, 0.45); }
+}
+
+// ---- Detail view ----
+
+.pr-detail {
+ max-width: 1120px;
+ margin: 0 auto;
+
+ &__sticky {
+ position: sticky;
+ top: 0;
+ background: #0a0a0a;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+ padding: 12px 24px;
+ z-index: 10;
+ }
+
+ &__title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ color: $phosphor;
+ font-size: 18px;
+ }
+
+ &__meta {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.45);
+ margin-top: 4px;
+ }
+
+ &__body {
+ padding: 16px 24px;
+ }
+
+ &__section {
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 6px;
+ margin-bottom: 14px;
+ overflow: hidden;
+
+ &-title {
+ padding: 8px 12px;
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ color: rgba(255, 255, 255, 0.4);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+ }
+
+ &-body { padding: 10px 12px; font-size: 13px; }
+ }
+}
+
+// ---- New PR form ----
+
+.pr-form {
+ max-width: 700px;
+
+ label {
+ display: block;
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.5);
+ margin-bottom: 4px;
+ }
+
+ input[type='text'],
+ textarea {
+ width: 100%;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 4px;
+ padding: 8px 10px;
+ color: $phosphor;
+ font-family: inherit;
+ font-size: 13px;
+ outline: none;
+ margin-bottom: 14px;
+
+ &:focus { border-color: rgba(255, 255, 255, 0.3); }
+ }
+
+ textarea { min-height: 140px; resize: vertical; }
+}
+
+.pr-form-actions {
+ display: flex;
+ gap: 8px;
+}
+
+.pr-form-btn {
+ padding: 8px 18px;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 4px;
+ color: $phosphor;
+ font-family: inherit;
+ font-size: 13px;
+ cursor: pointer;
+
+ &:hover { background: rgba(255, 255, 255, 0.12); }
+ &--primary {
+ background: rgba(86, 211, 100, 0.15);
+ border-color: rgba(86, 211, 100, 0.4);
+ color: #56d364;
+ &:hover { background: rgba(86, 211, 100, 0.22); }
+ }
+}
diff --git a/orbitmines.com/src/@ether/UI/pages/pullrequests/PullRequests.tsx b/orbitmines.com/src/@ether/UI/pages/pullrequests/PullRequests.tsx
new file mode 100644
index 00000000..2ba7b109
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/pullrequests/PullRequests.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import type {PRParams} from '../../router/types';
+import ListView from './ListView';
+import DetailView from './DetailView';
+import NewPRForm from './NewPRForm';
+import CategoryView from './CategoryView';
+import CommitDiffView from './CommitDiff';
+import './PullRequests.scss';
+
+// Dispatch to one of five sub-views based on `prAction`.
+const PullRequests: React.FC<{params: PRParams}> = ({params}) => {
+ if (params.prAction === 'list') return ;
+ if (params.prAction === 'new') return ;
+ if (params.prAction === 'players' || params.prAction === 'worlds') return ;
+ if (params.prAction === 'detail' && params.commitId) return ;
+ if (params.prAction === 'detail') return ;
+ return ;
+};
+
+export default PullRequests;
diff --git a/orbitmines.com/src/@ether/UI/pages/pullrequests/timeAgo.ts b/orbitmines.com/src/@ether/UI/pages/pullrequests/timeAgo.ts
new file mode 100644
index 00000000..898e7881
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/pullrequests/timeAgo.ts
@@ -0,0 +1,17 @@
+// Relative-time formatter for PR rows. The ray prototype's `formatTime`
+// lives in ChatCommon — this is a focused inline copy until Chat lands.
+
+export function timeAgo(iso: string): string {
+ const t = new Date(iso).getTime();
+ const diff = Date.now() - t;
+ const min = 60 * 1000;
+ const hr = 60 * min;
+ const day = 24 * hr;
+ const wk = 7 * day;
+
+ if (diff < min) return 'just now';
+ if (diff < hr) return Math.floor(diff / min) + 'm ago';
+ if (diff < day) return Math.floor(diff / hr) + 'h ago';
+ if (diff < wk) return Math.floor(diff / day) + 'd ago';
+ return new Date(t).toLocaleDateString();
+}
diff --git a/orbitmines.com/src/@ether/UI/pages/pullrequests/urls.ts b/orbitmines.com/src/@ether/UI/pages/pullrequests/urls.ts
new file mode 100644
index 00000000..6c007653
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/pullrequests/urls.ts
@@ -0,0 +1,26 @@
+import type {PRParams} from '../../router/types';
+
+export function buildPullsUrl(params: PRParams, suffix?: string): string {
+ const base = params.base || '';
+ const pathPart = params.path.length > 0 ? '/' + params.path.join('/') : '';
+ const categoryPart = params.category ? '/' + params.category : '';
+ return `${base}${pathPart}/-${categoryPart}/pulls${suffix ? '/' + suffix : ''}`;
+}
+
+export function buildRepoUrl(params: PRParams): string {
+ const base = params.base || '';
+ const pathPart = params.path.length > 0 ? '/' + params.path.join('/') : '';
+ return `${base}${pathPart}` || '/';
+}
+
+export function buildBranchUrl(label: string): string | null {
+ return label.includes('/') ? `/@${label}` : null;
+}
+
+export function displaySegment(seg: string): string {
+ return seg;
+}
+
+export function displayPath(relPath: string): string {
+ return relPath;
+}
diff --git a/orbitmines.com/src/@ether/UI/pages/repository/AccessBadge.tsx b/orbitmines.com/src/@ether/UI/pages/repository/AccessBadge.tsx
new file mode 100644
index 00000000..c2b87b4f
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/repository/AccessBadge.tsx
@@ -0,0 +1,209 @@
+import React, {useCallback, useEffect, useRef, useState} from 'react';
+import {useNavigate} from 'react-router-dom';
+import {AccessIcon} from '../../icons';
+
+// Access badge — small icon with hover-tooltip + click-to-edit popup.
+// Tooltip is rendered into document.body via a portal-like absolute element
+// so it escapes any stacking context, matching the ray prototype.
+
+export type AccessLevel = 'public' | 'local' | 'private' | 'npc' | 'player' | 'everyone';
+
+interface TooltipData {
+ label: string;
+ color: string;
+ desc: string;
+}
+
+const ACCESS_DATA: Record = {
+ public: {label: '@public', color: 'rgba(255,255,255,0.5)', desc: 'Visible to everyone'},
+ local: {label: '@local', color: '#f87171', desc: 'Only on your local machine'},
+ private: {
+ label: '@private',
+ color: '#fb923c',
+ desc: 'Any machine hosting your character, includes @ether',
+ },
+ npc: {label: '@npc', color: 'rgba(255,255,255,0.5)', desc: 'Only visible to NPCs'},
+ player: {label: '@player', color: 'rgba(255,255,255,0.5)', desc: 'Only visible to players'},
+ everyone: {label: '', color: '#fb923c', desc: ''},
+};
+
+export function resolveAccessLevel(value: string): AccessLevel {
+ const v = value.trim().toLowerCase();
+ if (v === '@local' || v === 'local') return 'local';
+ if (v === '@public' || v === 'public') return 'public';
+ if (v === '@private' || v === 'private') return 'private';
+ if (v === '@npc' || v === 'npc') return 'npc';
+ if (v === '@player' || v === 'player') return 'player';
+ if (v.endsWith('.@everyone') || v === '@everyone') return 'everyone';
+ return 'everyone';
+}
+
+export function accessValueForLevel(level: AccessLevel, groupContext: string): string {
+ switch (level) {
+ case 'local':
+ return '@local';
+ case 'private':
+ return '@private';
+ case 'npc':
+ return '@npc';
+ case 'player':
+ return '@player';
+ case 'everyone':
+ return (groupContext || '@public') + '.@everyone';
+ case 'public':
+ default:
+ return '@public';
+ }
+}
+
+function colorForLevel(level: AccessLevel): string {
+ return ACCESS_DATA[level]?.color || ACCESS_DATA.public.color;
+}
+
+export interface AccessBadgeProps {
+ level?: AccessLevel;
+ /** Custom access value (overrides label/desc) — e.g. "@ether.@everyone". */
+ value?: string;
+ /** Surrounding group used to resolve "@everyone" labels. */
+ groupContext?: string;
+ size?: number;
+}
+
+const AccessBadge: React.FC = ({
+ level = 'public',
+ value,
+ groupContext = '@public',
+ size = 12,
+}) => {
+ const navigate = useNavigate();
+ const badgeRef = useRef(null);
+ const [editing, setEditing] = useState(false);
+ const [hovered, setHovered] = useState(false);
+ const [currentLevel, setCurrentLevel] = useState(level);
+ const [currentValue, setCurrentValue] = useState(value);
+
+ useEffect(() => setCurrentLevel(level), [level]);
+ useEffect(() => setCurrentValue(value), [value]);
+
+ const showTooltip = hovered || editing;
+
+ const data = ACCESS_DATA[currentLevel] || ACCESS_DATA.public;
+ let label: string;
+ let desc: string;
+ if (currentValue) {
+ label = currentValue;
+ desc = 'Custom access group';
+ } else if (currentLevel === 'everyone') {
+ const ctx = groupContext || '@public';
+ label = ctx + '.@everyone';
+ desc = 'Everyone in ' + ctx;
+ } else {
+ label = data.label;
+ desc = data.desc;
+ }
+
+ const onTooltipClick = useCallback(
+ (e: React.MouseEvent) => {
+ const link = (e.target as HTMLElement).closest('[data-access-link]') as HTMLAnchorElement | null;
+ if (link) {
+ e.preventDefault();
+ e.stopPropagation();
+ setHovered(false);
+ setEditing(false);
+ const href = link.getAttribute('href');
+ if (href) navigate(href);
+ }
+ },
+ [navigate],
+ );
+
+ const onInputBlur = useCallback(() => {
+ setEditing(false);
+ }, []);
+
+ const onInputKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ const raw = (e.target as HTMLInputElement).value.trim();
+ if (raw) {
+ const newLevel = resolveAccessLevel(raw);
+ const standard = accessValueForLevel(newLevel, groupContext);
+ setCurrentLevel(newLevel);
+ setCurrentValue(raw.toLowerCase() === standard.toLowerCase() ? undefined : raw);
+ }
+ setEditing(false);
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ setEditing(false);
+ }
+ },
+ [groupContext],
+ );
+
+ // Position tooltip relative to badge
+ const [pos, setPos] = useState<{top: number; left: number} | null>(null);
+ useEffect(() => {
+ if (!showTooltip || !badgeRef.current) {
+ setPos(null);
+ return;
+ }
+ const rect = badgeRef.current.getBoundingClientRect();
+ // Initial position; we re-measure after first paint to align right-edge
+ let top = rect.top - 30;
+ let left = rect.right - 4;
+ if (top < 8) top = rect.bottom + 6;
+ setPos({top, left});
+ }, [showTooltip]);
+
+ return (
+ <>
+ !editing && setHovered(true)}
+ onMouseLeave={() => !editing && setHovered(false)}
+ onClick={(e) => {
+ e.stopPropagation();
+ setHovered(false);
+ setEditing(true);
+ }}
+ >
+
+
+ {showTooltip && pos && (
+
+ {editing ? (
+
+ ) : (
+ <>
+
+ {label}
+ {' '}
+
+ >
+ )}
+
+ )}
+ >
+ );
+};
+
+export default AccessBadge;
diff --git a/orbitmines.com/src/@ether/UI/pages/repository/ActionButtons.tsx b/orbitmines.com/src/@ether/UI/pages/repository/ActionButtons.tsx
new file mode 100644
index 00000000..c12303df
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/repository/ActionButtons.tsx
@@ -0,0 +1,196 @@
+import React, {useEffect, useState} from 'react';
+import {useNavigate} from 'react-router-dom';
+import {getAPI} from '../../data';
+import {
+ CloneIcon,
+ DownloadIcon,
+ FollowIcon,
+ FollowingIcon,
+ PullRequestIcon,
+ SettingsIcon,
+ StarFilledIcon,
+ StarOutlineIcon,
+ ChatIcon,
+} from './icons';
+import ClonePopup from './ClonePopup';
+import {
+ getCurrentPlayer,
+ getFollowerCount,
+ getStarCount,
+ isFollowing,
+ isStarred,
+ setFollowerCount,
+ setStarCount,
+ toggleFollow,
+ toggleStar,
+} from './storage';
+
+interface PrimaryButtonProps {
+ canonicalPath: string;
+ starPath: string;
+ followUser?: string;
+}
+
+// Star or follow button — mutually exclusive (follow takes precedence when present).
+export const PrimaryButton: React.FC = ({starPath, followUser}) => {
+ const [, force] = useState(0);
+ const refresh = () => force((n) => n + 1);
+ if (followUser) {
+ const followed = isFollowing(followUser);
+ const count = getFollowerCount(followUser);
+ return (
+
+ );
+ }
+ const starred = isStarred(starPath);
+ const count = getStarCount(starPath);
+ return (
+
+ );
+};
+
+interface CloneButtonProps {
+ canonicalPath: string;
+}
+
+export const CloneButton: React.FC = ({canonicalPath}) => {
+ const [open, setOpen] = useState(false);
+ return (
+ <>
+ setOpen(false)} />
+
+
setOpen(false)} />
+ >
+ );
+};
+
+interface ActionRowProps {
+ canonicalPath: string;
+ starPath: string;
+ followUser?: string;
+ /** Builder for the /-/pulls URL (called with the current canonical path). */
+ pullsUrl: string;
+ /** Builder for the .ether/Usage.ray URL. */
+ settingsUrl: string;
+ chatUrl: string;
+}
+
+// Renders both the mobile nav-row (with icon buttons) and the breadcrumb
+// row's action buttons (visible inline on desktop).
+const ActionRow: React.FC = ({
+ canonicalPath,
+ starPath,
+ followUser,
+ pullsUrl,
+ settingsUrl,
+ chatUrl,
+}) => {
+ const navigate = useNavigate();
+ const [prCount, setPrCount] = useState(0);
+
+ useEffect(() => {
+ let cancelled = false;
+ getAPI()
+ .getOpenPRCount(canonicalPath)
+ .then(async (count) => {
+ if (cancelled) return;
+ if (count > 0) {
+ setPrCount(count);
+ return;
+ }
+ // clonePath strips the @user/ prefix for root users; retry with it
+ if (!canonicalPath.startsWith('@')) {
+ const player = getCurrentPlayer();
+ const retry = await getAPI().getOpenPRCount(`@${player}/${canonicalPath}`);
+ if (!cancelled) setPrCount(retry);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [canonicalPath]);
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export default ActionRow;
diff --git a/orbitmines.com/src/@ether/UI/pages/repository/Breadcrumb.tsx b/orbitmines.com/src/@ether/UI/pages/repository/Breadcrumb.tsx
new file mode 100644
index 00000000..93d69088
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/repository/Breadcrumb.tsx
@@ -0,0 +1,95 @@
+import React from 'react';
+import {useNavigate} from 'react-router-dom';
+import ActionRow, {CloneButton, PrimaryButton} from './ActionButtons';
+
+export interface BreadcrumbItem {
+ label: string;
+ href: string | null;
+}
+
+interface BreadcrumbProps {
+ displayVersion: string;
+ items: BreadcrumbItem[];
+ /** When present, action buttons (star/follow/clone/PR/settings) appear. */
+ canonicalPath?: string;
+ starPath?: string;
+ rootLink?: {label: string; href: string};
+ followUser?: string;
+ pullsUrl?: string;
+ settingsUrl?: string;
+ chatUrl?: string;
+}
+
+const Breadcrumb: React.FC = ({
+ displayVersion,
+ items,
+ canonicalPath,
+ starPath,
+ rootLink,
+ followUser,
+ pullsUrl,
+ settingsUrl,
+ chatUrl,
+}) => {
+ const navigate = useNavigate();
+ return (
+ <>
+ {canonicalPath && pullsUrl && settingsUrl && chatUrl && (
+
+ )}
+
+ >
+ );
+};
+
+export default Breadcrumb;
diff --git a/orbitmines.com/src/@ether/UI/pages/repository/ClonePopup.tsx b/orbitmines.com/src/@ether/UI/pages/repository/ClonePopup.tsx
new file mode 100644
index 00000000..c1bd32ac
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/repository/ClonePopup.tsx
@@ -0,0 +1,73 @@
+import React, {useState} from 'react';
+import {CopyIcon, ForkIcon, GitIcon, PlayIcon} from './icons';
+import {getCurrentPlayer} from './storage';
+
+interface ClonePopupProps {
+ canonicalPath: string;
+ open: boolean;
+ onClose: () => void;
+}
+
+const ClonePopup: React.FC = ({canonicalPath, open}) => {
+ const etherCmd = `ether clone ${canonicalPath}`;
+ const gitCmd = `git clone git@ether.orbitmines.com:${canonicalPath}`;
+
+ const slashIdx = canonicalPath.indexOf('/');
+ const forkUser = `@${getCurrentPlayer()}/`;
+ const forkRepoName = slashIdx >= 0 ? canonicalPath.slice(slashIdx + 1) : canonicalPath;
+ const forkPlaceholder = canonicalPath.startsWith('@ether') ? canonicalPath : `@ether/${forkRepoName}`;
+
+ return (
+
+
+
+

+
+
+
+
+
+
+
+ {forkUser}
+
+ % @me
+
+
+
+
+
+ );
+};
+
+const CopyButton: React.FC<{text: string}> = ({text}) => {
+ const [copied, setCopied] = useState(false);
+ return (
+
+ );
+};
+
+export default ClonePopup;
diff --git a/orbitmines.com/src/@ether/UI/pages/repository/FileListing.tsx b/orbitmines.com/src/@ether/UI/pages/repository/FileListing.tsx
new file mode 100644
index 00000000..bc8b85e2
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/repository/FileListing.tsx
@@ -0,0 +1,137 @@
+import React from 'react';
+import {useNavigate} from 'react-router-dom';
+import {FileIcon} from '../../icons';
+import {flattenEntries, isCompound} from '../../data';
+import type {CompoundEntry, FileEntry, TreeEntry} from '../../data';
+import AccessBadge from './AccessBadge';
+import type {AccessLevel} from './AccessBadge';
+import {buildEntryHref, displayEntryName} from './paths';
+import type {ParentContext} from './paths';
+
+interface FileListingProps {
+ entries: TreeEntry[];
+ basePath: string;
+}
+
+const FileListing: React.FC = ({entries, basePath}) => {
+ const sortKey = (entry: TreeEntry): {isDir: boolean; name: string} => {
+ if (isCompound(entry)) {
+ const leaf = firstLeaf(entry);
+ return leaf ? {isDir: leaf.isDirectory, name: leaf.name} : {isDir: false, name: ''};
+ }
+ return {isDir: entry.isDirectory, name: entry.name};
+ };
+
+ const sorted = [...entries].sort((a, b) => {
+ const ka = sortKey(a);
+ const kb = sortKey(b);
+ if (ka.isDir !== kb.isDir) return ka.isDir ? -1 : 1;
+ return ka.name.localeCompare(kb.name);
+ });
+
+ return (
+
+ {sorted.map((entry, i) =>
+ isCompound(entry) ? (
+
+ ) : (
+
+ ),
+ )}
+
+ );
+};
+
+interface FileRowProps {
+ entry: FileEntry;
+ basePath: string;
+ compoundSize?: number;
+ parentContext?: ParentContext;
+}
+
+export const FileRow: React.FC = ({
+ entry,
+ basePath,
+ compoundSize,
+ parentContext = null,
+}) => {
+ const navigate = useNavigate();
+ const href = entry.isDirectory
+ ? buildEntryHref(basePath, entry.name, parentContext)
+ : basePath + '#' + entry.name;
+ const displayName = displayEntryName(entry.name, parentContext);
+ const accessLevel = (entry.access || 'public') as AccessLevel;
+
+ return (
+ {
+ e.preventDefault();
+ if (entry.externalRoute) navigate(entry.externalRoute);
+ else navigate(href);
+ }}
+ >
+
+
+
+
+
+ {displayName}
+ {compoundSize ? ({compoundSize}) : null}
+
+ {entry.modified}
+
+ );
+};
+
+interface CompoundRowProps {
+ compound: CompoundEntry;
+ basePath: string;
+}
+
+const CompoundRow: React.FC = ({compound, basePath}) => {
+ const count = flattenEntries(compound.entries).length;
+ if (compound.op === '|') {
+ // OR: show only the first entry with the count badge
+ const first = compound.entries[0];
+ if (isCompound(first)) return ;
+ return ;
+ }
+ return (
+
+ {compound.entries.map((e, i) =>
+ isCompound(e) ? (
+
+ ) : (
+
+ ),
+ )}
+
+ );
+};
+
+function firstLeaf(entry: TreeEntry): FileEntry | null {
+ if (!isCompound(entry)) return entry;
+ for (const child of entry.entries) {
+ const leaf = firstLeaf(child);
+ if (leaf) return leaf;
+ }
+ return null;
+}
+
+export function findReadmes(entries: TreeEntry[]): FileEntry[] {
+ const result: FileEntry[] = [];
+ for (const entry of entries) {
+ if (isCompound(entry)) {
+ result.push(...findReadmes(entry.entries));
+ } else if (entry.name === 'README.md' && !entry.isDirectory) {
+ result.push(entry);
+ }
+ }
+ return result;
+}
+
+export default FileListing;
diff --git a/orbitmines.com/src/@ether/UI/pages/repository/FileViewer.tsx b/orbitmines.com/src/@ether/UI/pages/repository/FileViewer.tsx
new file mode 100644
index 00000000..afc00d6a
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/repository/FileViewer.tsx
@@ -0,0 +1,267 @@
+import React, {useEffect, useMemo, useRef, useState} from 'react';
+import classnames from 'classnames';
+import type {FileEntry} from '../../data';
+
+const LINE_HEIGHT = 20;
+const VIRTUAL_THRESHOLD = 500;
+const BUFFER_LINES = 50;
+
+interface FileViewerProps {
+ files: FileEntry[];
+}
+
+const FileViewer: React.FC = ({files}) => {
+ const [active, setActive] = useState(0);
+
+ // Tabs only render when there are multiple files (superposed entries).
+ const tabLabels = useMemo(() => {
+ const sameName = files.every((f) => f.name === files[0]?.name);
+ return files.map((f, i) => (sameName ? `${f.name} (${i + 1})` : f.name));
+ }, [files]);
+
+ return (
+
+ {files.length > 1 && (
+
+ {tabLabels.map((label, i) => (
+
+ ))}
+
+ )}
+ {files.map((file, i) => (
+
+
+
+ ))}
+
+ );
+};
+
+interface FileBodyProps {
+ file: FileEntry;
+ visible: boolean;
+}
+
+const FileBody: React.FC = ({file, visible}) => {
+ if (!file.content) {
+ return (
+ <>
+
+ {file.name}
+
+ No content available
+ >
+ );
+ }
+
+ // Count lines cheaply
+ let lineCount = 1;
+ for (let ci = 0; ci < file.content.length; ci++) {
+ if (file.content.charCodeAt(ci) === 10) lineCount++;
+ }
+
+ return (
+ <>
+
+ {file.name}
+ {lineCount} lines
+
+ {lineCount <= VIRTUAL_THRESHOLD ? (
+
+ ) : (
+
+ )}
+ >
+ );
+};
+
+const SmallFile: React.FC<{content: string}> = ({content}) => {
+ const lines = useMemo(() => content.split('\n'), [content]);
+ return (
+
+
+ {lines.map((line, i) => (
+
+ ))}
+
+
+ );
+};
+
+interface VirtualFileProps {
+ content: string;
+ lineCount: number;
+ visible: boolean;
+}
+
+const VirtualFile: React.FC = ({content, lineCount, visible}) => {
+ const containerRef = useRef(null);
+ const linesRef = useRef(null);
+
+ // Offsets index — built incrementally so very large files don't block
+ // first paint. `offsetsRef` is mutated in place to avoid re-renders.
+ const offsetsRef = useRef([0]);
+ const indexCompleteRef = useRef(false);
+
+ useEffect(() => {
+ // Reset whenever content changes
+ offsetsRef.current = [0];
+ indexCompleteRef.current = false;
+ let cancelled = false;
+ let pos = 0;
+
+ const INITIAL_INDEX = BUFFER_LINES * 2 + 100;
+ for (let n = 0; n < INITIAL_INDEX && pos < content.length; n++) {
+ const nl = content.indexOf('\n', pos);
+ if (nl === -1) {
+ pos = content.length;
+ break;
+ }
+ offsetsRef.current.push(nl + 1);
+ pos = nl + 1;
+ }
+ if (pos >= content.length) {
+ indexCompleteRef.current = true;
+ } else {
+ const buildChunk = () => {
+ if (cancelled) return;
+ const deadline = performance.now() + 8;
+ while (pos < content.length && performance.now() < deadline) {
+ const nl = content.indexOf('\n', pos);
+ if (nl === -1) {
+ pos = content.length;
+ break;
+ }
+ offsetsRef.current.push(nl + 1);
+ pos = nl + 1;
+ }
+ if (pos >= content.length) indexCompleteRef.current = true;
+ else requestAnimationFrame(buildChunk);
+ };
+ requestAnimationFrame(buildChunk);
+ }
+ return () => {
+ cancelled = true;
+ };
+ }, [content]);
+
+ const getLine = (i: number): string => {
+ const offsets = offsetsRef.current;
+ if (i < 0 || i >= offsets.length) return '';
+ const start = offsets[i];
+ const end = i + 1 < offsets.length ? offsets[i + 1] - 1 : content.length;
+ return content.substring(start, end);
+ };
+
+ const [visibleRange, setVisibleRange] = useState<{start: number; end: number}>({
+ start: 0,
+ end: Math.min(lineCount, BUFFER_LINES * 2),
+ });
+
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el) return;
+ let ticking = false;
+ let lastScrollParent: HTMLElement | null = null;
+
+ const findScrollParent = (node: HTMLElement): HTMLElement | null => {
+ let p = node.parentElement;
+ while (p && p !== document.documentElement) {
+ const {overflowY} = getComputedStyle(p);
+ if (overflowY === 'auto' || overflowY === 'scroll') return p;
+ p = p.parentElement;
+ }
+ return null;
+ };
+
+ const update = () => {
+ if (!containerRef.current) return;
+ const rect = containerRef.current.getBoundingClientRect();
+ const sp = findScrollParent(containerRef.current);
+ if (sp !== lastScrollParent) {
+ if (lastScrollParent) lastScrollParent.removeEventListener('scroll', schedule);
+ if (sp) sp.addEventListener('scroll', schedule, {passive: true});
+ lastScrollParent = sp;
+ }
+ let scrollTop: number;
+ let viewHeight: number;
+ if (sp) {
+ const spRect = sp.getBoundingClientRect();
+ scrollTop = Math.max(0, spRect.top - rect.top);
+ viewHeight = sp.clientHeight;
+ } else {
+ scrollTop = Math.max(0, -rect.top);
+ viewHeight = window.innerHeight;
+ }
+ const startLine = Math.max(0, Math.floor(scrollTop / LINE_HEIGHT) - BUFFER_LINES);
+ const maxLine = indexCompleteRef.current ? lineCount : offsetsRef.current.length;
+ const endLine = Math.min(
+ maxLine,
+ Math.ceil((scrollTop + viewHeight) / LINE_HEIGHT) + BUFFER_LINES,
+ );
+ setVisibleRange({start: startLine, end: endLine});
+ };
+
+ const schedule = () => {
+ if (!ticking) {
+ ticking = true;
+ requestAnimationFrame(() => {
+ update();
+ ticking = false;
+ });
+ }
+ };
+
+ window.addEventListener('scroll', schedule, {passive: true});
+ const initialSP = findScrollParent(el);
+ if (initialSP) {
+ initialSP.addEventListener('scroll', schedule, {passive: true});
+ lastScrollParent = initialSP;
+ }
+ update();
+
+ return () => {
+ window.removeEventListener('scroll', schedule);
+ if (lastScrollParent) lastScrollParent.removeEventListener('scroll', schedule);
+ };
+ }, [content, lineCount, visible]);
+
+ const visibleLines = [];
+ for (let i = visibleRange.start; i < visibleRange.end; i++) {
+ visibleLines.push();
+ }
+
+ const totalHeight = lineCount * LINE_HEIGHT;
+
+ return (
+
+ );
+};
+
+const Line: React.FC<{num: number; text: string}> = ({num, text}) => (
+
+ {num}
+ {text === '' ? ' ' : text}
+
+);
+
+export default FileViewer;
diff --git a/orbitmines.com/src/@ether/UI/pages/repository/Header.tsx b/orbitmines.com/src/@ether/UI/pages/repository/Header.tsx
new file mode 100644
index 00000000..e1d3a0b2
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/repository/Header.tsx
@@ -0,0 +1,57 @@
+import React from 'react';
+import {useNavigate} from 'react-router-dom';
+import {buildPathPreservingWildcards} from './paths';
+
+export interface HeaderChainItem {
+ label: string;
+ /** Index into `path` (exclusive end) where this segment lives; -1 = no link. */
+ pathEnd: number;
+}
+
+interface HeaderProps {
+ chain: HeaderChainItem[];
+ base: string;
+ versions: [number, string][];
+ path: string[];
+}
+
+// Header chain: a / -separated list of context switches that build the
+// repo's title. Each segment is a link to the prefix it represents.
+const Header: React.FC = ({chain, base, versions, path}) => {
+ const navigate = useNavigate();
+ return (
+
+ {chain.map((item, idx) => {
+ const isLast = idx === chain.length - 1;
+ const cls = isLast ? 'repo-name' : 'user';
+ const sep = idx > 0 ?
/ : null;
+ if (item.pathEnd >= 0) {
+ const href = buildPathPreservingWildcards(base, versions, path, item.pathEnd) || '/';
+ return (
+
+ {sep}
+ {
+ e.preventDefault();
+ navigate(href);
+ }}
+ >
+ {item.label}
+
+
+ );
+ }
+ return (
+
+ {sep}
+ {item.label}
+
+ );
+ })}
+
+ );
+};
+
+export default Header;
diff --git a/orbitmines.com/src/@ether/UI/pages/repository/IframeMount.tsx b/orbitmines.com/src/@ether/UI/pages/repository/IframeMount.tsx
new file mode 100644
index 00000000..a480e858
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/repository/IframeMount.tsx
@@ -0,0 +1,106 @@
+import React, {useEffect, useRef} from 'react';
+import {getCurrentPlayer} from './storage';
+
+interface IframeMountProps {
+ jsContent: string;
+ canonicalPath: string;
+}
+
+// Sandboxed iframe used to run a repo's index.ray.js. The host exposes a
+// small message bridge (storage / fetch) that the iframe's runtime calls
+// over postMessage.
+const IframeMount: React.FC = ({jsContent, canonicalPath}) => {
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ const container = containerRef.current;
+ if (!container) return;
+
+ const iframe = document.createElement('iframe');
+ iframe.sandbox.add('allow-scripts');
+ iframe.src = '/sandbox.html';
+ iframe.style.cssText =
+ 'width: 100%; border: none; border-radius: 6px; background: #0a0a0a; min-height: 300px; flex-grow: 1;';
+
+ let iframeReady = false;
+
+ const sendInit = (includeScript: boolean) => {
+ if (!iframe.contentWindow) return;
+ iframe.contentWindow.postMessage(
+ {
+ type: 'ether:init',
+ user: getCurrentPlayer(),
+ repo: canonicalPath,
+ ...(includeScript ? {script: jsContent} : {}),
+ },
+ '*',
+ );
+ };
+
+ const onMessage = (e: MessageEvent) => {
+ if (e.source !== iframe.contentWindow) return;
+ const data = e.data;
+ if (!data || !data.type) return;
+
+ if (data.type === 'ether:ready') {
+ iframeReady = true;
+ sendInit(true);
+ } else if (data.type === 'ether:storage') {
+ const nsKey = `ray:${canonicalPath}:${data.key}`;
+ let value: string | null = null;
+ if (data.action === 'get') value = localStorage.getItem(nsKey);
+ else if (data.action === 'set') localStorage.setItem(nsKey, data.value);
+ else if (data.action === 'remove') localStorage.removeItem(nsKey);
+ iframe.contentWindow!.postMessage(
+ {type: 'ether:storage:response', id: data.id, value},
+ '*',
+ );
+ } else if (data.type === 'ether:fetch') {
+ fetch(data.url, data.options || {})
+ .then((resp) =>
+ resp.text().then((body) => {
+ iframe.contentWindow!.postMessage(
+ {
+ type: 'ether:fetch:response',
+ id: data.id,
+ ok: resp.ok,
+ status: resp.status,
+ statusText: resp.statusText,
+ body,
+ },
+ '*',
+ );
+ }),
+ )
+ .catch((err) => {
+ iframe.contentWindow!.postMessage(
+ {
+ type: 'ether:fetch:response',
+ id: data.id,
+ error: err.message || String(err),
+ },
+ '*',
+ );
+ });
+ }
+ };
+
+ const onCharacter = () => {
+ if (iframeReady) sendInit(false);
+ };
+
+ window.addEventListener('message', onMessage);
+ window.addEventListener('ether:character', onCharacter);
+ container.appendChild(iframe);
+
+ return () => {
+ window.removeEventListener('message', onMessage);
+ window.removeEventListener('ether:character', onCharacter);
+ if (iframe.parentNode) iframe.parentNode.removeChild(iframe);
+ };
+ }, [jsContent, canonicalPath]);
+
+ return ;
+};
+
+export default IframeMount;
diff --git a/orbitmines.com/src/@ether/UI/pages/repository/Profile.tsx b/orbitmines.com/src/@ether/UI/pages/repository/Profile.tsx
new file mode 100644
index 00000000..e9508898
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/repository/Profile.tsx
@@ -0,0 +1,338 @@
+import React, {useCallback, useEffect, useMemo, useState} from 'react';
+import {useNavigate} from 'react-router-dom';
+import {Button} from '@blueprintjs/core';
+import Markdown from '../../util/MarkdownView';
+import {getAPI} from '../../data';
+import type {Repository as RepoData} from '../../data';
+import {EditIcon} from './icons';
+import {DEFAULT_AVATAR_SVG_HTML} from './icons';
+import type {HeaderChainItem} from './Header';
+import Breadcrumb from './Breadcrumb';
+import {buildBasePath, buildCanonicalPath} from './paths';
+import {findReadmes} from './FileListing';
+import {getCurrentPlayer, loadProfile, saveProfile} from './storage';
+import ProfileNames from './ProfileNames';
+import {getUserContent, getProfileDefaults, externalToSocials, ProfileMeta} from './userDefaults';
+
+interface ProfileProps {
+ effectiveUser: string;
+ repository: RepoData;
+ headerChain: HeaderChainItem[];
+ base: string;
+ versions: [number, string][];
+ path: string[];
+ clonePath: string;
+ rootStarPath: string;
+ pullsUrl: string;
+ settingsUrl: string;
+ chatUrl: string;
+}
+
+const Profile: React.FC = ({
+ effectiveUser,
+ repository,
+ headerChain,
+ base,
+ versions,
+ path,
+ clonePath,
+ rootStarPath,
+ pullsUrl,
+ settingsUrl,
+ chatUrl,
+}) => {
+ const navigate = useNavigate();
+ const currentPlayer = getCurrentPlayer();
+ const isOwner = effectiveUser === currentPlayer;
+ const defaults = useMemo(() => getProfileDefaults(effectiveUser), [effectiveUser]);
+ const defaultSocials = useMemo(
+ () => (defaults ? externalToSocials(defaults.external) : undefined),
+ [defaults],
+ );
+ const userContent = useMemo(() => getUserContent(effectiveUser), [effectiveUser]);
+ const [profile, setProfile] = useState(() => loadProfile(effectiveUser));
+
+ // Refresh local state if we switched to a different user
+ useEffect(() => {
+ setProfile(loadProfile(effectiveUser));
+ }, [effectiveUser]);
+
+ const displayName = profile.displayName || defaults?.name || effectiveUser;
+ const displayVersion = versions.length > 0 ? versions[versions.length - 1][1] : 'latest';
+
+ // ---- Avatar URL ----
+ const [avatarUrl, setAvatarUrl] = useState(defaults?.picture ?? null);
+ useEffect(() => {
+ let cancelled = false;
+ setAvatarUrl(defaults?.picture ?? null);
+ void (async () => {
+ const repo = await getAPI().getRepository(effectiveUser);
+ if (!repo || cancelled) return;
+ const names = ['2d-square.svg', '2d-square.png', '2d-square.jpeg'];
+ for (const name of names) {
+ const flat = repo.tree.flatMap((e) =>
+ 'children' in e ? (e as {children?: unknown[]}).children || [] : [e],
+ );
+ // best-effort check; ignore type complaints from the flat any[]
+ const hit = (flat as {name?: string}[]).some((entry) => entry.name === name);
+ if (hit) {
+ setAvatarUrl(`/**/@${effectiveUser}/avatar/${name}`);
+ return;
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [effectiveUser, defaults]);
+
+ // ---- README ----
+ const [readmeContent, setReadmeContent] = useState(null);
+ useEffect(() => {
+ let cancelled = false;
+ const readmes = findReadmes(repository.tree);
+ void (async () => {
+ for (const readme of readmes) {
+ if (!readme.content) {
+ const fetched = await getAPI().readFile(`@${effectiveUser}/${readme.name}`);
+ if (fetched !== null) readme.content = fetched;
+ }
+ }
+ const withContent = readmes.filter((r) => r.content);
+ if (cancelled) return;
+ if (withContent.length > 0) {
+ const content = withContent[0].content!;
+ const resolved = content.replace(/href="(?!\/|https?:|#)([^"]+)"/g, (_m, rel) => {
+ const segs = (rel as string).split('/').filter(Boolean);
+ return `href="${buildBasePath(base, versions, [...path, ...segs])}"`;
+ });
+ setReadmeContent(resolved);
+ } else {
+ setReadmeContent('');
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [repository, effectiveUser, base, versions, path]);
+
+ return (
+ <>
+
+
+
+ {defaults ?
: null}
+
+
+
+
+ setProfile((p) => {
+ const next = {...p, displayName: name};
+ saveProfile(effectiveUser, next);
+ return next;
+ })
+ }
+ />
+
+
+
+
+
+ {userContent ? (
+
{userContent}
+ ) : readmeContent ? (
+
+ ) : null}
+
+
+
+ >
+ );
+};
+
+interface AvatarProps {
+ effectiveUser: string;
+ avatarUrl: string | null;
+ isOwner: boolean;
+}
+
+const Avatar: React.FC = ({effectiveUser, avatarUrl, isOwner}) => (
+
+
+ {avatarUrl ? (
+

+ ) : (
+
+ )}
+ {isOwner && (
+
+
+
+ )}
+
+
+);
+
+interface DisplayNameProps {
+ displayName: string;
+ isOwner: boolean;
+ user: string;
+ onChange: (name: string) => void;
+}
+
+const DisplayName: React.FC = ({displayName, isOwner, onChange}) => {
+ const [editing, setEditing] = useState(false);
+ const [value, setValue] = useState(displayName);
+ useEffect(() => setValue(displayName), [displayName]);
+
+ if (editing && isOwner) {
+ return (
+
+ setValue(e.target.value)}
+ onBlur={() => {
+ setEditing(false);
+ const next = value.trim();
+ if (next !== displayName) onChange(next);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ setEditing(false);
+ const next = value.trim();
+ if (next !== displayName) onChange(next);
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ setValue(displayName);
+ setEditing(false);
+ }
+ }}
+ />
+
+ );
+ }
+
+ return (
+
+
isOwner && setEditing(true)}
+ >
+ {displayName}
+
+ {isOwner && (
+
+ )}
+
+ );
+};
+
+interface UsernameProps {
+ effectiveUser: string;
+ isOwner: boolean;
+ displayVersion: string;
+}
+
+const Username: React.FC = ({effectiveUser, isOwner, displayVersion}) => {
+ const navigate = useNavigate();
+ const [editing, setEditing] = useState(false);
+ const [value, setValue] = useState(`@${effectiveUser}`);
+ useEffect(() => setValue(`@${effectiveUser}`), [effectiveUser]);
+
+ const commit = useCallback(() => {
+ setEditing(false);
+ const next = value.replace(/^@/, '').trim();
+ if (next && next !== effectiveUser) {
+ localStorage.setItem('ether:name', next);
+ const profile = loadProfile(effectiveUser);
+ saveProfile(next, profile);
+ navigate(`/@${next}`);
+ } else {
+ setValue(`@${effectiveUser}`);
+ }
+ }, [value, effectiveUser, navigate]);
+
+ if (editing && isOwner) {
+ return (
+
+ {
+ let v = e.target.value;
+ if (!v.startsWith('@') && v.length > 0) v = '@' + v;
+ setValue(v);
+ }}
+ onBlur={commit}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ commit();
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ setValue(`@${effectiveUser}`);
+ setEditing(false);
+ }
+ if (e.key === 'Backspace' && value === '@') e.preventDefault();
+ }}
+ />
+ {displayVersion}
+
+ );
+ }
+
+ return (
+
+ isOwner && setEditing(true)}
+ >
+ @{effectiveUser}
+
+ {isOwner && (
+
+ )}
+ {displayVersion}
+
+ );
+};
+
+export default Profile;
diff --git a/orbitmines.com/src/@ether/UI/pages/repository/ProfileNames.tsx b/orbitmines.com/src/@ether/UI/pages/repository/ProfileNames.tsx
new file mode 100644
index 00000000..0f42507f
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/repository/ProfileNames.tsx
@@ -0,0 +1,714 @@
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import {getAPI} from '../../data';
+import {
+ DRAG_HANDLE_SVG_HTML,
+ EMAIL_SVG_HTML,
+ ETHER_SVG_HTML,
+ WORLD_SVG_HTML,
+ getSocialLabel,
+ getSocialSvg,
+ getSocialUrl,
+ matchPlatform,
+} from './icons';
+import {loadProfile, saveProfile} from './storage';
+import type {ProfileSocial} from './storage';
+import {groupSocials, nameEntryMode} from './profileGroups';
+import type {SocialGroup} from './profileGroups';
+
+interface ProfileNamesProps {
+ user: string;
+ isOwner: boolean;
+ defaults?: ProfileSocial[];
+}
+
+// Editable list of social/world/email entries, grouped by platform.
+// Drag handles let the owner reorder both within a group and across groups.
+const ProfileNames: React.FC = ({user, isOwner, defaults}) => {
+ const [socials, setSocials] = useState(() => {
+ const stored = loadProfile(user).socials;
+ return stored.length > 0 ? stored : (defaults ?? []);
+ });
+
+ useEffect(() => {
+ const stored = loadProfile(user).socials;
+ setSocials(stored.length > 0 ? stored : (defaults ?? []));
+ }, [user, defaults]);
+
+ const persist = useCallback(
+ (next: ProfileSocial[]) => {
+ setSocials(next);
+ const profile = loadProfile(user);
+ profile.socials = next;
+ saveProfile(user, profile);
+ },
+ [user],
+ );
+
+ const groups = useMemo(() => groupSocials(socials), [socials]);
+
+ // ---- Group-level DnD ----
+ const [draggingGroupKey, setDraggingGroupKey] = useState(null);
+ const [groupDropTarget, setGroupDropTarget] = useState<{key: string; before: boolean} | null>(
+ null,
+ );
+
+ const onGroupDrop = useCallback(() => {
+ if (!draggingGroupKey || !groupDropTarget) {
+ setDraggingGroupKey(null);
+ setGroupDropTarget(null);
+ return;
+ }
+ if (groupDropTarget.key === draggingGroupKey) {
+ setDraggingGroupKey(null);
+ setGroupDropTarget(null);
+ return;
+ }
+ const srcGroup = groups.find((g) => g.key === draggingGroupKey);
+ const tgtGroup = groups.find((g) => g.key === groupDropTarget.key);
+ if (!srcGroup || !tgtGroup) {
+ setDraggingGroupKey(null);
+ setGroupDropTarget(null);
+ return;
+ }
+
+ const next = [...socials];
+ const srcIndices = srcGroup.entries.map((e) => e.idx).sort((a, b) => b - a);
+ const srcItems = srcGroup.entries.map((e) => socials[e.idx]);
+ for (const idx of srcIndices) next.splice(idx, 1);
+
+ const tgtFirstIdx = tgtGroup.entries[0].idx;
+ let insertAt = tgtFirstIdx;
+ const removedBefore = srcIndices.filter((si) => si < tgtFirstIdx).length;
+ insertAt -= removedBefore;
+ if (!groupDropTarget.before) {
+ const tgtLastIdx = tgtGroup.entries[tgtGroup.entries.length - 1].idx;
+ insertAt = tgtLastIdx - srcIndices.filter((si) => si < tgtLastIdx).length + 1;
+ }
+ if (insertAt < 0) insertAt = 0;
+ if (insertAt > next.length) insertAt = next.length;
+ next.splice(insertAt, 0, ...srcItems);
+ persist(next);
+ setDraggingGroupKey(null);
+ setGroupDropTarget(null);
+ }, [draggingGroupKey, groupDropTarget, groups, socials, persist]);
+
+ // ---- Within-group entry DnD ----
+ const [draggingEntryIdx, setDraggingEntryIdx] = useState(null);
+ const [entryDropTarget, setEntryDropTarget] = useState<{idx: number; before: boolean} | null>(
+ null,
+ );
+
+ const onEntryDrop = useCallback(() => {
+ if (draggingEntryIdx === null || !entryDropTarget) {
+ setDraggingEntryIdx(null);
+ setEntryDropTarget(null);
+ return;
+ }
+ if (draggingEntryIdx === entryDropTarget.idx) {
+ setDraggingEntryIdx(null);
+ setEntryDropTarget(null);
+ return;
+ }
+ const next = [...socials];
+ const [moved] = next.splice(draggingEntryIdx, 1);
+ let insertIdx = entryDropTarget.before ? entryDropTarget.idx : entryDropTarget.idx + 1;
+ if (draggingEntryIdx < insertIdx) insertIdx--;
+ next.splice(insertIdx, 0, moved);
+ persist(next);
+ setDraggingEntryIdx(null);
+ setEntryDropTarget(null);
+ }, [draggingEntryIdx, entryDropTarget, socials, persist]);
+
+ // ---- Entry modifications ----
+ const removeIdx = useCallback(
+ (idx: number) => {
+ const next = [...socials];
+ next.splice(idx, 1);
+ persist(next);
+ },
+ [socials, persist],
+ );
+
+ const updateAt = useCallback(
+ (idx: number, partial: Partial) => {
+ const next = [...socials];
+ if (idx < next.length) {
+ next[idx] = {...next[idx], ...partial};
+ }
+ persist(next);
+ },
+ [socials, persist],
+ );
+
+ const appendSocial = useCallback(
+ (s: ProfileSocial) => {
+ persist([...socials, s]);
+ },
+ [socials, persist],
+ );
+
+ if (!isOwner && socials.length === 0) return null;
+
+ return (
+
+
Names
+ {groups.map((group) => (
+
setDraggingGroupKey(k)}
+ onGroupDragOver={(k, before) => setGroupDropTarget({key: k, before})}
+ onGroupDrop={onGroupDrop}
+ onGroupDragEnd={() => {
+ setDraggingGroupKey(null);
+ setGroupDropTarget(null);
+ }}
+ draggingEntryIdx={draggingEntryIdx}
+ entryDropTarget={entryDropTarget}
+ onEntryDragStart={(idx) => setDraggingEntryIdx(idx)}
+ onEntryDragOver={(idx, before) => setEntryDropTarget({idx, before})}
+ onEntryDrop={onEntryDrop}
+ onEntryDragEnd={() => {
+ setDraggingEntryIdx(null);
+ setEntryDropTarget(null);
+ }}
+ onRemoveIdx={removeIdx}
+ onUpdateAt={updateAt}
+ user={user}
+ />
+ ))}
+ {isOwner && (
+
+ )}
+
+ );
+};
+
+interface GroupBlockProps {
+ group: SocialGroup;
+ isOwner: boolean;
+ draggingGroupKey: string | null;
+ groupDropTarget: {key: string; before: boolean} | null;
+ onGroupDragStart: (key: string) => void;
+ onGroupDragOver: (key: string, before: boolean) => void;
+ onGroupDrop: () => void;
+ onGroupDragEnd: () => void;
+ draggingEntryIdx: number | null;
+ entryDropTarget: {idx: number; before: boolean} | null;
+ onEntryDragStart: (idx: number) => void;
+ onEntryDragOver: (idx: number, before: boolean) => void;
+ onEntryDrop: () => void;
+ onEntryDragEnd: () => void;
+ onRemoveIdx: (idx: number) => void;
+ onUpdateAt: (idx: number, partial: Partial) => void;
+ user: string;
+}
+
+const GroupBlock: React.FC = ({
+ group,
+ isOwner,
+ draggingGroupKey,
+ groupDropTarget,
+ onGroupDragStart,
+ onGroupDragOver,
+ onGroupDrop,
+ onGroupDragEnd,
+ draggingEntryIdx,
+ entryDropTarget,
+ onEntryDragStart,
+ onEntryDragOver,
+ onEntryDrop,
+ onEntryDragEnd,
+ onRemoveIdx,
+ onUpdateAt,
+ user,
+}) => {
+ const isDragging = draggingGroupKey === group.key;
+ const isDropBefore = groupDropTarget?.key === group.key && groupDropTarget.before;
+ const isDropAfter = groupDropTarget?.key === group.key && !groupDropTarget.before;
+
+ const className = [
+ 'profile-name-group',
+ isDragging ? 'dragging' : '',
+ isDropBefore ? 'drag-over-top' : '',
+ isDropAfter ? 'drag-over-bottom' : '',
+ ]
+ .filter(Boolean)
+ .join(' ');
+
+ return (
+ {
+ if (!draggingGroupKey) return;
+ e.preventDefault();
+ e.stopPropagation();
+ const rect = e.currentTarget.getBoundingClientRect();
+ const before = e.clientY < rect.top + rect.height / 2;
+ onGroupDragOver(group.key, before);
+ }}
+ onDrop={(e) => {
+ if (!draggingGroupKey) return;
+ e.preventDefault();
+ e.stopPropagation();
+ onGroupDrop();
+ }}
+ >
+ {group.entries.map((e, ei) => (
+ onRemoveIdx(e.idx)}
+ onUpdate={(partial) => onUpdateAt(e.idx, partial)}
+ user={user}
+ />
+ ))}
+
+ );
+};
+
+interface EntryRowProps {
+ group: SocialGroup;
+ entryIdx: number;
+ social: ProfileSocial;
+ isFirst: boolean;
+ isOwner: boolean;
+ onGroupDragStart: (key: string) => void;
+ onGroupDragEnd: () => void;
+ draggingEntryIdx: number | null;
+ entryDropTarget: {idx: number; before: boolean} | null;
+ onEntryDragStart: (idx: number) => void;
+ onEntryDragOver: (idx: number, before: boolean) => void;
+ onEntryDrop: () => void;
+ onEntryDragEnd: () => void;
+ onRemove: () => void;
+ onUpdate: (partial: Partial) => void;
+ user: string;
+}
+
+const EntryRow: React.FC = ({
+ group,
+ entryIdx,
+ social,
+ isFirst,
+ isOwner,
+ onGroupDragStart,
+ onGroupDragEnd,
+ draggingEntryIdx,
+ entryDropTarget,
+ onEntryDragStart,
+ onEntryDragOver,
+ onEntryDrop,
+ onEntryDragEnd,
+ onRemove,
+ onUpdate,
+ user,
+}) => {
+ const isWorld = group.key.startsWith('world:');
+ const mode = nameEntryMode(social.platform);
+ const firstIdx = group.entries[0].idx;
+
+ let displayPlatform = '';
+ let displayUsername = '';
+ if (mode === 'world') {
+ displayPlatform = social.platform;
+ displayUsername = social.username ? `@${social.username}` : '';
+ } else if (mode === 'email') {
+ displayPlatform = social.platform;
+ } else {
+ displayPlatform = social.platform ? `@${getSocialLabel(social.platform)}` : '';
+ displayUsername = social.username ? `@${social.username}` : '';
+ }
+
+ const modeClass = mode !== 'platform' && social.platform ? ` mode-${mode}` : '';
+ const isBeingDragged = draggingEntryIdx === entryIdx;
+ const isDropBefore = entryDropTarget?.idx === entryIdx && entryDropTarget.before;
+ const isDropAfter = entryDropTarget?.idx === entryIdx && !entryDropTarget.before;
+ const dragClass = [
+ isBeingDragged ? 'dragging' : '',
+ isDropBefore ? 'drag-over-top' : '',
+ isDropAfter ? 'drag-over-bottom' : '',
+ ]
+ .filter(Boolean)
+ .join(' ');
+
+ const [draggable, setDraggable] = useState(false);
+
+ return (
+ {
+ const target = e.target as HTMLElement;
+ if (target.closest('input, button, a, [contenteditable], [data-group-drag]')) return;
+ setDraggable(true);
+ }}
+ onMouseUp={() => setDraggable(false)}
+ onDragStart={(e) => {
+ if (!draggable) {
+ e.preventDefault();
+ return;
+ }
+ e.stopPropagation();
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', String(entryIdx));
+ onEntryDragStart(entryIdx);
+ }}
+ onDragOver={(e) => {
+ if (draggingEntryIdx === null) return;
+ e.preventDefault();
+ e.stopPropagation();
+ e.dataTransfer.dropEffect = 'move';
+ const rect = e.currentTarget.getBoundingClientRect();
+ const before = e.clientY < rect.top + rect.height / 2;
+ onEntryDragOver(entryIdx, before);
+ }}
+ onDrop={(e) => {
+ if (draggingEntryIdx === null) return;
+ e.preventDefault();
+ e.stopPropagation();
+ onEntryDrop();
+ }}
+ onDragEnd={() => {
+ setDraggable(false);
+ onEntryDragEnd();
+ }}
+ >
+ {isOwner && isFirst ? (
+
{
+ e.stopPropagation();
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', group.key);
+ onGroupDragStart(group.key);
+ }}
+ onDragEnd={onGroupDragEnd}
+ dangerouslySetInnerHTML={{__html: DRAG_HANDLE_SVG_HTML}}
+ />
+ ) : isOwner ? (
+
+ ) : null}
+
+ {isFirst ? (
+
+ ) : (
+
+ )}
+
+ {group.label ? (
+ isFirst ? (
+ {group.label}
+ ) : (
+
+ )
+ ) : null}
+
+ {!isOwner ? (
+
+ {isWorld ? (
+
+ ) : group.key === 'ether' ? (
+ @{social.username}
+ ) : group.key === 'domain' ? (
+
+ {social.username}
+
+ ) : group.key === 'email' ? (
+ {social.platform}
+ ) : (
+
+ )}
+
+ ) : isWorld ? (
+
+ ) : group.key === 'email' ? (
+
+ ) : (
+
+ )}
+
+ ',
+ }}
+ />
+
+ {isOwner && (
+
+ )}
+
+ );
+};
+
+const WorldPathValue: React.FC<{social: ProfileSocial}> = ({social}) => {
+ const segments = social.platform.split('.');
+ return (
+
+ {segments.map((seg, s) => {
+ const indent = s > 0 ? {paddingLeft: s * 12} : undefined;
+ if (seg.startsWith('@')) {
+ const playerName = seg.slice(1);
+ return (
+
+ {seg}
+
+ );
+ }
+ return (
+
+ {seg}
+
+ );
+ })}
+ {social.username && (
+
+ @{social.username}
+
+ )}
+
+ );
+};
+
+const PlatformValue: React.FC<{social: ProfileSocial}> = ({social}) => {
+ const url = getSocialUrl(social.platform, social.username);
+ if (url) {
+ return (
+
+ @{social.username}
+
+ );
+ }
+ return <>@{social.username}>;
+};
+
+interface EditorProps {
+ social: ProfileSocial;
+ onUpdate: (partial: Partial) => void;
+}
+
+const EmailEditor: React.FC = ({social, onUpdate}) => {
+ const [value, setValue] = useState(social.platform);
+ return (
+
+ setValue(e.target.value)}
+ onBlur={() => {
+ const next = value.trim();
+ if (next !== social.platform) onUpdate({platform: next, username: ''});
+ }}
+ />
+
+ );
+};
+
+interface UsernameEditorProps extends EditorProps {
+ displayUsername: string;
+ user: string;
+}
+
+const UsernameEditor: React.FC = ({social, displayUsername, onUpdate}) => {
+ const [value, setValue] = useState(displayUsername);
+ useEffect(() => setValue(displayUsername), [displayUsername]);
+ return (
+
+ {
+ let v = e.target.value;
+ if (!v.startsWith('@') && v.length > 0) v = '@' + v;
+ setValue(v);
+ }}
+ onBlur={() => {
+ const next = value.replace(/^@/, '').trim();
+ if (next !== social.username) onUpdate({username: next});
+ }}
+ />
+
+ );
+};
+
+interface WorldEditorProps extends EditorProps {
+ displayPlatform: string;
+ displayUsername: string;
+ user: string;
+}
+
+const WorldEditor: React.FC = ({
+ social,
+ displayPlatform,
+ displayUsername,
+ onUpdate,
+}) => {
+ const [platform, setPlatform] = useState(displayPlatform);
+ const [username, setUsername] = useState(displayUsername);
+ useEffect(() => setPlatform(displayPlatform), [displayPlatform]);
+ useEffect(() => setUsername(displayUsername), [displayUsername]);
+ return (
+ <>
+
+ setPlatform(e.target.value)}
+ onBlur={() => {
+ const next = platform.trim();
+ if (next && next !== social.platform) onUpdate({platform: next});
+ }}
+ />
+
+
+ {
+ let v = e.target.value;
+ if (!v.startsWith('@') && v.length > 0) v = '@' + v;
+ setUsername(v);
+ }}
+ onBlur={() => {
+ const next = username.replace(/^@/, '').trim();
+ if (next !== social.username) onUpdate({username: next});
+ }}
+ />
+
+ >
+ );
+};
+
+interface AddRowProps {
+ user: string;
+ onAdd: (s: ProfileSocial) => void;
+}
+
+// Empty row at the bottom that adds a new social entry once both fields have
+// values. Used only when the viewer is the profile owner.
+const AddRow: React.FC = ({onAdd}) => {
+ const [platform, setPlatform] = useState('');
+ const [username, setUsername] = useState('');
+
+ const mode = nameEntryMode(platform);
+ const ghostIcon = useMemo(() => {
+ if (mode === 'world') return WORLD_SVG_HTML;
+ if (mode === 'email') return EMAIL_SVG_HTML;
+ const match = matchPlatform(platform.replace(/^@/, ''));
+ return match ? getSocialSvg(match.id) : '';
+ }, [platform, mode]);
+
+ const commitIfReady = useCallback(() => {
+ const rawPlatform = platform.replace(/^@/, '').trim();
+ const rawUsername = username.replace(/^@/, '').trim();
+ if (mode === 'world' && rawPlatform) {
+ onAdd({platform: rawPlatform, username: rawUsername});
+ setPlatform('');
+ setUsername('');
+ return;
+ }
+ if (mode === 'email' && rawPlatform) {
+ onAdd({platform: rawPlatform, username: ''});
+ setPlatform('');
+ return;
+ }
+ if (rawPlatform && rawUsername) {
+ const match = matchPlatform(rawPlatform);
+ const platformId = match ? match.id : rawPlatform.toLowerCase();
+ onAdd({platform: platformId, username: rawUsername});
+ setPlatform('');
+ setUsername('');
+ } else if (!rawPlatform && rawUsername) {
+ onAdd({platform: 'ether', username: rawUsername});
+ setUsername('');
+ }
+ }, [platform, username, mode, onAdd]);
+
+ return (
+
+
+
+
+ setPlatform(e.target.value)}
+ onBlur={commitIfReady}
+ />
+
+
+ {
+ let v = e.target.value;
+ if (!v.startsWith('@') && v.length > 0) v = '@' + v;
+ setUsername(v);
+ }}
+ onBlur={commitIfReady}
+ />
+
+
+ );
+};
+
+export default ProfileNames;
diff --git a/orbitmines.com/src/@ether/UI/pages/repository/Repository.scss b/orbitmines.com/src/@ether/UI/pages/repository/Repository.scss
new file mode 100644
index 00000000..7974e8ce
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/repository/Repository.scss
@@ -0,0 +1,1132 @@
+$phosphor: #ffffff;
+$crt-bg: #0a0a0a;
+$line-height: 20px;
+
+.repo-page {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 32px 24px;
+ font-family: 'Courier New', Courier, monospace;
+ color: $phosphor;
+ min-height: 100vh;
+ box-sizing: border-box;
+
+ &.file-view-mode {
+ max-width: none;
+ padding-right: 0;
+ padding-bottom: 0;
+ display: flex;
+ flex-direction: column;
+ }
+}
+
+.file-view-top {
+ max-width: none;
+ padding-right: 24px;
+}
+
+.ide-layout-mount {
+ flex: 1 0 0;
+}
+
+.repo-header {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ font-size: 22px;
+ margin-bottom: 8px;
+ text-shadow: 0 0 4px rgba(255, 255, 255, 0.5), 0 0 11px rgba(255, 255, 255, 0.22);
+
+ .user { color: rgba(255, 255, 255, 0.55); }
+ .sep { color: rgba(255, 255, 255, 0.25); }
+ .repo-name { color: $phosphor; font-weight: bold; }
+
+ a {
+ color: inherit;
+ text-decoration: none;
+ &:hover { text-decoration: underline; }
+ }
+}
+
+.repo-description {
+ color: rgba(255, 255, 255, 0.4);
+ font-size: 14px;
+ margin-bottom: 24px;
+}
+
+.repo-nav-row {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 2px;
+ margin-bottom: 6px;
+ position: relative;
+}
+
+.nav-actions { display: none; }
+.breadcrumb-actions { display: contents; }
+
+.repo-breadcrumb {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 14px;
+ margin-bottom: 16px;
+ color: rgba(255, 255, 255, 0.45);
+ position: relative;
+
+ a, span, .version-badge { flex-shrink: 0; white-space: nowrap; }
+ a {
+ color: rgba(255, 255, 255, 0.65);
+ text-decoration: none;
+ cursor: pointer;
+ &:hover { color: $phosphor; text-decoration: underline; }
+ }
+ .sep { margin: 0 2px; }
+}
+
+.version-badge {
+ display: inline-block;
+ font-size: 12px;
+ padding: 2px 8px;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 10px;
+ color: rgba(255, 255, 255, 0.5);
+ margin-left: 4px;
+}
+
+.file-table {
+ width: 100%;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 6px;
+ overflow: hidden;
+ margin-bottom: 32px;
+}
+
+.file-row {
+ display: flex;
+ align-items: center;
+ padding: 8px 16px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+ cursor: pointer;
+ transition: background 0.1s;
+ text-decoration: none;
+ color: inherit;
+
+ &:last-child { border-bottom: none; }
+ &:hover { background: rgba(255, 255, 255, 0.04); }
+}
+
+.file-icon {
+ flex: 0 0 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-right: 10px;
+ svg { display: block; }
+}
+
+.file-access {
+ flex: 0 0 auto;
+ display: inline-flex;
+ align-items: center;
+ gap: 1px;
+ margin-right: 6px;
+ svg { display: block; }
+}
+
+.access-badge {
+ display: inline-flex;
+ align-items: center;
+ cursor: pointer;
+ > svg { display: block; }
+}
+
+.access-tooltip {
+ display: none;
+ position: fixed;
+ font-family: 'Courier New', Courier, monospace;
+ background: #111111;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 6px;
+ padding: 6px 10px;
+ white-space: nowrap;
+ font-size: 12px;
+ line-height: 1.4;
+ color: rgba(255, 255, 255, 0.85);
+ z-index: 2147483647;
+ pointer-events: auto;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
+ gap: 6px;
+ align-items: baseline;
+
+ &.visible { display: flex; }
+
+ .access-tooltip-label { font-weight: 600; }
+ .access-tooltip-desc { color: rgba(255, 255, 255, 0.5); }
+ .access-tooltip-link {
+ color: $phosphor;
+ text-decoration: none;
+ cursor: pointer;
+ &:hover { text-decoration: underline; }
+ }
+ .access-tooltip-input {
+ background: transparent;
+ border: none;
+ outline: none;
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 12px;
+ font-weight: 600;
+ color: inherit;
+ padding: 0;
+ margin: 0;
+ width: 100%;
+ min-width: 80px;
+ }
+}
+
+.file-name {
+ flex: 1;
+ font-size: 14px;
+ color: rgba(255, 255, 255, 0.85);
+ .file-row:hover & { color: $phosphor; }
+}
+
+.file-modified {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.25);
+ white-space: nowrap;
+}
+
+.readme-section {
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.readme-header {
+ padding: 10px 16px;
+ font-size: 13px;
+ font-weight: bold;
+ color: rgba(255, 255, 255, 0.6);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.readme-body {
+ padding: 24px 32px;
+ font-size: 14px;
+ line-height: 1.7;
+ color: rgba(255, 255, 255, 0.8);
+
+ &.hidden { display: none; }
+
+ h1 {
+ font-size: 28px;
+ margin: 0 0 16px 0;
+ padding-bottom: 8px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ color: $phosphor;
+ text-shadow: 0 0 6px rgba(255, 255, 255, 0.3);
+ }
+ h2 {
+ font-size: 22px;
+ margin: 28px 0 12px 0;
+ padding-bottom: 6px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+ color: $phosphor;
+ }
+ h3 { font-size: 18px; margin: 24px 0 8px 0; color: $phosphor; }
+ h4, h5, h6 {
+ font-size: 15px;
+ margin: 20px 0 6px 0;
+ color: rgba(255, 255, 255, 0.9);
+ }
+
+ p { margin: 0 0 12px 0; }
+
+ a { color: #7db8e0; text-decoration: none; &:hover { text-decoration: underline; } }
+
+ code {
+ background: rgba(255, 255, 255, 0.08);
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-size: 13px;
+ }
+ pre {
+ background: rgba(0, 0, 0, 0.5);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 6px;
+ padding: 16px;
+ overflow-x: auto;
+ margin: 0 0 16px 0;
+ code { background: none; padding: 0; font-size: 13px; color: rgba(255, 255, 255, 0.75); }
+ }
+
+ blockquote {
+ border-left: 3px solid rgba(255, 255, 255, 0.15);
+ margin: 0 0 12px 0;
+ padding: 4px 16px;
+ color: rgba(255, 255, 255, 0.55);
+ }
+
+ ul, ol { margin: 0 0 12px 0; padding-left: 24px; }
+ li { margin-bottom: 4px; }
+ li.task-item {
+ list-style: none;
+ margin-left: -24px;
+ input { margin-right: 6px; accent-color: $phosphor; }
+ }
+
+ table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 0 0 16px 0;
+ th, td {
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ padding: 6px 12px;
+ font-size: 13px;
+ }
+ th { background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.7); }
+ }
+
+ hr { border: none; border-top: 1px solid rgba(255, 255, 255, 0.1); margin: 20px 0; }
+ del { color: rgba(255, 255, 255, 0.35); }
+ img { max-width: 100%; margin: 8px 0; }
+ strong { color: $phosphor; }
+ em { color: rgba(255, 255, 255, 0.9); font-style: italic; }
+}
+
+.repo-404 {
+ text-align: center;
+ padding: 80px 20px;
+ color: rgba(255, 255, 255, 0.4);
+ font-size: 18px;
+
+ .code {
+ font-size: 64px;
+ color: rgba(255, 255, 255, 0.12);
+ margin-bottom: 16px;
+ }
+}
+
+.compound-group {
+ border-left: 2px solid rgba(255, 255, 255, 0.08);
+}
+.compound-and { border-color: rgba(255, 255, 255, 0.12); }
+.compound-or { border-color: rgba(0, 200, 80, 0.3); }
+.compound-count {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.35);
+ margin-left: 4px;
+}
+
+.readme-tabs {
+ display: flex;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(255, 255, 255, 0.02);
+}
+.readme-tab {
+ padding: 8px 16px;
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.4);
+ cursor: pointer;
+ border: none;
+ background: none;
+ border-bottom: 2px solid transparent;
+ font-family: inherit;
+
+ &:hover { color: rgba(255, 255, 255, 0.6); }
+ &.active { color: $phosphor; border-bottom-color: $phosphor; }
+}
+
+.action-btn {
+ display: inline-flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ height: 26px;
+ box-sizing: border-box;
+ border-radius: 6px;
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 12px;
+ padding: 0 10px;
+ cursor: pointer;
+ line-height: 1;
+ vertical-align: middle;
+ margin-left: 4px;
+
+ .action-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 14px;
+ height: 14px;
+ flex-shrink: 0;
+ svg { width: 14px; height: 14px; fill: currentColor; display: block; }
+ }
+ .action-label { display: flex; align-items: center; height: 100%; }
+ .action-icon-small { display: none !important; }
+ .action-count { font-weight: bold; }
+}
+
+.star-btn {
+ background: none;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ color: rgba(255, 255, 255, 0.55);
+ transition: border-color 0.15s, color 0.15s;
+ &:hover { border-color: rgba(255, 255, 255, 0.3); color: rgba(255, 255, 255, 0.75); }
+ &.starred { color: #f5a623; border-color: #f5a623; }
+ &.starred:hover { color: #f7b84e; border-color: #f7b84e; }
+}
+
+.follow-btn {
+ background: none;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ color: rgba(255, 255, 255, 0.55);
+ transition: border-color 0.15s, color 0.15s;
+ &:hover { border-color: rgba(255, 255, 255, 0.3); color: rgba(255, 255, 255, 0.75); }
+ &.following { color: $phosphor; border-color: $phosphor; }
+ &.following:hover { color: $phosphor; border-color: $phosphor; opacity: 0.85; }
+}
+
+.icon-btn {
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ border-radius: 0;
+ color: rgba(255, 255, 255, 0.5);
+ padding: 0 6px;
+ &:hover { color: rgba(255, 255, 255, 0.85); border-bottom-color: rgba(255, 255, 255, 0.3); }
+}
+
+.clone-btn {
+ background: #00c850;
+ border: 1px solid #00c850;
+ color: #0a0a0a;
+ transition: background 0.15s, border-color 0.15s;
+ &:hover { background: #00da58; border-color: #00da58; }
+}
+
+.popup {
+ position: absolute;
+ top: calc(100% + 6px);
+ right: 0;
+ width: fit-content;
+ max-width: 100%;
+ z-index: 100;
+ background: #0e0e0e;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ padding: 16px;
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.6), 0 0 12px rgba(255, 255, 255, 0.03);
+ font-family: 'Courier New', Courier, monospace;
+ display: none;
+ &.open { display: block; }
+}
+
+.popup-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 99;
+ display: none;
+ &.open { display: block; }
+}
+
+.popup-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 12px;
+ &:last-child { margin-bottom: 0; }
+}
+
+.popup-row-icon {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ img, svg { width: 22px; height: 22px; }
+ svg { fill: rgba(255, 255, 255, 0.4); }
+}
+
+.popup-code {
+ flex: 1;
+ min-width: 0;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 4px;
+ padding: 6px 10px;
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.7);
+ word-break: break-all;
+ overflow-wrap: anywhere;
+}
+
+.copy-btn {
+ flex-shrink: 0;
+ background: none;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 4px;
+ padding: 5px 7px;
+ cursor: pointer;
+ color: rgba(255, 255, 255, 0.4);
+ transition: color 0.15s, border-color 0.15s;
+ display: flex;
+ align-items: center;
+ svg { width: 14px; height: 14px; fill: currentColor; }
+ &:hover { color: $phosphor; border-color: rgba(255, 255, 255, 0.25); }
+ &.copied { color: $phosphor; border-color: $phosphor; }
+}
+
+.popup-ether-block {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.popup-ether-icon {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ align-self: center;
+ img { width: 22px; height: 22px; }
+}
+.popup-ether-lines {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.popup-ether-line {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+.popup-play-btn {
+ flex-shrink: 0;
+ background: #00c850;
+ border: 1px solid #00c850;
+ border-radius: 4px;
+ padding: 5px 7px;
+ cursor: pointer;
+ color: #0a0a0a;
+ transition: background 0.15s, border-color 0.15s;
+ display: flex;
+ align-items: center;
+ svg { width: 14px; height: 14px; fill: currentColor; }
+ &:hover { background: #00da58; border-color: #00da58; }
+}
+.popup-fork-icon {
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ color: rgba(255, 255, 255, 0.4);
+ svg { width: 14px; height: 14px; fill: currentColor; }
+}
+.popup-fork-prefix {
+ color: rgba(255, 255, 255, 0.35);
+ font-size: 12px;
+ white-space: nowrap;
+}
+.popup-fork-input {
+ flex: 1;
+ background: transparent;
+ border: none;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.15);
+ outline: none;
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.7);
+ padding: 2px 4px;
+ min-width: 0;
+ &:focus { border-bottom-color: $phosphor; color: rgba(255, 255, 255, 0.9); }
+}
+.popup-fork-suffix {
+ flex-shrink: 0;
+ color: rgba(255, 255, 255, 0.35);
+ font-size: 12px;
+ white-space: nowrap;
+}
+
+// ---- File viewer ----
+
+.file-view-sidebar-entry {
+ display: flex;
+ align-items: center;
+ padding: 4px 8px;
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.6);
+ cursor: pointer;
+ gap: 6px;
+ transition: background 0.1s;
+ text-decoration: none;
+ &:hover { background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.85); }
+ &.active { background: rgba(255, 255, 255, 0.06); color: $phosphor; }
+ svg { flex-shrink: 0; }
+}
+
+.sidebar-dir-header {
+ display: flex;
+ align-items: center;
+ padding: 4px 8px;
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.6);
+ cursor: pointer;
+ gap: 6px;
+ transition: background 0.1s;
+ &:hover { background: rgba(255, 255, 255, 0.04); color: rgba(255, 255, 255, 0.85); }
+ svg { flex-shrink: 0; }
+}
+
+.sidebar-arrow {
+ flex-shrink: 0;
+ width: 14px;
+ text-align: center;
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.3);
+ line-height: 1;
+}
+.sidebar-arrow-spacer { flex-shrink: 0; width: 14px; }
+.sidebar-dir-children.hidden { display: none; }
+
+.file-view-content { flex: 1; min-width: 0; }
+.file-view-body { &.hidden { display: none; } }
+
+.file-view-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 16px;
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.6);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ background: $crt-bg;
+ position: sticky;
+ top: 0;
+ z-index: 2;
+ .line-count { font-size: 12px; color: rgba(255, 255, 255, 0.3); }
+}
+
+.file-view-tabs {
+ display: flex;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ background: $crt-bg;
+ position: sticky;
+ top: 0;
+ z-index: 2;
+}
+.file-view-tab {
+ padding: 8px 16px;
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.4);
+ cursor: pointer;
+ border: none;
+ background: none;
+ border-bottom: 2px solid transparent;
+ font-family: inherit;
+ &:hover { color: rgba(255, 255, 255, 0.6); }
+ &.active { color: $phosphor; border-bottom-color: $phosphor; }
+}
+.file-view-scroll-container { position: relative; tab-size: 4; }
+.file-view-virtual-spacer { width: 100%; }
+.file-view-lines.virtual { position: absolute; left: 0; right: 0; }
+
+.file-line {
+ display: flex;
+ height: $line-height;
+ line-height: $line-height;
+}
+.file-line-number {
+ flex: 0 0 60px;
+ text-align: right;
+ padding-right: 16px;
+ color: rgba(255, 255, 255, 0.2);
+ font-size: 13px;
+ font-family: 'Courier New', Courier, monospace;
+ user-select: none;
+ -webkit-user-select: none;
+}
+.file-line-text {
+ flex: 1;
+ white-space: pre;
+ font-size: 13px;
+ font-family: 'Courier New', Courier, monospace;
+ color: rgba(255, 255, 255, 0.75);
+ overflow-x: hidden;
+}
+.file-no-content {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex: 1;
+ color: rgba(255, 255, 255, 0.25);
+ font-size: 14px;
+ padding: 40px;
+}
+
+.iframe-overlay {
+ position: fixed;
+ bottom: 0;
+ right: 0;
+ background: rgba(0, 0, 0, 0.55);
+ color: rgba(255, 255, 255, 0.65);
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 13px;
+ padding: 6px 14px;
+ border-top-left-radius: 8px;
+ z-index: 10;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ backdrop-filter: blur(6px);
+ -webkit-backdrop-filter: blur(6px);
+
+ .popup { top: auto; bottom: calc(100% + 6px); right: 0; }
+ .overlay-label { color: rgba(255, 255, 255, 0.85); pointer-events: none; white-space: nowrap; }
+ .overlay-desc {
+ color: rgba(255, 255, 255, 0.4);
+ font-size: 12px;
+ pointer-events: none;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ min-width: 0;
+ }
+}
+
+@media (max-width: 640px) {
+ .iframe-overlay {
+ left: 0;
+ border-top-left-radius: 0;
+ justify-content: flex-end;
+ gap: 6px;
+ padding: 6px 10px;
+ .overlay-desc { display: none; }
+ .action-btn .action-label { display: none; }
+ .action-btn { gap: 4px; padding: 0 6px; }
+ }
+
+ .repo-breadcrumb {
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+ &::-webkit-scrollbar { display: none; }
+ }
+ .breadcrumb-actions { display: none !important; }
+ .nav-actions { display: contents; }
+ .nav-actions .action-btn:not(.follow-btn) .action-label { display: none; }
+ .nav-actions .star-btn,
+ .nav-actions .clone-btn { gap: 4px; padding: 0 6px; }
+
+ .action-icon-default { display: none !important; }
+ .action-btn .action-icon-small { display: flex !important; }
+}
+
+@media (max-width: 400px) {
+ .iframe-overlay {
+ .overlay-label { font-size: 11px; }
+ .action-btn { margin-left: 2px; }
+ }
+ .repo-breadcrumb .action-btn { margin-left: 2px; }
+}
+
+// ---- Profile page ----
+
+.profile-layout {
+ display: block;
+ margin-top: 8px;
+ &::after { content: ""; display: block; clear: both; }
+}
+.profile-readme { min-width: 0; }
+.profile-card {
+ float: right;
+ width: 300px;
+ margin-left: 32px;
+ margin-bottom: 32px;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ overflow: visible;
+ // The readme block slides full-width under this float, and its
+ // position:relative descendants (e.g. timeline rows) otherwise paint above
+ // the float and swallow clicks on the card's buttons. Promote the card into
+ // the positioned layer so it stays on top and stays interactive. z-index 2
+ // clears the timeline filter input (position:relative; z-index:1) that would
+ // otherwise tie and win on DOM order.
+ position: relative;
+ z-index: 2;
+ .popup { max-width: none; right: 0; }
+ .repo-nav-row { margin-top: 8px; }
+}
+
+.profile-avatar-wrap { align-self: center; margin-bottom: 12px; }
+.profile-avatar {
+ width: 180px;
+ height: 180px;
+ border-radius: 50%;
+ overflow: hidden;
+ border: 2px solid rgba(255, 255, 255, 0.12);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(255, 255, 255, 0.04);
+ position: relative;
+ cursor: default;
+ img, svg { width: 100%; height: 100%; object-fit: cover; display: block; }
+ &.editable { cursor: pointer; }
+ &.editable:hover .profile-avatar-overlay { opacity: 1; }
+}
+.profile-avatar-overlay {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.5);
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity 0.15s;
+ pointer-events: none;
+ svg { width: 24px; height: 24px; fill: rgba(255, 255, 255, 0.7); }
+}
+
+.profile-name-row {
+ display: flex;
+ align-items: center;
+ gap: 0;
+ line-height: 28px;
+ .profile-hover-edit { opacity: 0; transition: opacity 0.15s; margin-left: 6px; flex-shrink: 0; }
+ &:hover .profile-hover-edit { opacity: 1; }
+}
+.profile-display-name {
+ font-size: 20px;
+ font-weight: bold;
+ color: $phosphor;
+ line-height: 28px;
+ cursor: default;
+ margin: 0;
+ padding: 0;
+ display: inline;
+}
+.profile-hover-edit {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 4px;
+ border: none;
+ background: none;
+ cursor: pointer;
+ color: rgba(255, 255, 255, 0.3);
+ padding: 0;
+ transition: color 0.15s;
+ &:hover { color: rgba(255, 255, 255, 0.7); }
+ svg { width: 14px; height: 14px; fill: currentColor; }
+}
+.profile-display-name-input {
+ background: transparent;
+ border: none;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.2);
+ outline: none;
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 20px;
+ font-weight: bold;
+ color: $phosphor;
+ padding: 0 0 2px 0;
+ width: 200px;
+ line-height: 28px;
+}
+
+.profile-username-row {
+ display: flex;
+ align-items: center;
+ gap: 0;
+ height: 20px;
+ .profile-hover-edit {
+ opacity: 0;
+ transition: opacity 0.15s;
+ margin-left: 2px;
+ flex-shrink: 0;
+ width: 20px;
+ height: 20px;
+ svg { width: 10px; height: 10px; }
+ }
+ &:hover .profile-hover-edit { opacity: 1; }
+ .version-badge { margin-left: 4px; }
+}
+.profile-username {
+ font-size: 14px;
+ color: rgba(255, 255, 255, 0.4);
+ cursor: default;
+ &.editable { cursor: text; }
+}
+.profile-username-input {
+ font-size: 14px;
+ color: rgba(255, 255, 255, 0.4);
+ background: none;
+ border: none;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.2);
+ outline: none;
+ font-family: inherit;
+ padding: 0;
+ width: 100%;
+}
+
+.profile-names { width: 100%; margin-top: 16px; }
+.profile-names-header {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.3);
+ text-transform: uppercase;
+ letter-spacing: 1px;
+ margin-bottom: 10px;
+ padding-bottom: 6px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.profile-name-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 5px 0;
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.7);
+ position: relative;
+ flex-wrap: wrap;
+
+ &.dragging { opacity: 0.4; }
+ &.drag-over-top { box-shadow: 0 -1px 0 0 rgba(255, 255, 255, 0.3); }
+ &.drag-over-bottom { box-shadow: 0 1px 0 0 rgba(255, 255, 255, 0.3); }
+ &:hover .profile-name-drag { opacity: 1; }
+ &:hover .profile-name-remove { opacity: 1; }
+
+ &.mode-email [data-name-value-wrap] { display: none; }
+ &.mode-email [data-name-platform-wrap] { flex: 1; }
+ &.mode-email .profile-name-field { width: 100%; }
+ &.mode-world .profile-name-field {
+ width: 140px;
+ color: rgba(255, 255, 255, 0.7);
+ &:focus { color: rgba(255, 255, 255, 0.9); }
+ }
+}
+
+.profile-name-drag {
+ flex: 0 0 14px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: grab;
+ color: rgba(255, 255, 255, 0.15);
+ opacity: 0;
+ transition: opacity 0.15s;
+ font-size: 11px;
+ user-select: none;
+ -webkit-user-select: none;
+ &:active { cursor: grabbing; }
+ svg { width: 10px; height: 14px; fill: currentColor; }
+}
+
+.profile-name-icon {
+ flex: 0 0 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 20px;
+ svg { width: 16px; height: 16px; fill: rgba(255, 255, 255, 0.5); }
+}
+
+.profile-name-platform {
+ color: rgba(255, 255, 255, 0.35);
+ font-size: 13px;
+}
+
+.profile-name-value {
+ color: rgba(255, 255, 255, 0.7);
+ min-width: 0;
+ word-break: break-word;
+ a { color: #7db8e0; text-decoration: none; &:hover { text-decoration: underline; } }
+}
+
+.profile-name-remove {
+ margin-left: auto;
+ background: none;
+ border: none;
+ color: rgba(255, 255, 255, 0.2);
+ cursor: pointer;
+ font-size: 14px;
+ padding: 2px 6px;
+ line-height: 1;
+ opacity: 0;
+ transition: opacity 0.15s;
+ &:hover { color: #f87171; }
+}
+
+.profile-name-field {
+ background: transparent;
+ border: none;
+ outline: none;
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.35);
+ padding: 0;
+ width: 90px;
+ caret-color: rgba(255, 255, 255, 0.5);
+ &:focus { color: rgba(255, 255, 255, 0.6); }
+}
+.profile-name-field-value {
+ background: transparent;
+ border: none;
+ outline: none;
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.7);
+ padding: 0;
+ width: 120px;
+ caret-color: $phosphor;
+ &:focus { color: rgba(255, 255, 255, 0.9); }
+}
+
+.profile-name-field-wrap {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ min-width: 0;
+}
+.profile-name-field-ghost {
+ position: absolute;
+ left: 0;
+ top: 0;
+ pointer-events: none;
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.15);
+ white-space: pre;
+ line-height: normal;
+}
+
+.profile-name-world-path {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+}
+.profile-name-path-line {
+ line-height: 1.4;
+ white-space: nowrap;
+ a { color: #7db8e0; text-decoration: none; &:hover { text-decoration: underline; } }
+}
+
+.profile-name-dropdown {
+ flex-basis: 100%;
+ order: 99;
+ display: none;
+ margin-top: 2px;
+ &.active { display: block; }
+}
+.profile-name-dropdown-item {
+ padding: 1px 0;
+ font-size: 13px;
+ font-family: 'Courier New', Courier, monospace;
+ color: rgba(255, 255, 255, 0.25);
+ cursor: pointer;
+ white-space: nowrap;
+ &.selected { color: rgba(255, 255, 255, 0.6); }
+ &:hover { color: rgba(255, 255, 255, 0.5); }
+}
+
+.profile-name-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ margin-bottom: 6px;
+ &:hover .profile-name-group-drag { opacity: 1; }
+ &.drag-over-top { border-top: 2px solid rgba(255, 255, 255, 0.4); }
+ &.drag-over-bottom { border-bottom: 2px solid rgba(255, 255, 255, 0.4); }
+ &.dragging { opacity: 0.3; pointer-events: none; }
+}
+.profile-name-group-row {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ min-height: 24px;
+ user-select: none;
+ -webkit-user-select: none;
+ padding: 0;
+ input { user-select: text; -webkit-user-select: text; }
+ &:hover { background: rgba(255, 255, 255, 0.02); }
+ &:hover .profile-name-verified { opacity: 1; }
+ &:hover .profile-name-entry-remove { opacity: 1; }
+ &.drag-over-top { border-top: 2px solid rgba(255, 255, 255, 0.4); }
+ &.drag-over-bottom { border-bottom: 2px solid rgba(255, 255, 255, 0.4); }
+ &.dragging { opacity: 0.3; pointer-events: none; }
+ .profile-name-field-value { color: rgba(255, 255, 255, 0.9); width: auto; }
+ .profile-name-field { width: auto; }
+
+ &.mode-email [data-name-value-wrap] { display: none; }
+ &.mode-email [data-name-platform-wrap] { flex: 1; }
+ &.mode-email .profile-name-field { width: 100%; }
+ &.mode-world .profile-name-field {
+ width: 140px;
+ color: rgba(255, 255, 255, 0.7);
+ &:focus { color: rgba(255, 255, 255, 0.9); }
+ }
+}
+.profile-name-group-drag {
+ cursor: grab;
+ opacity: 0;
+ transition: opacity 0.15s;
+ display: inline-flex;
+ align-items: center;
+ flex-shrink: 0;
+ svg { width: 12px; height: 12px; fill: rgba(255, 255, 255, 0.3); }
+}
+.profile-name-group-icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 20px;
+ min-height: 20px;
+ svg { width: 16px; height: 16px; fill: rgba(255, 255, 255, 0.5); }
+}
+.profile-name-group-icon-spacer { display: inline-block; flex: 0 0 20px; }
+.profile-name-group-label {
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 12px;
+ flex-shrink: 0;
+ min-width: 60px;
+}
+.profile-name-group-label-spacer { display: inline-block; min-width: 60px; flex-shrink: 0; }
+.profile-name-group-value {
+ color: rgba(255, 255, 255, 0.9);
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 13px;
+ a { color: rgba(255, 255, 255, 0.9); text-decoration: none; &:hover { text-decoration: underline; } }
+}
+.profile-name-verified {
+ display: inline-flex;
+ align-items: center;
+ flex-shrink: 0;
+ margin-left: auto;
+ opacity: 0;
+ transition: opacity 0.15s;
+ svg { width: 13px; height: 13px; fill: rgba(76, 175, 80, 0.45); }
+}
+.profile-name-entry-remove {
+ background: none;
+ border: none;
+ color: rgba(255, 255, 255, 0.35);
+ cursor: pointer;
+ font-size: 14px;
+ padding: 0 2px;
+ line-height: 1;
+ margin-left: 4px;
+ opacity: 0;
+ transition: opacity 0.15s;
+ flex-shrink: 0;
+ &:hover { color: #f87171; }
+}
+
+@media (max-width: 700px) {
+ .profile-card { float: none; width: 100%; margin-left: 0; }
+}
diff --git a/orbitmines.com/src/@ether/UI/pages/repository/Repository.tsx b/orbitmines.com/src/@ether/UI/pages/repository/Repository.tsx
new file mode 100644
index 00000000..a0f7c48b
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/repository/Repository.tsx
@@ -0,0 +1,683 @@
+import React, {useEffect, useMemo, useRef, useState} from 'react';
+import {useLocation, useNavigate} from 'react-router-dom';
+import classnames from 'classnames';
+import type {RepoParams} from '../../router/types';
+import type {FileEntry, Repository as RepoData, TreeEntry} from '../../data';
+import {getAPI} from '../../data';
+import Markdown from '../../util/MarkdownView';
+import Header from './Header';
+import Breadcrumb from './Breadcrumb';
+import FileListing, {findReadmes, FileRow} from './FileListing';
+import FileViewer from './FileViewer';
+import Sidebar from './Sidebar';
+import Profile from './Profile';
+import IframeMount from './IframeMount';
+import {IDELayout, generateId} from '../../layout';
+import type {LayoutNode, PanelDefinition} from '../../layout';
+import {buildBasePath, buildCanonicalPath} from './paths';
+import {
+ buildBreadcrumbItems,
+ buildRootStarPath,
+ fetchFileContent,
+ findIndexRay,
+ hrefToFileTreePath,
+ loadRepoEntries,
+ processPath,
+} from './repoResolve';
+import {CloneButton} from './ActionButtons';
+import {isFollowing, isStarred, getFollowerCount, getStarCount} from './storage';
+import {
+ CloneIcon,
+ DownloadIcon,
+ FollowIcon,
+ FollowingIcon,
+ StarFilledIcon,
+ StarOutlineIcon,
+} from './icons';
+import './Repository.scss';
+
+const Repository: React.FC<{params: RepoParams}> = ({params}) => {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const {user, path, versions, base, hash} = params;
+
+ const resolved = useMemo(() => processPath(user, path, base), [user, path, base]);
+
+ // Resolve synchronously during render so the first paint already has the
+ // repository content (the backend is in-memory). loadRepoEntries returns
+ // null for a 404, or a { redirect } for an inline file path; the redirect
+ // is performed in an effect since navigation can't happen during render.
+ const result = useMemo(
+ () => loadRepoEntries({user, path, base, versions, hash, ...resolved}),
+ [resolved, user, path, base, versions, hash],
+ );
+
+ useEffect(() => {
+ if (result?.redirect) navigate(result.redirect);
+ }, [result, navigate]);
+
+ if (result === null) {
+ const target = resolved.effectiveWorld
+ ? `#${resolved.effectiveWorld} in @${resolved.effectiveUser}`
+ : `@${resolved.effectiveUser}`;
+ return (
+
+
+
404
+ {target} not found
+
+
+ );
+ }
+
+ if (result.redirect) return ;
+
+ return (
+
+ );
+};
+
+interface ViewProps {
+ params: RepoParams;
+ resolved: ReturnType;
+ repository: RepoData;
+ entries: TreeEntry[];
+}
+
+const RepositoryView: React.FC = ({params, resolved, repository, entries}) => {
+ const {user, path, versions, base, hash} = params;
+ const {
+ effectiveUser,
+ effectiveWorld,
+ effectiveWorldParent,
+ treePath,
+ treePathStart,
+ showUsersListing,
+ showWorldsListing,
+ hasWildcard,
+ headerChain,
+ } = resolved;
+
+ // ---- Canonical paths / URL builders ----
+ const clonePath = useMemo(() => {
+ let p = buildCanonicalPath(effectiveUser, effectiveWorld, treePath);
+ if (!base) {
+ const prefix = `@${user}/`;
+ if (p.startsWith(prefix)) p = p.slice(prefix.length);
+ }
+ return p;
+ }, [effectiveUser, effectiveWorld, treePath, base, user]);
+
+ const rootStarPath = useMemo(
+ () => buildRootStarPath(repository, effectiveUser, effectiveWorld, treePath, user, base),
+ [repository, effectiveUser, effectiveWorld, treePath, user, base],
+ );
+
+ const basePath = useMemo(() => buildBasePath(base, versions, path), [base, versions, path]);
+ const displayVersion = versions.length > 0 ? versions[versions.length - 1][1] : 'latest';
+
+ const pullsUrl = useMemo(() => {
+ const clean = path.filter((s) => s !== '*' && s !== '**');
+ const pathPart = clean.length > 0 ? '/' + clean.join('/') : '';
+ return `${base || ''}${pathPart}/-/pulls`;
+ }, [base, path]);
+
+ const settingsUrl = useMemo(() => {
+ const clean = path.filter((s) => s !== '*' && s !== '**');
+ const pathPart = clean.length > 0 ? '/' + clean.join('/') : '';
+ return `${base || ''}${pathPart}/.ether/Usage.ray`;
+ }, [base, path]);
+
+ const chatUrl = useMemo(() => {
+ const b = base || `/@${user}`;
+ return `${b}/chat`;
+ }, [base, user]);
+
+ // ---- Hash file view mode ----
+ if (hash) {
+ return (
+
+ );
+ }
+
+ // ---- Iframe mode (index.ray.js) ----
+ const indexRay =
+ !showUsersListing && !showWorldsListing && !hasWildcard ? findIndexRay(entries) : null;
+ if (indexRay && indexRay.content) {
+ const canonicalPath = buildCanonicalPath(effectiveUser, effectiveWorld, treePath);
+ const headerLabel = headerChain.map((item) => item.label).join(' / ');
+ const isPlayerIframe = !effectiveWorld && treePath.length === 0;
+
+ return (
+
+
+ {headerLabel}
+ {repository.description}
+
+
+
+
+
+ );
+ }
+
+ // ---- Profile mode (user root, no index.ray.js) ----
+ if (
+ treePath.length === 0 &&
+ !hasWildcard &&
+ !showUsersListing &&
+ !showWorldsListing &&
+ !effectiveWorld
+ ) {
+ let profileClonePath = buildCanonicalPath(effectiveUser, null, []);
+ if (!base) {
+ const prefix = `@${user}/`;
+ if (profileClonePath.startsWith(prefix)) profileClonePath = profileClonePath.slice(prefix.length);
+ }
+ return (
+
+ );
+ }
+
+ // ---- Default directory listing ----
+ const {rootLink, items: breadcrumbItems} = buildBreadcrumbItems(
+ treePath,
+ headerChain,
+ base,
+ versions,
+ path,
+ treePathStart,
+ repository.tree,
+ );
+
+ const followUser =
+ !effectiveWorld && treePath.length === 0 ? effectiveUser : undefined;
+
+ return (
+
+
+
{repository.description}
+
+ {showUsersListing ? (
+
+ ) : showWorldsListing ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+interface FileTableWithContextProps {
+ entries: FileEntry[];
+ base: string;
+ versions: [number, string][];
+ path: string[];
+ parentContext: '@' | '~';
+}
+
+const FileTableWithContext: React.FC = ({
+ entries,
+ base,
+ versions,
+ path,
+ parentContext,
+}) => {
+ const subBase =
+ buildBasePath(base, versions, path.slice(0, -1)) + '/' + parentContext;
+ return (
+
+ {entries.map((entry, i) => (
+
+ ))}
+
+ );
+};
+
+interface ReadmeSectionProps {
+ entries: TreeEntry[];
+ effectiveUser: string;
+ effectiveWorld: string | null;
+ effectiveWorldParent: string;
+ treePath: string[];
+ base: string;
+ versions: [number, string][];
+ path: string[];
+}
+
+const ReadmeSection: React.FC = ({
+ entries,
+ effectiveUser,
+ effectiveWorld,
+ effectiveWorldParent,
+ treePath,
+ base,
+ versions,
+ path,
+}) => {
+ const [readmes, setReadmes] = useState([]);
+ const [active, setActive] = useState(0);
+
+ useEffect(() => {
+ let cancelled = false;
+ const found = findReadmes(entries);
+ void (async () => {
+ for (const r of found) {
+ if (!r.content) {
+ const apiPath = effectiveWorld
+ ? `@${effectiveWorldParent}/~${effectiveWorld}/${
+ treePath.length > 0 ? treePath.join('/') + '/' : ''
+ }${r.name}`
+ : `@${effectiveUser}/${
+ treePath.length > 0 ? treePath.join('/') + '/' : ''
+ }${r.name}`;
+ const fetched = await getAPI().readFile(apiPath);
+ if (fetched !== null) r.content = fetched;
+ }
+ }
+ if (cancelled) return;
+ setReadmes(found.filter((r) => r.content));
+ setActive(0);
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [entries, effectiveUser, effectiveWorld, effectiveWorldParent, treePath]);
+
+ if (readmes.length === 0) return null;
+ const allSameName = readmes.every((r) => r.name === readmes[0].name);
+
+ const resolve = (content: string) =>
+ content.replace(/href="(?!\/|https?:|#)([^"]+)"/g, (_m, rel) => {
+ const segs = (rel as string).split('/').filter(Boolean);
+ return `href="${buildBasePath(base, versions, [...path, ...segs])}"`;
+ });
+
+ if (readmes.length === 1) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {readmes.map((r, i) => (
+
+ ))}
+
+ {readmes.map((r, i) => (
+
+ ))}
+
+ );
+};
+
+interface IframePrimaryProps {
+ isPlayer: boolean;
+ followUser?: string;
+ starPath: string;
+}
+
+const IframePrimaryButton: React.FC = ({isPlayer, followUser, starPath}) => {
+ const [, force] = useState(0);
+ const refresh = () => force((n) => n + 1);
+ if (isPlayer && followUser) {
+ const followed = isFollowing(followUser);
+ const count = getFollowerCount(followUser);
+ return (
+
+ );
+ }
+ const starred = isStarred(starPath);
+ const count = getStarCount(starPath);
+ return (
+
+ );
+};
+
+// ---- File view mode (hash) — sidebar + file viewer in IDELayout ----
+
+interface FileViewModeProps {
+ params: RepoParams;
+ resolved: ReturnType;
+ repository: RepoData;
+ entries: TreeEntry[];
+ basePath: string;
+ clonePath: string;
+ rootStarPath: string;
+ pullsUrl: string;
+ settingsUrl: string;
+ chatUrl: string;
+ displayVersion: string;
+}
+
+const FileViewMode: React.FC = ({
+ params,
+ resolved,
+ repository,
+ entries,
+ basePath,
+ clonePath,
+ rootStarPath,
+ pullsUrl,
+ settingsUrl,
+ chatUrl,
+ displayVersion,
+}) => {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const {base, versions, path, hash, user} = params;
+ const {
+ effectiveUser,
+ effectiveWorld,
+ effectiveWorldParent,
+ treePath,
+ treePathStart,
+ headerChain,
+ } = resolved;
+ const hashPath = useMemo(() => (hash || '').split('/').filter(Boolean), [hash]);
+
+ // The initially-opened file's content
+ const [initialFiles, setInitialFiles] = useState(null);
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ const files = await fetchFileContent(
+ entries,
+ hashPath,
+ effectiveWorld,
+ effectiveWorldParent,
+ effectiveUser,
+ treePath,
+ );
+ if (!cancelled) setInitialFiles(files);
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [entries, hashPath, effectiveWorld, effectiveWorldParent, effectiveUser, treePath]);
+
+ const {rootLink, items: breadcrumbItems} = buildBreadcrumbItems(
+ treePath,
+ headerChain,
+ base,
+ versions,
+ path,
+ treePathStart,
+ repository.tree,
+ );
+
+ const initialFilePanelId = 'file:' + (hash || '');
+ const fileName = hashPath[hashPath.length - 1] || '';
+
+ // Sidebar / lazy file-fetching helpers
+ const toApiPath = (relPath: string): string => {
+ return effectiveWorld
+ ? `@${effectiveWorldParent}/~${effectiveWorld}/${
+ treePath.length > 0 ? treePath.join('/') + '/' : ''
+ }${relPath}`
+ : `@${effectiveUser}/${treePath.length > 0 ? treePath.join('/') + '/' : ''}${relPath}`;
+ };
+
+ // Map from panelId -> panel content (for opened file tabs).
+ // Each opened file gets its own panel so users can split panes etc.
+ const [openedPanels, setOpenedPanels] = useState([]);
+
+ const openFileHref = async (href: string) => {
+ const segs = hrefToFileTreePath(href, basePath);
+ if (!segs || segs.length === 0) {
+ navigate(href);
+ return;
+ }
+ const files = await fetchFileContent(
+ entries,
+ segs,
+ effectiveWorld,
+ effectiveWorldParent,
+ effectiveUser,
+ treePath,
+ );
+ if (files.length === 0) {
+ navigate(href);
+ return;
+ }
+ const relHash = segs.map(encodeURIComponent).join('/');
+ const panelId = 'file:' + relHash;
+ const name = segs[segs.length - 1];
+ window.history.replaceState(null, '', location.pathname + '#' + relHash);
+ setOpenedPanels((prev) => {
+ if (prev.some((p) => p.id === panelId)) return prev;
+ return [
+ ...prev,
+ {
+ id: panelId,
+ title: name,
+ closable: true,
+ content: ,
+ },
+ ];
+ });
+ };
+
+ const sidebarPanel: PanelDefinition = useMemo(
+ () => ({
+ id: 'sidebar',
+ title: treePath[0] || (effectiveWorld ? '#' + effectiveWorld : '@' + effectiveUser),
+ closable: false,
+ sticky: true,
+ content: (
+ navigate(href)}
+ />
+ ),
+ }),
+ // openFileHref captured below; we want stable identity for sidebar
+ // panel so we recompute when entries/basePath change.
+ [entries, basePath, hashPath.join('/'), effectiveWorld, effectiveUser, treePath.join('/')],
+ );
+
+ const filePanel: PanelDefinition | null = useMemo(() => {
+ if (!initialFiles) return null;
+ return {
+ id: initialFilePanelId,
+ title: fileName || '404',
+ closable: true,
+ content:
+ initialFiles.length > 0 ? (
+
+ ) : (
+
+
+ {fileName}
+
+
+
+
+ 404
+
+
Path not found
+
+
+
+ ),
+ };
+ }, [initialFiles, initialFilePanelId, fileName]);
+
+ const panels = useMemo(() => {
+ const out: PanelDefinition[] = [sidebarPanel];
+ if (filePanel) out.push(filePanel);
+ for (const p of openedPanels) {
+ if (p.id !== initialFilePanelId) out.push(p);
+ }
+ return out;
+ }, [sidebarPanel, filePanel, openedPanels, initialFilePanelId]);
+
+ const initialLayout = useMemo(() => {
+ const sidebarGroupId = generateId();
+ const fileGroupId = generateId();
+ return {
+ type: 'split',
+ id: generateId(),
+ direction: 'horizontal',
+ children: [
+ {type: 'tabgroup', id: sidebarGroupId, panels: ['sidebar'], activeIndex: 0},
+ {
+ type: 'tabgroup',
+ id: fileGroupId,
+ panels: [initialFilePanelId],
+ activeIndex: 0,
+ },
+ ],
+ sizes: [0.2, 0.8],
+ };
+ }, [initialFilePanelId]);
+
+ if (!filePanel) return ;
+
+ return (
+
+
+
+
{repository.description}
+
+
+
+ {
+ if (panelId.startsWith('file:')) {
+ const relHash = panelId.slice(5);
+ const target = location.pathname + '#' + relHash;
+ if (location.pathname + location.hash !== target) {
+ window.history.replaceState(null, '', target);
+ }
+ }
+ }}
+ />
+
+
+ );
+};
+
+export default Repository;
diff --git a/orbitmines.com/src/@ether/UI/pages/repository/Sidebar.tsx b/orbitmines.com/src/@ether/UI/pages/repository/Sidebar.tsx
new file mode 100644
index 00000000..dc857b2e
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/repository/Sidebar.tsx
@@ -0,0 +1,349 @@
+import React, {useCallback, useEffect, useState} from 'react';
+import {useNavigate} from 'react-router-dom';
+import classnames from 'classnames';
+import {FileIcon} from '../../icons';
+import {flattenEntries, getAPI} from '../../data';
+import type {FileEntry, TreeEntry} from '../../data';
+import {buildEntryHref, displayEntryName} from './paths';
+import type {ParentContext} from './paths';
+import AccessBadge from './AccessBadge';
+import type {AccessLevel} from './AccessBadge';
+import {getCurrentPlayer, loadSession, saveSession} from './storage';
+
+interface SidebarProps {
+ entries: TreeEntry[];
+ basePath: string;
+ /** Path to auto-expand on initial mount (matches the file currently open). */
+ expandPath: string[];
+ /** Resolve a sidebar href into the API listDirectory path for lazy loading. */
+ toApiPath: (relPath: string) => string;
+ /** Called when a leaf entry is clicked. */
+ onFileClick: (href: string) => void;
+ /** When clicking a directory header with no children, navigate to it. */
+ onDirectoryNavigate?: (href: string) => void;
+}
+
+const Sidebar: React.FC = (props) => {
+ const [expanded, setExpanded] = useState>(() => {
+ const session = loadSession(getCurrentPlayer());
+ const set = new Set();
+ if (Array.isArray(session.sidebarExpanded)) {
+ for (const key of session.sidebarExpanded) set.add(key);
+ }
+ return set;
+ });
+
+ const toggleExpanded = useCallback((key: string, willExpand: boolean) => {
+ setExpanded((prev) => {
+ const next = new Set(prev);
+ if (willExpand) next.add(key);
+ else next.delete(key);
+ const session = loadSession(getCurrentPlayer());
+ session.sidebarExpanded = [...next];
+ saveSession(getCurrentPlayer(), session);
+ return next;
+ });
+ }, []);
+
+ return (
+
+ );
+};
+
+interface SidebarTreeProps extends SidebarProps {
+ expanded: Set;
+ onToggle: (key: string, willExpand: boolean) => void;
+ depth: number;
+ parentContext: ParentContext;
+}
+
+const SidebarTree: React.FC = ({
+ entries,
+ basePath,
+ expandPath,
+ expanded,
+ onToggle,
+ toApiPath,
+ onFileClick,
+ onDirectoryNavigate,
+ depth,
+ parentContext,
+}) => {
+ const flat = flattenEntries(entries);
+ const sorted = [...flat].sort((a, b) => {
+ if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
+ return a.name.localeCompare(b.name);
+ });
+ const seen = new Set();
+ const deduped = sorted.filter((e) => {
+ if (seen.has(e.name)) return false;
+ seen.add(e.name);
+ return true;
+ });
+
+ const pad = 8 + depth * 16;
+
+ return (
+ <>
+ {deduped.map((entry) => {
+ const href = buildEntryHref(basePath, entry.name, parentContext);
+ const name = displayEntryName(entry.name, parentContext);
+ const childContext: ParentContext = entry.name === '@' ? '@' : entry.name === '~' ? '~' : null;
+ const isOnPath = expandPath.length > 0 && expandPath[0] === entry.name;
+
+ if (entry.isDirectory) {
+ return (
+ 0 ? expandPath.slice(1) : []}
+ basePath={href}
+ toApiPath={toApiPath}
+ onFileClick={onFileClick}
+ onDirectoryNavigate={onDirectoryNavigate}
+ depth={depth + 1}
+ parentContext={childContext}
+ />
+ );
+ }
+
+ return (
+ 0 ? expandPath.slice(1) : []}
+ expanded={expanded}
+ onToggle={onToggle}
+ onFileClick={onFileClick}
+ onDirectoryNavigate={onDirectoryNavigate}
+ toApiPath={toApiPath}
+ depth={depth + 1}
+ parentContext={childContext}
+ />
+ );
+ })}
+ >
+ );
+};
+
+interface NodeProps {
+ entry: FileEntry;
+ href: string;
+ name: string;
+ pad: number;
+ isOnPath: boolean;
+ expandPath: string[];
+ expanded: Set;
+ onToggle: (key: string, willExpand: boolean) => void;
+ toApiPath: (relPath: string) => string;
+ onFileClick: (href: string) => void;
+ onDirectoryNavigate?: (href: string) => void;
+ basePath?: string;
+ depth: number;
+ parentContext: ParentContext;
+}
+
+const DirectoryNode: React.FC = ({
+ entry,
+ href,
+ name,
+ pad,
+ isOnPath,
+ expandPath,
+ expanded,
+ onToggle,
+ toApiPath,
+ onFileClick,
+ onDirectoryNavigate,
+ basePath,
+ depth,
+ parentContext,
+}) => {
+ // Auto-expand directories that sit on the open file's path
+ useEffect(() => {
+ if (isOnPath && !expanded.has(href)) {
+ onToggle(href, true);
+ }
+ }, []);
+
+ const isExpanded = expanded.has(href);
+ const [fetchedChildren, setFetchedChildren] = useState(null);
+ const children = entry.children || fetchedChildren || [];
+ const navigate = useNavigate();
+
+ const accessLevel = (entry.access || 'public') as AccessLevel;
+
+ const onHeaderClick = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (!entry.children && !fetchedChildren) {
+ // Lazy fetch children on first expand
+ const sidebarPrefix = (basePath || '').replace(/\/$/, '');
+ const relPath = href.startsWith(sidebarPrefix + '/')
+ ? href.slice(sidebarPrefix.length + 1)
+ : null;
+ if (relPath !== null) {
+ getAPI()
+ .listDirectory(toApiPath(decodeURIComponent(relPath)))
+ .then((fetched) => {
+ setFetchedChildren(fetched);
+ onToggle(href, true);
+ });
+ return;
+ }
+ if (onDirectoryNavigate) onDirectoryNavigate(href);
+ else navigate(href);
+ return;
+ }
+ onToggle(href, !isExpanded);
+ },
+ [
+ entry.children,
+ fetchedChildren,
+ basePath,
+ href,
+ toApiPath,
+ onToggle,
+ isExpanded,
+ onDirectoryNavigate,
+ navigate,
+ ],
+ );
+
+ return (
+
+
+
{isExpanded ? '▾' : '▸'}
+
+
+
{name}
+
+ {children.length > 0 && (
+
+
+
+ )}
+
+ );
+};
+
+const FileNode: React.FC = ({
+ entry,
+ href,
+ name,
+ pad,
+ isOnPath,
+ expandPath,
+ expanded,
+ onToggle,
+ onFileClick,
+ onDirectoryNavigate,
+ toApiPath,
+ depth,
+ parentContext,
+}) => {
+ const isActive = isOnPath && expandPath.length === 0;
+ const accessLevel = (entry.access || 'public') as AccessLevel;
+ const fileChildren = entry.children || [];
+
+ useEffect(() => {
+ if (isOnPath && fileChildren.length > 0 && !expanded.has(href)) {
+ onToggle(href, true);
+ }
+ }, []);
+
+ if (fileChildren.length === 0) {
+ return (
+ {
+ e.preventDefault();
+ onFileClick(href);
+ }}
+ >
+
+
+
+
{name}
+
+ );
+ }
+
+ const isExpanded = expanded.has(href);
+
+ return (
+
+
{
+ e.preventDefault();
+ onFileClick(href);
+ }}
+ >
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ onToggle(href, !isExpanded);
+ }}
+ >
+ {isExpanded ? '▾' : '▸'}
+
+
+
+
{name}
+
+
+
+
+
+ );
+};
+
+export default Sidebar;
diff --git a/orbitmines.com/src/@ether/UI/pages/repository/icons.tsx b/orbitmines.com/src/@ether/UI/pages/repository/icons.tsx
new file mode 100644
index 00000000..62e37d38
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/repository/icons.tsx
@@ -0,0 +1,230 @@
+import React from 'react';
+
+// Repository-specific icons not already exported from ../../icons.
+// Sized via inline width/height to match the action button visual rhythm.
+
+export const CloneIcon: React.FC = () => (
+
+);
+
+export const CopyIcon: React.FC = () => (
+
+);
+
+export const GitIcon: React.FC = () => (
+
+);
+
+export const StarFilledIcon: React.FC = () => (
+
+);
+
+export const StarOutlineIcon: React.FC = () => (
+
+);
+
+export const PullRequestIcon: React.FC = () => (
+
+);
+
+export const SettingsIcon: React.FC = () => (
+
+);
+
+export const DownloadIcon: React.FC = () => (
+
+);
+
+export const ForkIcon: React.FC = () => (
+
+);
+
+export const PlayIcon: React.FC = () => (
+
+);
+
+export const FollowIcon: React.FC = () => (
+
+);
+
+export const FollowingIcon: React.FC = () => (
+
+);
+
+export const ChatIcon: React.FC = () => (
+
+);
+
+export const EditIcon: React.FC = () => (
+
+);
+
+// SVG used as raw strings inside dangerouslySetInnerHTML for the
+// access tooltip transitions (legacy DOM-bound logic).
+export const ETHER_SVG_HTML = ``;
+
+export const EMAIL_SVG_HTML = ``;
+
+export const WORLD_SVG_HTML = ``;
+
+export const DRAG_HANDLE_SVG_HTML = ``;
+
+export const DEFAULT_AVATAR_SVG_HTML = ``;
+
+export interface SocialPlatform {
+ id: string;
+ label: string;
+ svg: string;
+ urlPrefix?: string;
+}
+
+export const SOCIAL_PLATFORMS: SocialPlatform[] = [
+ {id: 'ether', label: 'Ether', urlPrefix: '/@', svg: ETHER_SVG_HTML},
+ {
+ id: 'github',
+ label: 'GitHub',
+ urlPrefix: 'https://github.com/',
+ svg: ``,
+ },
+ {
+ id: 'twitter',
+ label: 'X',
+ urlPrefix: 'https://x.com/',
+ svg: ``,
+ },
+ {
+ id: 'discord',
+ label: 'Discord',
+ svg: ``,
+ },
+ {
+ id: 'youtube',
+ label: 'YouTube',
+ urlPrefix: 'https://youtube.com/@',
+ svg: ``,
+ },
+ {
+ id: 'twitch',
+ label: 'Twitch',
+ urlPrefix: 'https://twitch.tv/',
+ svg: ``,
+ },
+ {
+ id: 'linkedin',
+ label: 'LinkedIn',
+ urlPrefix: 'https://linkedin.com/in/',
+ svg: ``,
+ },
+ {
+ id: 'instagram',
+ label: 'Instagram',
+ urlPrefix: 'https://instagram.com/',
+ svg: ``,
+ },
+ {
+ id: 'reddit',
+ label: 'Reddit',
+ urlPrefix: 'https://reddit.com/u/',
+ svg: ``,
+ },
+ {
+ id: 'mastodon',
+ label: 'Mastodon',
+ svg: ``,
+ },
+ {
+ id: 'bluesky',
+ label: 'Bluesky',
+ urlPrefix: 'https://bsky.app/profile/',
+ svg: ``,
+ },
+ {
+ id: 'telegram',
+ label: 'Telegram',
+ urlPrefix: 'https://t.me/',
+ svg: ``,
+ },
+ {
+ id: 'website',
+ label: 'Website',
+ svg: ``,
+ },
+];
+
+export function getSocialUrl(platform: string, username: string): string | null {
+ const p = SOCIAL_PLATFORMS.find((s) => s.id === platform);
+ if (!p || !p.urlPrefix) return null;
+ return p.urlPrefix + username;
+}
+
+export function getSocialSvg(platform: string): string {
+ const p = SOCIAL_PLATFORMS.find((s) => s.id === platform);
+ return p ? p.svg : SOCIAL_PLATFORMS[SOCIAL_PLATFORMS.length - 1].svg;
+}
+
+export function getSocialLabel(platform: string): string {
+ const p = SOCIAL_PLATFORMS.find((s) => s.id === platform);
+ return p ? p.label : platform;
+}
+
+export function matchPlatform(input: string): {id: string; label: string} | null {
+ const val = input.toLowerCase();
+ if (!val) return null;
+ for (const p of SOCIAL_PLATFORMS) {
+ if (p.label.toLowerCase().startsWith(val) || p.id.startsWith(val)) {
+ return {id: p.id, label: p.label};
+ }
+ }
+ return null;
+}
diff --git a/orbitmines.com/src/@ether/UI/pages/repository/paths.ts b/orbitmines.com/src/@ether/UI/pages/repository/paths.ts
new file mode 100644
index 00000000..8e9b547b
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/repository/paths.ts
@@ -0,0 +1,131 @@
+// Path helpers for the repository view. The ether URL scheme keeps several
+// ray-specific conventions that don't map cleanly to react-router params —
+// /~/version markers, @user / ~world segment escaping, etc. This module
+// centralises all the URL construction so the rest of the page just calls
+// `buildBasePath(...)` instead of stringifying segments themselves.
+
+export type ParentContext = '@' | '~' | null;
+
+export function encodeSegment(seg: string): string {
+ return encodeURIComponent(seg);
+}
+
+export function buildBasePath(
+ base: string,
+ versions: [number, string][],
+ path: string[],
+): string {
+ const relevant = versions
+ .filter(([d]) => d <= path.length)
+ .sort((a, b) => a[0] - b[0]);
+
+ if (relevant.length === 0) {
+ return (base || '') + (path.length > 0 ? '/' + path.join('/') : '');
+ }
+
+ let result = base || '';
+ let pathIdx = 0;
+ let verIdx = 0;
+
+ while (pathIdx < path.length || verIdx < relevant.length) {
+ if (verIdx < relevant.length && relevant[verIdx][0] === pathIdx) {
+ result += '/~/' + relevant[verIdx][1];
+ verIdx++;
+ } else if (pathIdx < path.length) {
+ result += '/' + path[pathIdx];
+ pathIdx++;
+ } else {
+ break;
+ }
+ }
+
+ return result;
+}
+
+// Segments that have special routing meaning and need escaping with '!'.
+// (Backslash doesn't work — browsers convert \ to / in URLs.
+// Percent-encoding doesn't work for - and ~ — they're unreserved per RFC 3986
+// so browsers normalize %2D→- and %7E→~ when typed in the URL bar.)
+export function needsPathEscaping(name: string): boolean {
+ if (name.length === 0) return false;
+ const ch = name[0];
+ if (name === '@' || name === '~') return false;
+ if (ch === '@' || ch === '~') return true;
+ if (name === '*' || name === '**' || name === '-') return true;
+ if (ch === '!') return true;
+ return false;
+}
+
+export function escapePathSegment(name: string): string {
+ return needsPathEscaping(name) ? '!' + name : name;
+}
+
+export function unescapePathSegment(seg: string): string {
+ const stripped = seg.length > 1 && seg[0] === '!' ? seg.slice(1) : seg;
+ try {
+ return decodeURIComponent(stripped);
+ } catch {
+ return stripped;
+ }
+}
+
+export function displayEntryName(
+ name: string,
+ parentContext: ParentContext = null,
+): string {
+ if (name === '@') return '@{: String}';
+ if (name === '~') return '#{: String}';
+ if (parentContext === '@') return `@${name}`;
+ if (parentContext === '~') return `#${name}`;
+ return name;
+}
+
+// Children of @ get /@name, children of ~ get /~name (no extra separator).
+export function buildEntryHref(
+ basePath: string,
+ name: string,
+ parentContext: ParentContext = null,
+): string {
+ if (parentContext === '@') {
+ const parent = basePath.replace(/\/@$/, '');
+ return parent + '/@' + encodeSegment(name);
+ }
+ if (parentContext === '~') {
+ const parent = basePath.replace(/\/~$/, '');
+ return parent + '/~' + encodeSegment(name);
+ }
+ if (name === '@' || name === '~') {
+ return basePath + (basePath.endsWith('/') ? '' : '/') + name;
+ }
+ return basePath + (basePath.endsWith('/') ? '' : '/') + encodeSegment(escapePathSegment(name));
+}
+
+export function computeRelativeHash(sidebarBase: string, fullHref: string): string {
+ const prefix = sidebarBase.endsWith('/') ? sidebarBase : sidebarBase + '/';
+ if (fullHref.startsWith(prefix)) return fullHref.slice(prefix.length);
+ return fullHref;
+}
+
+export function buildPathPreservingWildcards(
+ base: string,
+ versions: [number, string][],
+ fullPath: string[],
+ end: number,
+): string {
+ const sliced = fullPath.slice(0, end);
+ for (const seg of fullPath.slice(end)) {
+ if (seg === '*' || seg === '**') sliced.push(seg);
+ }
+ return buildBasePath(base, versions, sliced);
+}
+
+export function buildCanonicalPath(
+ user: string,
+ world: string | null,
+ treePath: string[],
+): string {
+ let p = `@${user}`;
+ if (world) p += `/#${world}`;
+ if (treePath.length > 0) p += '/' + treePath.join('/');
+ return p;
+}
diff --git a/orbitmines.com/src/@ether/UI/pages/repository/profileGroups.ts b/orbitmines.com/src/@ether/UI/pages/repository/profileGroups.ts
new file mode 100644
index 00000000..b361ac42
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/repository/profileGroups.ts
@@ -0,0 +1,87 @@
+// Group flat socials into ordered categories for rendering. Pulled out of
+// the React Profile component so the data logic stays unit-testable.
+
+import {
+ EMAIL_SVG_HTML,
+ ETHER_SVG_HTML,
+ WORLD_SVG_HTML,
+ getSocialLabel,
+ getSocialSvg,
+} from './icons';
+import type {ProfileSocial} from './storage';
+
+export type NameEntryMode = 'platform' | 'world' | 'email';
+
+export function nameEntryMode(platform: string): NameEntryMode {
+ if (platform.startsWith('#')) return 'world';
+ if (!platform || platform.startsWith('@')) return 'platform';
+ if (platform.includes('@')) return 'email';
+ return 'platform';
+}
+
+export function isDomainName(name: string): boolean {
+ return /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*\.[a-zA-Z]{2,}$/.test(
+ name,
+ );
+}
+
+export interface SocialGroup {
+ /** Category key — 'ether', 'domain', 'email', platform id, or per-world unique. */
+ key: string;
+ icon: string;
+ label: string;
+ entries: {idx: number; social: ProfileSocial}[];
+}
+
+export function groupSocials(socials: ProfileSocial[]): SocialGroup[] {
+ const groups: SocialGroup[] = [];
+ const keyToGroup = new Map();
+
+ for (let i = 0; i < socials.length; i++) {
+ const s = socials[i];
+ const mode = nameEntryMode(s.platform);
+
+ if (mode === 'world') {
+ groups.push({
+ key: `world:${i}`,
+ icon: WORLD_SVG_HTML,
+ label: '',
+ entries: [{idx: i, social: s}],
+ });
+ continue;
+ }
+
+ let key: string;
+ let icon: string;
+ let label: string;
+
+ if (s.platform === 'ether' && !isDomainName(s.username)) {
+ key = 'ether';
+ icon = ETHER_SVG_HTML;
+ label = '';
+ } else if (s.platform === 'ether' && isDomainName(s.username)) {
+ key = 'domain';
+ icon = WORLD_SVG_HTML;
+ label = '';
+ } else if (mode === 'email') {
+ key = 'email';
+ icon = EMAIL_SVG_HTML;
+ label = '';
+ } else {
+ key = s.platform;
+ icon = getSocialSvg(s.platform);
+ label = `@${getSocialLabel(s.platform)}`;
+ }
+
+ const existing = keyToGroup.get(key);
+ if (existing) {
+ existing.entries.push({idx: i, social: s});
+ } else {
+ const group: SocialGroup = {key, icon, label, entries: [{idx: i, social: s}]};
+ groups.push(group);
+ keyToGroup.set(key, group);
+ }
+ }
+
+ return groups;
+}
diff --git a/orbitmines.com/src/@ether/UI/pages/repository/repoResolve.ts b/orbitmines.com/src/@ether/UI/pages/repository/repoResolve.ts
new file mode 100644
index 00000000..76b2f397
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/repository/repoResolve.ts
@@ -0,0 +1,445 @@
+// Path -> repo / world / tree resolution. Shared between the React entry
+// and tests. Mirrors the renderRepo() prologue from ray's Repository.ts.
+
+import {flattenEntries, resolveDirectory, resolveFile, resolveFiles} from '../../data';
+import type {FileEntry, Repository as RepoData, TreeEntry} from '../../data';
+import {getAPI} from '../../data';
+import {
+ buildBasePath,
+ buildCanonicalPath,
+ unescapePathSegment,
+} from './paths';
+import {getCurrentPlayer, getSessionContent, getStars} from './storage';
+import type {HeaderChainItem} from './Header';
+
+export interface ResolvedRepoParams {
+ effectiveUser: string;
+ effectiveWorld: string | null;
+ effectiveWorldParent: string;
+ worldParentKey: string;
+ treePath: string[];
+ treePathStart: number;
+ userPathEnd: number;
+ showUsersListing: boolean;
+ showWorldsListing: boolean;
+ hasWildcard: boolean;
+ headerChain: HeaderChainItem[];
+ accessGroupContext: string;
+}
+
+export function processPath(
+ user: string,
+ path: string[],
+ base: string,
+): ResolvedRepoParams {
+ let effectiveUser = user;
+ let effectiveWorld: string | null = null;
+ let effectiveWorldParent = user;
+ let worldParentKey = user;
+ let treePath: string[] = [];
+ let treePathStart = 0;
+ let userPathEnd = 0;
+ let showUsersListing = false;
+ let showWorldsListing = false;
+ let hasWildcard = false;
+
+ // Hide the implicit @user when the path immediately enters a world.
+ const firstNonWild = path.find((s) => s !== '*' && s !== '**');
+ const startsWithWorld =
+ firstNonWild !== undefined && (firstNonWild === '~' || firstNonWild.startsWith('~'));
+ const headerChain: HeaderChainItem[] =
+ base || !startsWithWorld ? [{label: `@${user}`, pathEnd: 0}] : [];
+
+ for (let i = 0; i < path.length; i++) {
+ const seg = path[i];
+ if (seg === '*' || seg === '**') {
+ hasWildcard = true;
+ if (treePath.length === 0) {
+ treePathStart = i + 1;
+ userPathEnd = i + 1;
+ }
+ continue;
+ } else if (seg === '@') {
+ if (i === path.length - 1) {
+ showUsersListing = true;
+ } else {
+ effectiveUser = path[i + 1];
+ effectiveWorld = null;
+ worldParentKey = effectiveUser;
+ treePath = [];
+ treePathStart = i + 2;
+ userPathEnd = i + 2;
+ headerChain.push({label: `@${effectiveUser}`, pathEnd: i + 2});
+ i++;
+ }
+ } else if (seg.startsWith('@')) {
+ effectiveUser = seg.slice(1);
+ effectiveWorld = null;
+ worldParentKey = effectiveUser;
+ treePath = [];
+ treePathStart = i + 1;
+ userPathEnd = i + 1;
+ headerChain.push({label: `@${effectiveUser}`, pathEnd: i + 1});
+ } else if (seg === '~') {
+ if (i === path.length - 1) showWorldsListing = true;
+ } else if (seg.startsWith('~')) {
+ const parentKey = worldParentKey;
+ effectiveWorld = seg.slice(1);
+ treePath = [];
+ treePathStart = i + 1;
+ headerChain.push({label: `#${effectiveWorld}`, pathEnd: i + 1});
+ worldParentKey = effectiveWorld;
+ effectiveWorldParent = parentKey;
+ } else {
+ treePath.push(unescapePathSegment(seg));
+ }
+ }
+
+ if (showUsersListing) {
+ headerChain.push({label: '@{: String}', pathEnd: -1});
+ } else if (showWorldsListing) {
+ headerChain.push({label: '#{: String}', pathEnd: -1});
+ } else if (treePath.length > 0) {
+ headerChain.push({label: treePath[0], pathEnd: treePathStart + 1});
+ }
+
+ const accessGroupContext = effectiveWorld ? '#' + effectiveWorld : '@' + effectiveUser;
+
+ return {
+ effectiveUser,
+ effectiveWorld,
+ effectiveWorldParent,
+ worldParentKey,
+ treePath,
+ treePathStart,
+ userPathEnd,
+ showUsersListing,
+ showWorldsListing,
+ hasWildcard,
+ headerChain,
+ accessGroupContext,
+ };
+}
+
+export interface LoadRepoResult {
+ repository: RepoData;
+ entries: TreeEntry[];
+ /** If non-null, hash/path resolution should redirect to this URL. */
+ redirect?: string;
+}
+
+export interface LoadRepoArgs extends ResolvedRepoParams {
+ user: string;
+ path: string[];
+ base: string;
+ versions: [number, string][];
+ hash: string | null;
+}
+
+// Loads the repository (or world), augments its tree at the root with the
+// virtual @/~ navigation entries + session/stars files, and returns the
+// directory entries that should be shown for the current path. Returns null
+// when the target repo/world doesn't exist.
+export function loadRepoEntries(args: LoadRepoArgs): LoadRepoResult | null {
+ const {
+ effectiveUser,
+ effectiveWorld,
+ effectiveWorldParent,
+ worldParentKey,
+ treePath,
+ treePathStart,
+ showUsersListing,
+ showWorldsListing,
+ user,
+ path,
+ base,
+ versions,
+ hash,
+ } = args;
+
+ const currentPlayer = getCurrentPlayer();
+ let repository = effectiveWorld
+ ? getAPI().getWorld(effectiveWorldParent, effectiveWorld)
+ : getAPI().getRepository(effectiveUser);
+ if (!repository && !effectiveWorld && effectiveUser === currentPlayer) {
+ repository = {user: currentPlayer, description: `@${currentPlayer}`, tree: []};
+ }
+ if (!repository) return null;
+
+ let entries: TreeEntry[];
+
+ if (showUsersListing) {
+ const referencedUsers = getAPI().getReferencedUsers(effectiveUser, effectiveWorld);
+ const users = referencedUsers.includes(currentPlayer)
+ ? referencedUsers
+ : [currentPlayer, ...referencedUsers];
+ entries = users.map((u) => ({
+ name: u,
+ isDirectory: true,
+ modified: '',
+ }));
+ } else if (showWorldsListing) {
+ const referencedWorlds = getAPI().getReferencedWorlds(effectiveUser, effectiveWorld);
+ entries = referencedWorlds.map((w) => ({
+ name: w,
+ isDirectory: true,
+ modified: '',
+ }));
+ } else {
+ let resolved =
+ treePath.length > 0 ? resolveDirectory(repository.tree, treePath) : repository.tree;
+
+ if (!resolved && treePath.length > 0) {
+ const apiPath = effectiveWorld
+ ? `@${effectiveWorldParent}/~${effectiveWorld}/${treePath.join('/')}`
+ : `@${effectiveUser}/${treePath.join('/')}`;
+ const fetched = getAPI().listDirectory(apiPath);
+ if (fetched.length > 0) resolved = fetched;
+ }
+
+ if (!resolved) {
+ // Try the hash-redirect path: maybe the user typed a file path inline
+ if (!hash && treePath.length > 0) {
+ const resolveTree = augmentTreeRoot(
+ repository.tree,
+ effectiveUser,
+ effectiveWorld,
+ worldParentKey,
+ currentPlayer,
+ );
+ const files = resolveFiles(resolveTree, treePath);
+ if (files.length > 0) {
+ let dirDepth = treePath.length - 1;
+ while (dirDepth > 0 && !resolveDirectory(resolveTree, treePath.slice(0, dirDepth))) {
+ dirDepth--;
+ }
+ const dirPart = treePath.slice(0, dirDepth);
+ const filePart = treePath.slice(dirDepth);
+ const parentUrl = buildBasePath(base, versions, [
+ ...path.slice(0, treePathStart),
+ ...dirPart,
+ ]);
+ return {repository, entries: [], redirect: parentUrl + '#' + filePart.join('/')};
+ }
+ }
+ return null;
+ }
+ entries = resolved;
+
+ if (treePath.length === 0) {
+ const augmented = augmentTreeRoot(
+ entries,
+ effectiveUser,
+ effectiveWorld,
+ worldParentKey,
+ currentPlayer,
+ );
+ entries = augmented;
+ }
+ }
+
+ return {repository, entries};
+}
+
+function augmentTreeRoot(
+ tree: TreeEntry[],
+ effectiveUser: string,
+ effectiveWorld: string | null,
+ worldParentKey: string,
+ currentPlayer: string,
+): TreeEntry[] {
+ const virtuals: FileEntry[] = [];
+
+ const refUsers = getAPI().getReferencedUsers(effectiveUser, effectiveWorld);
+ if (refUsers.length > 0) {
+ const userChildren: FileEntry[] = (
+ refUsers.includes(currentPlayer) ? refUsers : [currentPlayer, ...refUsers]
+ ).map((u) => {
+ const repo = getAPI().getRepository(u);
+ return {
+ name: u,
+ isDirectory: true,
+ modified: '',
+ children: repo ? [...repo.tree] : [],
+ } as FileEntry;
+ });
+ virtuals.push({name: '@', isDirectory: true, modified: '', children: userChildren});
+ }
+
+ const refWorlds = getAPI().getReferencedWorlds(effectiveUser, effectiveWorld);
+ if (refWorlds.length > 0) {
+ const worldChildren: FileEntry[] = refWorlds.map((w) => {
+ const worldRepo = getAPI().getWorld(worldParentKey, w);
+ return {
+ name: w,
+ isDirectory: true,
+ modified: '',
+ children: worldRepo ? [...worldRepo.tree] : [],
+ } as FileEntry;
+ });
+ virtuals.push({name: '~', isDirectory: true, modified: '', children: worldChildren});
+ }
+
+ if (effectiveUser === currentPlayer && !effectiveWorld) {
+ const stars = getStars();
+ virtuals.push({
+ name: '.stars.list.ray',
+ isDirectory: false,
+ modified: '',
+ content: stars.length > 0 ? stars.join('\n') : '',
+ });
+ virtuals.push({
+ name: 'Session.ray.json',
+ isDirectory: false,
+ modified: '',
+ content: getSessionContent(currentPlayer),
+ });
+ }
+
+ const virtualNames = new Set(virtuals.map((v) => v.name));
+ return [...virtuals, ...tree.filter((e) => 'name' in e && !virtualNames.has((e as FileEntry).name))];
+}
+
+export function buildRootStarPath(
+ repository: RepoData,
+ effectiveUser: string,
+ effectiveWorld: string | null,
+ treePath: string[],
+ user: string,
+ base: string,
+): string {
+ if (treePath.length > 0) {
+ const flat = flattenEntries(repository.tree);
+ const isTopDir = flat.some((e) => e.name === treePath[0] && e.isDirectory);
+ if (isTopDir) {
+ let p = buildCanonicalPath(effectiveUser, effectiveWorld, treePath.slice(0, 1));
+ if (!base) {
+ const prefix = `@${user}/`;
+ if (p.startsWith(prefix)) p = p.slice(prefix.length);
+ }
+ return p;
+ }
+ }
+ let p = buildCanonicalPath(effectiveUser, effectiveWorld, []);
+ if (!base) {
+ const prefix = `@${user}/`;
+ if (p.startsWith(prefix)) p = p.slice(prefix.length);
+ }
+ return p;
+}
+
+export interface BreadcrumbBuildResult {
+ rootLink?: {label: string; href: string};
+ items: {label: string; href: string | null}[];
+}
+
+export function buildBreadcrumbItems(
+ treePath: string[],
+ headerChain: HeaderChainItem[],
+ base: string,
+ versions: [number, string][],
+ path: string[],
+ treePathStart: number,
+ repoTree?: TreeEntry[],
+): BreadcrumbBuildResult {
+ if (treePath.length === 0) return {items: []};
+
+ const isTopDir = repoTree
+ ? flattenEntries(repoTree).some((e) => e.name === treePath[0] && e.isDirectory)
+ : true;
+
+ if (!isTopDir && treePath.length === 1) {
+ const parentEntry =
+ headerChain.length >= 2
+ ? headerChain[headerChain.length - 2]
+ : headerChain[headerChain.length - 1];
+ const rootLabel = parentEntry?.label || '';
+ const rootHref = buildBasePathSlice(base, versions, path, treePathStart);
+ return {
+ rootLink: {label: rootLabel, href: rootHref},
+ items: [{label: treePath[0], href: null}],
+ };
+ }
+
+ const rootLabel = treePath[0] || headerChain[headerChain.length - 1]?.label || '';
+ const rootHref = buildBasePathSlice(base, versions, path, treePathStart + 1);
+ const subPath = treePath.slice(1);
+ const items = subPath.map((seg, i) => ({
+ label: seg,
+ href:
+ i < subPath.length - 1
+ ? buildBasePathSlice(base, versions, path, treePathStart + 1 + i + 1)
+ : null,
+ }));
+ return {rootLink: {label: rootLabel, href: rootHref}, items};
+}
+
+function buildBasePathSlice(
+ base: string,
+ versions: [number, string][],
+ fullPath: string[],
+ end: number,
+): string {
+ const sliced = fullPath.slice(0, end);
+ for (const seg of fullPath.slice(end)) {
+ if (seg === '*' || seg === '**') sliced.push(seg);
+ }
+ return buildBasePath(base, versions, sliced);
+}
+
+// Look up index.ray.js content from a directory listing.
+export function findIndexRay(entries: TreeEntry[]): FileEntry | null {
+ const flat = flattenEntries(entries);
+ return flat.find((e) => e.name === 'index.ray.js' && !e.isDirectory && e.content) || null;
+}
+
+// Convenience to fetch a file's content, falling back to the API.
+export async function fetchFileContent(
+ entries: TreeEntry[],
+ hashPath: string[],
+ effectiveWorld: string | null,
+ effectiveWorldParent: string,
+ effectiveUser: string,
+ treePath: string[],
+): Promise {
+ let files = resolveFiles(entries, hashPath);
+ const needsFetch = files.length === 0 || files.every((f) => f.content === undefined);
+ if (needsFetch) {
+ const apiFilePath = effectiveWorld
+ ? `@${effectiveWorldParent}/~${effectiveWorld}/${
+ treePath.length > 0 ? treePath.join('/') + '/' : ''
+ }${hashPath.join('/')}`
+ : `@${effectiveUser}/${treePath.length > 0 ? treePath.join('/') + '/' : ''}${hashPath.join(
+ '/',
+ )}`;
+ const content = await getAPI().readFile(apiFilePath);
+ if (content !== null) {
+ const name = hashPath[hashPath.length - 1];
+ files = [{name, isDirectory: false, modified: '', content}];
+ }
+ }
+ return files;
+}
+
+// Resolves a file from a sidebar href (decoding @/~ prefixes back into
+// virtual @/~ tree segments so resolveFiles can navigate them).
+export function hrefToFileTreePath(href: string, sidebarBasePath: string): string[] | null {
+ const prefix = sidebarBasePath.endsWith('/') ? sidebarBasePath : sidebarBasePath + '/';
+ if (!href.startsWith(prefix)) return null;
+ const rel = href.slice(prefix.length).split('/').filter(Boolean);
+ const segs: string[] = [];
+ for (const raw of rel) {
+ let seg: string;
+ try {
+ seg = decodeURIComponent(raw);
+ } catch {
+ seg = raw;
+ }
+ if (seg.length > 1 && (seg.startsWith('@') || seg.startsWith('~'))) {
+ segs.push(seg[0], seg.slice(1));
+ } else {
+ segs.push(seg);
+ }
+ }
+ return segs;
+}
diff --git a/orbitmines.com/src/@ether/UI/pages/repository/storage.ts b/orbitmines.com/src/@ether/UI/pages/repository/storage.ts
new file mode 100644
index 00000000..372e21a9
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/repository/storage.ts
@@ -0,0 +1,160 @@
+// LocalStorage state for repository: current player identity, stars,
+// follows, and per-user session (sidebar expand set + IDE layout).
+// Mirrors ray's API.ts local-state functions 1:1.
+
+// SSR-safe read: localStorage is undefined during server prerender, and these
+// reads now run in the render path (synchronous repo resolution). Writes only
+// happen from client event handlers, where localStorage always exists.
+function lsGet(key: string): string | null {
+ if (typeof localStorage === 'undefined') return null;
+ return localStorage.getItem(key);
+}
+
+// ---- Player identity ----
+
+export function getCurrentPlayer(): string {
+ return lsGet('ether:name') || 'anonymous';
+}
+
+// ---- Stars ----
+
+const STARS_KEY = 'ether:stars';
+
+function setStars(stars: string[]): void {
+ localStorage.setItem(STARS_KEY, stars.join('\n'));
+}
+
+export function getStars(): string[] {
+ const raw = lsGet(STARS_KEY);
+ return raw ? raw.split('\n').filter(Boolean) : [];
+}
+
+export function getStarCount(canonicalPath: string): number {
+ const raw = lsGet(`ether:star-count:${canonicalPath}`);
+ return raw ? parseInt(raw, 10) || 0 : 0;
+}
+
+export function setStarCount(canonicalPath: string, count: number): void {
+ localStorage.setItem(`ether:star-count:${canonicalPath}`, String(Math.max(0, count)));
+}
+
+export function isStarred(canonicalPath: string): boolean {
+ const stars = getStars();
+ if (stars.includes(canonicalPath)) return true;
+ // Parent match — but not for worlds (#), players (@), or top-level libraries.
+ const parts = canonicalPath.split('/');
+ for (let i = parts.length - 1; i >= 1; i--) {
+ const parent = parts.slice(0, i).join('/');
+ const child = parts[i];
+ if (child.startsWith('@') || child.startsWith('#') || child.startsWith('~')) break;
+ if (
+ i === 1 ||
+ parts[i - 1].startsWith('@') ||
+ parts[i - 1].startsWith('#') ||
+ parts[i - 1].startsWith('~')
+ )
+ break;
+ if (stars.includes(parent)) return true;
+ }
+ return false;
+}
+
+export function toggleStar(canonicalPath: string): boolean {
+ const stars = getStars();
+ const idx = stars.indexOf(canonicalPath);
+ if (idx >= 0) {
+ stars.splice(idx, 1);
+ setStars(stars);
+ return false;
+ }
+ stars.push(canonicalPath);
+ setStars(stars);
+ return true;
+}
+
+// ---- Follows ----
+
+export function getFollowerCount(user: string): number {
+ const raw = lsGet(`ether:follower-count:${user}`);
+ return raw ? parseInt(raw, 10) || 0 : 0;
+}
+
+export function setFollowerCount(user: string, count: number): void {
+ localStorage.setItem(`ether:follower-count:${user}`, String(Math.max(0, count)));
+}
+
+export function isFollowing(user: string): boolean {
+ const raw = lsGet('ether:following');
+ const list = raw ? raw.split('\n').filter(Boolean) : [];
+ return list.includes(user);
+}
+
+export function toggleFollow(user: string): boolean {
+ const raw = localStorage.getItem('ether:following');
+ const list = raw ? raw.split('\n').filter(Boolean) : [];
+ const idx = list.indexOf(user);
+ if (idx >= 0) {
+ list.splice(idx, 1);
+ localStorage.setItem('ether:following', list.join('\n'));
+ return false;
+ }
+ list.push(user);
+ localStorage.setItem('ether:following', list.join('\n'));
+ return true;
+}
+
+// ---- Sessions (sidebar-expanded set + IDE layout) ----
+
+function sessionKey(user: string): string {
+ return `ether:session:${user}`;
+}
+
+export interface RepoSession {
+ sidebarExpanded?: string[];
+ ideLayout?: unknown;
+ ideLayoutBase?: string;
+ [k: string]: unknown;
+}
+
+export function loadSession(user: string): RepoSession {
+ try {
+ const raw = localStorage.getItem(sessionKey(user));
+ return raw ? JSON.parse(raw) : {};
+ } catch {
+ return {};
+ }
+}
+
+export function saveSession(user: string, data: RepoSession): void {
+ localStorage.setItem(sessionKey(user), JSON.stringify(data, null, 2));
+}
+
+export function getSessionContent(user: string): string {
+ return JSON.stringify(loadSession(user), null, 2);
+}
+
+// ---- Profile data ----
+
+export interface ProfileSocial {
+ platform: string;
+ username: string;
+}
+
+export interface ProfileData {
+ displayName: string;
+ socials: ProfileSocial[];
+}
+
+export function loadProfile(user: string): ProfileData {
+ try {
+ const raw = localStorage.getItem(`ether:profile:${user}`);
+ if (raw) return JSON.parse(raw);
+ } catch {
+ /* ignore */
+ }
+ return {displayName: '', socials: []};
+}
+
+export function saveProfile(user: string, data: ProfileData): void {
+ localStorage.setItem(`ether:profile:${user}`, JSON.stringify(data));
+}
diff --git a/orbitmines.com/src/@ether/UI/pages/repository/userDefaults.tsx b/orbitmines.com/src/@ether/UI/pages/repository/userDefaults.tsx
new file mode 100644
index 00000000..9b1e7b39
--- /dev/null
+++ b/orbitmines.com/src/@ether/UI/pages/repository/userDefaults.tsx
@@ -0,0 +1,118 @@
+import React from 'react';
+import {PROFILES} from '../../../../routes/profiles/profiles';
+import type {TProfile, ExternalProfile} from '../../../../lib/organizations/ORGANIZATIONS';
+import {FadiShawkiBody} from '../../../../routes/profiles/fadi-shawki/FadiShawki';
+import {value} from '../../../../lib/post/Post';
+import {SOCIAL_PLATFORMS} from './icons';
+import type {ProfileSocial} from './storage';
+
+const PROFILE_BY_SLUG: Record = Object.values(PROFILES).reduce(
+ (acc, p) => {
+ if (p.profile) acc[p.profile] = p;
+ return acc;
+ },
+ {} as Record,
+);
+
+const USER_CONTENT: Record = {
+ 'fadi-shawki': (
+
+
+
+ ),
+};
+
+export function getUserContent(user: string): React.ReactNode | null {
+ return USER_CONTENT[user] ?? null;
+}
+
+export function getProfileDefaults(user: string): TProfile | null {
+ return PROFILE_BY_SLUG[user] ?? null;
+}
+
+const KNOWN_PLATFORM_IDS = new Set(SOCIAL_PLATFORMS.map((p) => p.id));
+
+const stripUsername = (raw: string | undefined, fallbackLink: string): string => {
+ const v = (raw ?? '').trim();
+ if (v) return v.replace(/^@+/, '');
+ try {
+ const u = new URL(fallbackLink);
+ const path = u.pathname.replace(/^\/+|\/+$/g, '');
+ return path.split('/').pop() ?? '';
+ } catch {
+ return '';
+ }
+};
+
+interface ProfileMetaProps {
+ profile: TProfile;
+ user: string;
+}
+
+export const ProfileMeta: React.FC = ({profile, user}) => {
+ const title = value(profile.title) ?? profile.name;
+ const description = value(profile.subtitle) ?? '';
+ const url = `https://orbitmines.com/@${user}`;
+ const picture = profile.picture;
+
+ return (
+ <>
+ <>
+ {title}
+
+ >
+ <>
+
+
+
+
+ {picture ? : null}
+ {picture ? : null}
+ {profile.first_name ? (
+
+ ) : null}
+ {profile.last_name ? (
+
+ ) : null}
+ {profile.profile ? (
+
+ ) : null}
+ >
+ <>
+
+
+
+ {picture ? : null}
+ >
+ <>
+
-
+ }])}} />
+ >
)
- const Twitter = () => (
+ const Twitter = () => (<>
- );
+ >);
return
-
+ <>
{value(title)}
-
+ >
@@ -168,7 +185,9 @@ export const renderPdfRendererElement: DereferencedElementRenderer = (element: E
return;
}
- if (['perspectiveOrigin', 'lineHeight', 'transformOrigin', 'flex', 'height'].includes(key))
+ if (['perspectiveOrigin', 'lineHeight', 'transformOrigin', 'flex'].includes(key))
+ return;
+ if (key === 'height' && tagName !== 'img')
return;
// ignore ad hoc styles
@@ -229,17 +248,39 @@ export const renderPdfRendererElement: DereferencedElementRenderer = (element: E
// } else if (['span'].includes(tagName)) {
// // @ts-ignore
// return
- // } else if (['div'].includes(tagName)) {
- // // @ts-ignore
- // return
- // }
+
+ const src = initialProps.src as string | undefined;
+ if (!src) {
+ // @ts-ignore
+ return
+ }
+ const resolvedSrc = (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('data:'))
+ ? src
+ : `${window.location.origin}${src.startsWith('/') ? '' : '/'}${src}`;
+ // react-pdf only supports PNG, JPG, TIFF — skip SVGs and other unsupported formats
+ if (resolvedSrc.startsWith('data:image/')) {
+ if (!(resolvedSrc.startsWith('data:image/png') || resolvedSrc.startsWith('data:image/jpeg') || resolvedSrc.startsWith('data:image/tiff'))) {
+ // @ts-ignore
+ return
+ }
+ } else {
+ const ext = resolvedSrc.split(/[?#]/)[0].split('.').pop()?.toLowerCase() ?? '';
+ if (!['png', 'jpg', 'jpeg', 'tiff', 'tif'].includes(ext)) {
+ // Try PNG fallback for SVG images (react-pdf doesn't support SVG in Image)
+ if (ext === 'svg') {
+ const pngSrc = resolvedSrc.replace(/\.svg(\?|#|$)/, '.png$1');
+ // @ts-ignore
+ return
+ }
+ // @ts-ignore
+ return
+ }
+ }
// @ts-ignore
- return
+ return
} else if (['canvas'].includes(tagName)) {
if (props.style.backgroundImage.startsWith('url(')) {
const url = props.style.backgroundImage.replace(/^url\("/, '').replace(/"\)$/, '');
- console.log(url)
- console.log(props)
return
// TODO FIX
}
@@ -302,7 +343,9 @@ export const ExportablePaper = (paper: PaperProps) => {
pdf.fonts?.forEach(registerFont);
- const content =
;
+ const content =
+
+ ;
if (!dereferenced || generate === 'dereferenced_html')
return
{
@@ -614,11 +674,12 @@ export const Grid = (props: React.HTMLAttributes & {
fluid?: boolean
tagName?: string
}) => {
- return React.createElement(props.tagName ?? 'div', {
- ...props,
+ const { fluid, tagName, className, ...rest } = props;
+ return React.createElement(tagName ?? 'div', {
+ ...rest,
className: classNames(
- props.fluid ? 'container-fluid' : 'container',
- props.className,
+ fluid ? 'container-fluid' : 'container',
+ className,
)
});
}
@@ -718,11 +779,17 @@ export const highlight = (code: string) => (
{({className, style, tokens, getLineProps, getTokenProps}) => (
<>
- {tokens.map((line, i) => (
-
- {line.map((token, key) =>
)}
+ {tokens.map((line, i) => {
+ const lp = getLineProps({line}) as any;
+ return (
+
+ {line.map((token, ti) => {
+ const tp = getTokenProps({token}) as any;
+ return {tp.children};
+ })}
- ))}
+ );
+ })}
>
)}
@@ -736,8 +803,9 @@ export const Block = ({children, className, style = {}, ...props}: Children & Re
return (
{children}
@@ -771,7 +839,7 @@ export const Category = (props: {
}
const inline_item = () =>
- {content.map((item, index) => - )}
+ {content.map((item, index) =>
- )}
if (inline)
@@ -820,12 +888,58 @@ export const Layer = ({zIndex, children, ...props}: any) => {
;
}
+const DebouncedSearch = ({delay = 350}: {delay?: number}) => {
+ const [params, setParams] = useSearchParams();
+ const urlSearch = params.get('search') ?? '';
+ const [value, setValue] = useState(urlSearch);
+ const timer = useRef | null>(null);
+ const lastWritten = useRef(urlSearch);
+
+ useEffect(() => {
+ if (urlSearch !== lastWritten.current) {
+ lastWritten.current = urlSearch;
+ setValue(urlSearch);
+ }
+ }, [urlSearch]);
+
+ useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
+
+ const commit = (next: string) => {
+ lastWritten.current = next;
+ setParams(prev => {
+ const p = new URLSearchParams(prev);
+ if (next) p.set('search', next); else p.delete('search');
+ return p;
+ });
+ };
+
+ return }
+ placeholder="Search"
+ value={value}
+ onChange={(e) => {
+ const next = e.target.value;
+ setValue(next);
+ if (timer.current) clearTimeout(timer.current);
+ timer.current = setTimeout(() => commit(next), delay);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ if (timer.current) clearTimeout(timer.current);
+ commit(value);
+ }
+ }}
+ />;
+};
+
export const Exports = (
{paper, children}: { paper: PaperProps } & Children
) => {
const location = useLocation();
const navigate = useNavigate();
- const [params] = useSearchParams();
+ const [params, setParams] = useSearchParams();
const ref = useRef(null);
@@ -855,21 +969,26 @@ export const Exports = (
});
}, [ref]);
- return
+ return
{/* TODO: */}
-
+
{children}
@@ -897,7 +1016,7 @@ export const FootnoteContent = (props: FootnoteProps & { goto?: JSX.Element } &
const { index, children } = props;
const goto = () => {
- const element =document.getElementById(`footnote-${index}`)
+ const element = document.getElementById(`footnote-${index}`)
window.scrollTo({
top: element.getBoundingClientRect().top + window.scrollY - 100,
@@ -998,7 +1117,7 @@ const RefIcon = ({organization}: {organization: TOrganization}) => {
return <>>
}
-export const Reference = (props: { reference?: ReferenceProps, target?: string } & React.HTMLAttributes
& RowProps & ReferenceStyle & FootnoteProps) => {
+export const Reference = (props: { reference?: ReferenceProps, target?: string, dark?: boolean } & React.HTMLAttributes & RowProps & ReferenceStyle & FootnoteProps) => {
const {
reference,
target = "_blank",
@@ -1014,6 +1133,7 @@ export const Reference = (props: { reference?: ReferenceProps, target?: string }
className,
style,
+ dark,
...otherProps
} = props;
@@ -1052,7 +1172,7 @@ export const Reference = (props: { reference?: ReferenceProps, target?: string }
[
{index}
{link ? <>
- {(organizations ?? []).map(organization => )}
+ {(organizations ?? []).map(organization => )}
{/*{link.startsWith('https://github.com') ? : <>>}*/}
{/*{link.startsWith('/') ? : <>>}*/}
> : <>>}
@@ -1089,14 +1209,15 @@ export const Reference = (props: { reference?: ReferenceProps, target?: string }
{React.createElement(link ? 'a' : 'span', {
...(link ? { href: link.replace("https://orbitmines.com", ""), target } : { }),
className: classNames('child-mr-3', className),
+ style: dark ? { color: '#5d7eac' } : {},
children: <>
- {(organizations ?? []).map(organization => )}
+ {(organizations ?? []).map(organization => )}
>
})}
-
+
{_.compact([(year || date) ? `${date ?? year}.` : '', author]).join(' ')}
@@ -1134,9 +1255,9 @@ export const Browser = ({ paper }: { paper: PaperProps }) => {
);
};
-export const Title = ({children}: Children) => {
+export const Title = ({children, ...props}: Children & any) => {
return
- {children}
+ {children}
;
}
@@ -1163,22 +1284,21 @@ export const PaperHeader = (props: PaperProps) => {
authors
} = props;
+ const profiledOrganizations = (organizations ?? []).filter((organization) => (organization as any).profile);
+
return <>
{subtitle ? : <>>}
-
- {organizations ? <>
- {organizations.map((organization) => (
-
- ))}
-
-
-
-
- > : <>>}
+
+ {profiledOrganizations.map((organization) => {
+ const { key: orgKey, ...rest } = organization as any;
+ return
+
+ ;
+ })}
- {(authors || []).map((author) => (
+ {(authors || []).map((author, i) => (
))}
@@ -1201,9 +1321,58 @@ export const PaperHeader = (props: PaperProps) => {
export const PaperContent = (props: PaperProps) => {
let generate;
- try {
- const [params] = useSearchParams();
+ const [params, setParams] = useSearchParams();
+ // Memoised: .slugs() deep-traverses the whole book; don't re-walk it on every
+ // re-render (search keystrokes, etc.). props.children is stable per content.
+ const sectionSlugs = useMemo(() => new BookUtil(props).slugs(), [props.children]);
+ const {currentSlug, navigateSection} = useSectionPath(sectionSlugs);
+ const navigationParam = params.get('navigation');
+ const desktopNavigation = navigationParam !== 'false';
+
+ const [mobileNavExpanded, setMobileNavExpanded] = useState(false);
+
+ const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' ? window.matchMedia('(max-width: 575px)').matches : false);
+ useEffect(() => {
+ const mql = window.matchMedia('(max-width: 575px)');
+ const handler = (e: MediaQueryListEvent) => {
+ setIsMobile(e.matches);
+ if (e.matches) setMobileNavExpanded(false);
+ };
+ mql.addEventListener('change', handler);
+ return () => mql.removeEventListener('change', handler);
+ }, []);
+
+ useEffect(() => {
+ if (mobileNavExpanded) {
+ document.body.style.overflow = 'hidden';
+ return () => { document.body.style.overflow = ''; };
+ }
+ }, [mobileNavExpanded]);
+
+ const navigation = isMobile ? mobileNavExpanded : desktopNavigation;
+
+ const toggleNavigation = () => {
+ if (isMobile) {
+ setMobileNavExpanded(prev => !prev);
+ } else {
+ setParams(prev => {
+ const next = new URLSearchParams(prev);
+ if (desktopNavigation) {
+ next.set('navigation', 'false');
+ } else {
+ next.delete('navigation');
+ }
+ return next;
+ });
+ }
+ };
+
+ const section = currentSlug;
+ const isStartPage: boolean = (section ?? "").length == 0
+ const isSearching: boolean = (params.get('search') ?? "").length > 0
+
+ try {
generate = params.get('generate');
} catch (e) {
generate = 'pdf';
@@ -1212,13 +1381,26 @@ export const PaperContent = (props: PaperProps) => {
if (generate === 'thumbnail')
return
- const {header, children, external, exclude_footnotes } = props;
+ let {book, header, children, external, exclude_footnotes } = props;
const {discord} = external || {};
const external_links = !!discord;
- const Content = <>
+ const util = new BookUtil(props, currentSlug)
+
+ const Content = book && (!isStartPage || isSearching) ? <>
+
+
+
+
+
+ {util.next() ? navigateSection(util.nextSection())} /> : null}
+
+
+
+
+ > : <>
{props.head ? <>
{props.head}
{children}
@@ -1231,31 +1413,68 @@ export const PaperContent = (props: PaperProps) => {
: <>>}
: <>>}
- {header ? header : }
+ {header ? header : (book ? : )}
- {children}
+ {book ? : children}
-
+ {book ? : }
>}
>
- const footnotes = getFootnotes(Content);
+ const isPdf = generate === 'button' || generate === 'pdf';
+ const footnotes = (book && !isPdf)
+ ? (!isStartPage && !isSearching ? getFootnotes(util.getContentChildren(util.current())) : [])
+ : getFootnotes(Content);
+ if (footnotes.length == 0)
+ exclude_footnotes = true
+
+
+ const notGenerate = generate !== 'button' && generate !== 'pdf';
+ const showSidebar = book && navigation && !isMobile && notGenerate;
+
+ return
+ {/* Book layout is capped at 1650px, so center it; non-book posts fill the
+ viewport via a 100vw inner Grid and must stay left-anchored. */}
+
+ {book && !isStartPage && isMobile && mobileNavExpanded && notGenerate ? <>
+ setMobileNavExpanded(false)} style={{position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', zIndex: 19}} />
+
+ setMobileNavExpanded(false)} />
+
+ > : <>>}
- return
- {Content}
+ {showSidebar ?
+
+ : <>>}
+
+
+
+ {Content}
+
+ {book && isStartPage && isMobile && notGenerate ? : <>>}
- {!exclude_footnotes ? : <>>}
-
+ {!exclude_footnotes ? : <>>}
+
+
+
+
}
+// Inline "jump to §X" button for use *inside* paper content. Reads the
+// section navigator from context so it lives wherever the content renders.
+export const SectionButton = ({section, ...buttonProps}: {section: string} & React.ComponentProps
) => {
+ const navigateSection = useSectionNav();
+ return navigateSection(section)} />;
+};
+
export type SectionProps = {
head?: ReactNode
}
@@ -1281,16 +1500,18 @@ export const Section = ({ head, sub, children }: SectionProps & Children & { sub
export const Paragraph = ({ children }: Children & { block?: boolean }): JSX.Element => {
const blocks: JSX.Element[] = [];
let currentBlock: JSX.Element[] = [];
+ let blockIdx = 0;
// little nasty regrouping into inline blocks
const pushCurrentBlock = () => {
- blocks.push({currentBlock}
);
+ blocks.push({currentBlock}
);
currentBlock = [];
}
let block = true;
+ let inlineIdx = 0;
- React.Children.forEach(children, child => {
+ React.Children.forEach(children, (child, ci) => {
const inline = (_.isString(child)
|| (child as any)?.props?.is === 'reference' // TODO THROUGH PROPS
|| (child as any)?.props?.is === 'footnote' // TODO THROUGH PROPS
@@ -1298,33 +1519,56 @@ export const Paragraph = ({ children }: Children & { block?: boolean }): JSX.Ele
if (!inline) {
pushCurrentBlock();
- blocks.push({child}
);
+ blocks.push({child}
);
return;
}
block = false;
- currentBlock.push({child});
+ currentBlock.push({child});
});
pushCurrentBlock();
if (block)
- return {blocks}
+ return {blocks}
- return
+ return