simian 0.2.1

A command-line tool for exploring and implementing Machine Learning algorithms in Rust.
import { useState, useEffect } from 'react'
import { ChevronLeft, Loader2, Moon, Sun, History } from 'lucide-react'
import { Link } from 'wouter'
import { useTheme } from 'next-themes'
import { Editor } from '@/editor'
import { title } from '@/editor/addon/title'
import { text } from '@/editor/addon/text'
import { paragraph } from '@/editor/addon/paragraph'
import { codeBlock } from '@/editor/addon/code-block'
import { root } from '@/editor/addon/root'
import { subtitle } from '@/editor/addon/subtitle'
import { heading } from '@/editor/addon/heading'
import { column } from '@/editor/addon/column'
import { imageBlock } from '@/editor/addon/image-block'
import { latexBlock } from '@/editor/addon/latex-block'
import { latexInline } from '@/editor/addon/latex-inline'
import { slash } from '@/editor/addon/slash'
import { cn } from '@/lib/utils'

let saveTimeout: ReturnType<typeof setTimeout>

function ThemeToggle() {
  const { resolvedTheme, setTheme } = useTheme()
  const [mounted, setMounted] = useState(false)

  useEffect(() => {
    const timer = setTimeout(() => setMounted(true), 0)
    return () => clearTimeout(timer)
  }, [])

  if (!mounted) return <div className="w-9 h-9" />

  return (
    <button
      onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
      className="p-2 rounded-full hover:bg-black/10 dark:hover:bg-white/10 transition-colors text-muted-foreground hover:text-foreground"
      title="Toggle Theme"
    >
      {resolvedTheme === 'dark' ? <Sun size={20} /> : <Moon size={20} />}
    </button>
  )
}

function AuthorBadge({ authorId }: { authorId: number }) {
  const [handle, setHandle] = useState<string | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(false)

  useEffect(() => {
    fetch(`https://api.github.com/user/${authorId}`)
      .then((res) => {
        if (!res.ok) throw new Error('Failed to fetch')
        return res.json()
      })
      .then((data) => {
        if (data.login) {
          setHandle(data.login)
        } else {
          setError(true)
        }
      })
      .catch(() => setError(true))
      .finally(() => setLoading(false))
  }, [authorId])

  if (loading) {
    return (
      <div className="flex items-center gap-2 px-2 py-1 bg-black/5 dark:bg-white/5 rounded-full border border-transparent">
        <div className="w-6 h-6 rounded-full bg-black/10 dark:bg-white/10 animate-pulse" />
        <div className="h-4 w-16 bg-black/10 dark:bg-white/10 rounded animate-pulse" />
      </div>
    )
  }

  if (error || !handle) {
    return (
      <div className="flex items-center gap-2 px-2 py-1 bg-red-500/10 text-red-500 rounded-full text-xs font-mono">
        <div className="w-6 h-6 rounded-full bg-red-500/20 flex items-center justify-center">
          !
        </div>
        <span>Unknown</span>
      </div>
    )
  }

  return (
    <a
      href={`https://github.com/${handle}`}
      target="_blank"
      rel="noopener noreferrer"
      className="flex items-center gap-2 px-2 py-1 bg-black/5 dark:bg-white/10 hover:bg-black/10 dark:hover:bg-white/20 transition-colors rounded-full text-xs font-mono text-zinc-700 dark:text-zinc-300 border border-black/5 dark:border-white/10"
    >
      <img
        src={`https://avatars.githubusercontent.com/u/${authorId}?v=4`}
        alt={`${handle} avatar`}
        className="w-6 h-6 rounded-full object-cover"
      />
      <span>@{handle}</span>
    </a>
  )
}

import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from '@/components/ui/tooltip'
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select'

function ApproversFacepile({ approvers }: { approvers: number[] }) {
  if (!approvers || approvers.length === 0) return null

  return (
    <div className="flex items-center">
      {approvers.map((approverId, i) => (
        <ApproverAvatar key={approverId} approverId={approverId} index={i} />
      ))}
    </div>
  )
}

