Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: outline doc pusher #9

Merged
merged 9 commits into from
Feb 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/cold-suns-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"coda-mover": patch
---

Outline importer:
- Outline pusher and APIs for importing synced docs, pages into Outline
- UI for selecting docs and pages, importing form
- Enhancement for item listing UI
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
"dependencies": {
"@abxvn/tasks": "^1.1.3",
"axios": "^1.6.5",
"classnames": "^2.5.1",
"form-data": "^4.0.0",
"fs-extra": "^11.2.0",
"next": "^14.1.0",
"react": "^18.2.0",
Expand Down
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const inter = Inter({ subsets: ['latin', 'vietnamese'] })

export const metadata: Metadata = {
title: 'Coda Mover',
description: 'Coda contents migration made simple',
description: 'Contents migration made simple',
}

export default function RootLayout ({
Expand All @@ -17,12 +17,12 @@ export default function RootLayout ({
}>) {
return (
<html lang='en'>
<body className={inter.className}>
<header className='p-4'>
<body className={`flex flex-col md:flex-row max-h-screen min-h-screen ${inter.className}`}>
<header className='p-4 md:basis-48 md:shrink-0'>
<h1 className='mb-1'>{metadata.title as string}</h1>
<p className='text-zinc-600'>{metadata.description!}</p>
<p className='text-zinc-600 pb-0 m-0'>{metadata.description!}</p>
</header>
<main className='px-4'>
<main className='flex flex-col overflow-hidden grow md:pt-4'>
{children}
</main>
</body>
Expand Down
9 changes: 8 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
'use client'

import { CodaDocPuller } from '@/modules/coda-doc-puller'
import { MoverClientProvider } from '@/modules/mover/client'
import { OutlineDocPusher } from '@/modules/outline-doc-pusher/OutlineDocPusher'

export default function Home () {
return (
<CodaDocPuller />
<MoverClientProvider>
<CodaDocPuller />
<OutlineDocPusher />
</MoverClientProvider>
)
}
20 changes: 8 additions & 12 deletions src/modules/coda-doc-puller/CodaDocList.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,23 @@
import type { HTMLAttributes } from 'react'
import { CLIENT_SYNC_DOCS, type ICodaItems, type IItemStatuses } from '../mover/client'
import { CLIENT_SYNC_DOCS, useClient } from '../mover/client'
import { CodaItem } from './CodaItem'

export interface ICodaDocListProps extends HTMLAttributes<HTMLElement> {
items: ICodaItems
statuses: IItemStatuses
}
export interface ICodaDocListProps extends HTMLAttributes<HTMLElement> {}

export function CodaDocList ({ items, className, statuses }: ICodaDocListProps) {
export function CodaDocList ({ className }: ICodaDocListProps) {
const { items, itemStatuses } = useClient()
const docs = items.filter(item => item.treePath === '/')
const isLoading = statuses[CLIENT_SYNC_DOCS]?.status !== 'done'
const isLoading = itemStatuses[CLIENT_SYNC_DOCS]?.status !== 'done'

return (
<section className={`menu-section ${className}`}>
<section className={`menu-section flex flex-col overflow-hidden ${className}`}>
{!isLoading && (
<span className='menu-title'>
Found {docs.length} docs and {items.length - docs.length} pages
</span>
)}
<menu className='menu-items gap-1'>
{docs.map(doc => (
<CodaItem key={doc.id} data={doc} items={items} statuses={statuses} />
))}
<menu className='menu-items gap-1 overflow-y-auto'>
{docs.map(doc => (<CodaItem key={doc.id} data={doc} />))}
</menu>
</section>
)
Expand Down
74 changes: 4 additions & 70 deletions src/modules/coda-doc-puller/CodaDocPuller.tsx
Original file line number Diff line number Diff line change
@@ -1,79 +1,13 @@
'use client'

import { CodaDocList } from './CodaDocList'
import { useEffect, useState } from 'react'
import { MoverClient, CLIENT_SYNC_DOCS, type IItemStatuses, type ICodaItems } from '../mover/client'
import { CodaDocPullerForm } from './CodaDocPullerForm'

export function CodaDocPuller () {
const [apiToken, setApiToken] = useState('')
const [items, setItems] = useState<ICodaItems>([])
const [itemStatuses, setItemStatuses] = useState<IItemStatuses>({})
const [isConnected, setIsConnected] = useState(false)
const [mover, setMover] = useState<MoverClient | null>(null)
const isSyncingDocs = itemStatuses[CLIENT_SYNC_DOCS] && itemStatuses[CLIENT_SYNC_DOCS].status === 'listing'
const isPullButtonDisabled = !apiToken || !isConnected || isSyncingDocs
let message: string | undefined

if (!apiToken) {
message = 'Please provide Coda API token'
} else if (!isConnected) {
message = 'Please wait for connection'
} else if (itemStatuses[CLIENT_SYNC_DOCS] && itemStatuses[CLIENT_SYNC_DOCS].status === 'error') {
message = itemStatuses[CLIENT_SYNC_DOCS].message
}

useEffect(() => {
if (mover) return

// Ensure mover server started
fetch('/api/mover').then(() => {
const client = new MoverClient()

client.handleServerResponses({
onConnection: state => setIsConnected(state === 'opened'),
onItems: items => setItems(items),
onStatuses: itemStatuses => setItemStatuses(itemStatuses),
})

setMover(client)
}).catch(err => {
// TODO: show error to UI
console.error(err)
})
}, []) // eslint-disable-line react-hooks/exhaustive-deps

return (
<div className='coda-doc-puller'>
<form className='flex items-end gap-3'>
<div className='form-field'>
<label className='form-label'>Coda API Token</label>
<div className='form-control relative w-full'>
<input
name='apiToken'
placeholder='Type here'
type='password'
className='input max-w-full'
value={apiToken}
onChange={ev => setApiToken(ev.target.value)}
/>
</div>
</div>
<div className='form-field'>
<div className='form-control justify-between'>
<button
type='button'
className='btn btn-primary w-full hover:bg-indigo-600 cursor-pointer!'
disabled={isPullButtonDisabled}
onClick={() => apiToken && mover?.syncDocs(apiToken)}
>Pull
</button>
</div>
</div>
</form>
{message && (
<div className='mt-3 text-indigo-600'>ℹ <span className='text-sm'>{message}</span></div>
)}
<CodaDocList items={items} className='mt-3' statuses={itemStatuses} />
<div className='coda-doc-puller flex flex-col overflow-hidden px-4 pb-2'>
<CodaDocPullerForm />
<CodaDocList className='mt-3' />
</div>
)
}
58 changes: 58 additions & 0 deletions src/modules/coda-doc-puller/CodaDocPullerForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useState, type HTMLAttributes } from 'react'
import { useClient } from '../mover/MoverClientContext'
import { CLIENT_SYNC_DOCS } from '../mover/events'

export interface ICodaDocPullerFormProps extends HTMLAttributes<HTMLFormElement> {
}

export function CodaDocPullerForm ({ className }: ICodaDocPullerFormProps) {
const [apiToken, setApiToken] = useState('')
const { isConnected, itemStatuses, isSyncingDocs, syncDocs, selectedItemIds } = useClient()
const isDocListingError = itemStatuses?.[CLIENT_SYNC_DOCS]?.status === 'error'
const hasSelectedItems = selectedItemIds.length > 0
const isFormDisabled = !isConnected || isSyncingDocs || hasSelectedItems
const isPullButtonDisabled = !apiToken || isFormDisabled

let message: string | undefined

if (!apiToken) {
message = 'Please provide Coda API token'
} else if (!isConnected) {
message = 'Please wait for connection'
} else if (isDocListingError) {
message = itemStatuses[CLIENT_SYNC_DOCS].message
}

return (
<form className={`flex items-end flex-wrap gap-3 ${className}`}>
<div className='form-field'>
<label className='form-label'>Coda API Token</label>
<div className='form-control relative w-full'>
<input
name='apiToken'
placeholder='Type here'
type='password'
className='input max-w-full focus:ring-1 ring-indigo-500'
value={apiToken}
onChange={ev => setApiToken(ev.target.value)}
disabled={isFormDisabled}
/>
</div>
</div>
<div className='form-field'>
<div className='form-control justify-between'>
<button
type='button'
className='btn btn-primary w-full hover:bg-indigo-600 cursor-pointer!'
disabled={isPullButtonDisabled}
onClick={() => apiToken && syncDocs(apiToken)}
>Pull
</button>
</div>
</div>
{message && (
<div className='basis-full text-indigo-600'>ℹ <span className='text-sm'>{message}</span></div>
)}
</form>
)
}
40 changes: 26 additions & 14 deletions src/modules/coda-doc-puller/CodaItem.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import type { HTMLAttributes } from 'react'
import type { ICodaItem, ICodaItems, IItemStatuses } from '../mover/client'
import { useClient, type ICodaItem } from '../mover/client'
import { CodaItemStatus } from './CodaItemStatus'

export interface ICodaItemProps extends HTMLAttributes<HTMLLIElement> {
data: ICodaItem
items: ICodaItems
statuses: IItemStatuses
}

export function CodaItem ({ data, items, statuses }: ICodaItemProps) {
export function CodaItem ({ data }: ICodaItemProps) {
const { select, deselect, items, itemStatuses: statuses, selectedItemIds } = useClient()
const innerPages = items.filter(item => item.treePath === `${data.treePath}${data.id}/`)
const hasInnerPages = innerPages.length > 0
const recursiveInnerPages = items.filter(item => item.treePath.startsWith(`${data.treePath}${data.id}/`))
const recursiveInnerPageCount = recursiveInnerPages.length
const isSelected = selectedItemIds.includes(data.id)
const itemStatus = statuses[data.id]
const innerPageStatuses = innerPages.map(page => statuses[page.id]?.status).flat()
const innerPageStatuses = recursiveInnerPages.map(page => statuses[page.id]?.status).flat()
const message = itemStatus?.message
let status = itemStatus?.status

Expand All @@ -27,24 +29,34 @@ export function CodaItem ({ data, items, statuses }: ICodaItemProps) {
return (
<li key={data.id} data-id={data.id} className='flex items-center flex-wrap'>
{hasInnerPages && <input type='checkbox' id={`toggle--${data.id}`} className='menu-toggle [&:defaultChecked~.flex>.menu-icon]:-rotate-90' />}
<input type='checkbox' className='checkbox' />
<label className='menu-item menu-item-no-animation basis-4/5 overflow-hidden justify-between grow px-3 ml-2' htmlFor={`toggle--${data.id}`}>
<input
type='checkbox'
className='checkbox'
checked={isSelected}
onChange={() => isSelected ? deselect(data.id) : select(data.id)}
/>
<label className='menu-item menu-item-no-animation basis-4/5 md:basis-11/12 overflow-hidden justify-between grow px-3 ml-2' htmlFor={`toggle--${data.id}`}>
<span className='text-ellipsis whitespace-nowrap overflow-hidden grow'>{data.name}</span>
<CodaItemStatus status={status} message={message} />
{hasInnerPages && (
<span className='menu-icon'>
<svg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' strokeWidth='1.5' className='w-4 h-4 stroke-content3'>
<path strokeLinecap='round' strokeLinejoin='round' d='M8.25 4.5l7.5 7.5-7.5 7.5' />
</svg>
</span>
<>
<span className='tooltip tooltip-left' data-tooltip={`${recursiveInnerPageCount} pages`}>
<span className='badge text-zinc-500'>{recursiveInnerPageCount}</span>
</span>
<span className='menu-icon'>
<svg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' strokeWidth='1.5' className='w-4 h-4 stroke-content3'>
<path strokeLinecap='round' strokeLinejoin='round' d='M8.25 4.5l7.5 7.5-7.5 7.5' />
</svg>
</span>
</>
)}
</label>

{hasInnerPages && (
<div className='menu-item-collapse pl-4 bg-slate-200/70 grow'>
<ul className='min-h-0'>
<ul className='min-h-0 overflow-hidden'>
{innerPages.map(page => (
<CodaItem key={page.id} data={page} items={items} statuses={statuses} />
<CodaItem key={page.id} data={page} />
))}
</ul>
</div>
Expand Down
9 changes: 8 additions & 1 deletion src/modules/coda-doc-puller/CodaItemStatus.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
export function CodaItemStatus ({ status, message }: { status?: string, message?: string }) {
if (!status || status === 'done') return null
const isError = status.includes('error')

return (
<span className='text-indigo-400 animate-pulse' title={message}>{status}</span>
<span className={message ? 'tooltip tooltip-left' : ''} data-tooltip={message}>
<span
className={`badge animate-pulse ${isError ? 'badge-error' : 'badge-primary'}`} title={message}
>
{status}
</span>
</span>
)
}
Loading
Loading