function ApproverAvatar({
  approverId,
  index,
}: {
  approverId: number
  index: number
}) {
  const [handle, setHandle] = useState<string | null>(null)

  useEffect(() => {
    fetch(`https://api.github.com/user/${approverId}`)
      .then((res) => res.json())
      .then((data) => {
        if (data.login) setHandle(data.login)
      })
      .catch(() => {})
  }, [approverId])

  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <a
          href={`https://github.com/${handle || approverId}`}
          target="_blank"
          rel="noopener noreferrer"
          className={cn(
            'w-7 h-7 rounded-full border-2 border-white dark:border-[#0d1117] overflow-hidden bg-muted transition-transform hover:z-10 hover:scale-110',
            index > 0 && '-ml-2',
          )}
        >
          <img
            src={`https://avatars.githubusercontent.com/u/${approverId}?v=4`}
            alt={`${handle || approverId} avatar`}
            className="w-full h-full object-cover"
          />
        </a>
      </TooltipTrigger>
      <TooltipContent>
        <p className="text-xs font-mono">
          {handle ? `@${handle}` : 'Loading...'}
        </p>
      </TooltipContent>
    </Tooltip>
  )
}

export function EditorPage({ id }: { id: string }) {
  const [value, setValue] = useState<any>(null)
  const [loading, setLoading] = useState(true)
  const [mode, setMode] = useState<'read' | 'write'>('write')
  const [authors, setAuthors] = useState<number[]>([])
  const [approvers, setApprovers] = useState<number[]>([])
  const [publishedAt, setPublishedAt] = useState<string | null>(null)
  const [submittedAt, setSubmittedAt] = useState<string | null>(null)
  const [versions, setVersions] = useState<any[]>([])

  useEffect(() => {
    if (window.__SIMIAN_PAPER_DATA__) {
      const data = window.__SIMIAN_PAPER_DATA__
      const metadata = window.__SIMIAN_PAPER_METADATA__
      setTimeout(() => {
        setValue(data)
        setMode('read')
        if (metadata?.authors) {
          setAuthors(metadata.authors)
        }
        if (metadata?.approvers) {
          setApprovers(metadata.approvers)
        }
        if (metadata?.publishedAt) {
          setPublishedAt(metadata.publishedAt)
        }
        if (metadata?.submittedAt) {
          setSubmittedAt(metadata.submittedAt)
        }
        if (metadata?.mockVersions) {
          setVersions(metadata.mockVersions)
        } else if (metadata?.publishedAt || metadata?.submittedAt) {
          fetch(
            `https://api.github.com/repos/nosebit/simian-papers/commits?path=published/${id}/index.html`,
          )
            .then((r) => r.json())
            .then((data) => {
              if (Array.isArray(data)) setVersions(data)
            })
            .catch(() => {})
        }
        setLoading(false)
      }, 0)
      return
    }

    fetch(`/api/paper/${id}/content`)
      .then((res) => res.json())
      .then((data) => {
        if (data && data.length > 0) {
          setValue(data)
        }
      })
      .catch((e) => console.error('Failed to load paper content', e))
      .finally(() => setLoading(false))
  }, [id])

  const onChange = (newValue: any) => {
    if (saveTimeout) clearTimeout(saveTimeout)
    saveTimeout = setTimeout(() => {
      fetch(`/api/paper/${id}/content`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newValue),
      }).catch((e) => console.error('Failed to save', e))
    }, 2000)
  }

  const addons = [
    root(),
    title(),
    subtitle(),
    text(),
    paragraph({ emptyPlaceholder: 'Type / for commands' }),
    heading(),
    column(),
    latexBlock(),
    latexInline(),
    codeBlock(),
    imageBlock({
      fileUploadAction: async ({ file }: { file: File }) => {
        const formData = new FormData()
        formData.append('file', file)

        const res = await fetch(`/api/paper/${id}/assets`, {
          method: 'POST',
          body: formData,
        })

        if (!res.ok) {
          throw new Error('Upload failed')
        }

        const data = await res.json()
        return { data: { id: data.url, url: data.url, mime: file.type } }
      },
    }),
    slash(),
  ]

  const isBundled = !!window.__SIMIAN_PAPER_DATA__

  return (
    <div className="flex flex-col h-full min-h-screen">
      <header className="fixed top-0 left-0 w-full z-50 flex items-center justify-between p-4 md:px-8 bg-white dark:bg-[#0d1117]">
        <div className="flex items-center gap-4">
          {!isBundled && (
            <Link href="/">
              <button className="p-2 -ml-2 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors text-muted-foreground hover:text-foreground">
                <ChevronLeft size={20} />
              </button>
            </Link>
          )}
          <span className="text-sm font-mono text-muted-foreground">{id}</span>
        </div>
        <div>
          <ThemeToggle />
        </div>
      </header>

      {loading ? (
        <div className="flex items-center justify-center p-12 mt-24 text-muted-foreground">
          <Loader2 className="animate-spin mr-2" size={20} /> Loading
          workspace...
        </div>
      ) : (
        <div className="flex-1 w-full max-w-4xl mx-auto mt-24 pb-64 px-8 text-base">
          {mode === 'read' &&
            (authors.length > 0 ||
              approvers.length > 0 ||
              publishedAt ||
              submittedAt) && (
              <div className="mb-8 pl-2">
                {(authors.length > 0 || approvers.length > 0) && (
                  <div className="flex flex-wrap gap-2 mb-2 relative z-10 items-center">
                    {authors.map((authorId) => (
                      <AuthorBadge key={authorId} authorId={authorId} />
                    ))}
                    {approvers.length > 0 && (
                      <div className="ml-2">
                        <ApproversFacepile approvers={approvers} />
                      </div>
                    )}
                  </div>
                )}
                <div className="flex items-center gap-4">
                  {(publishedAt || submittedAt) && (
                    <div className="text-xs text-muted-foreground font-mono">
                      {publishedAt ? 'Published on ' : 'Submitted on '}
                      {new Date(publishedAt || submittedAt!).toLocaleDateString(
                        undefined,
                        { year: 'numeric', month: 'long', day: 'numeric' },
                      )}
                    </div>
                  )}
                  {versions.length > 1 && (
                    <Select
                      value={
                        window.location.pathname.match(/-v(\d+)\/?$/)?.[1] ||
                        versions.length.toString()
                      }
                      onValueChange={(val) => {
                        window.location.href = `/${id}-v${val}/`
                      }}
                    >
                      <SelectTrigger className="h-7 px-2 py-1 text-xs font-mono w-auto min-w-[110px] border-black/10 dark:border-white/10 shadow-none bg-transparent">
                        <div className="flex items-center gap-1.5 text-muted-foreground">
                          <History size={14} className="shrink-0" />
                          <span>
                            <SelectValue />
                          </span>
                        </div>
                      </SelectTrigger>
                      <SelectContent className="font-mono text-xs max-h-64">
                        {versions.map((commit, i) => {
                          const ver = versions.length - i
                          const date = new Date(
                            commit.commit?.author?.date,
                          ).toLocaleDateString()
                          return (
                            <SelectItem key={commit.sha} value={ver.toString()}>
                              Version {ver}{' '}
                              <span className="text-muted-foreground ml-2 text-[10px]">
                                {date}
                              </span>
                            </SelectItem>
                          )
                        })}
                      </SelectContent>
                    </Select>
                  )}
                </div>
              </div>
            )}
          <Editor
            addons={addons}
            initialValue={value}
            onContentChange={onChange}
            mode={mode}
          />
        </div>
      )}
    </div>
  )
}