├── .tool-versions
├── vercel.json
├── public
├── screenshot.png
└── favicon.svg
├── postcss.config.js
├── src
├── types
│ ├── index.d.ts
│ └── nextauth.d.ts
├── icons
│ ├── square.svg
│ ├── save.svg
│ ├── github.svg
│ ├── arena-mark.svg
│ └── amux.svg
├── lib
│ ├── getErrorMessage.ts
│ └── mosaic.ts
├── components
│ ├── WindowFooter.tsx
│ ├── ZeroState.tsx
│ ├── WindowScroller.tsx
│ ├── Spinner.tsx
│ ├── BlockDragOverlay.tsx
│ ├── AuthButton.tsx
│ ├── BlockActions.tsx
│ ├── BlocksList.tsx
│ ├── Welcome.tsx
│ ├── BlocksGrid.tsx
│ ├── UserMenu.tsx
│ ├── Header.tsx
│ ├── Dialog.tsx
│ ├── BlockDndWrapper.tsx
│ ├── BlocksListItem.tsx
│ ├── BlockConnnections.tsx
│ ├── BlockInfo.tsx
│ ├── BlockContainer.tsx
│ ├── ChannelLoader.tsx
│ ├── WindowToolbar.tsx
│ ├── BlocksGridItem.tsx
│ ├── Info.tsx
│ ├── ChannelCreator.tsx
│ ├── BlockViewer.tsx
│ ├── ChannelsIndexMenu.tsx
│ ├── Desktop.tsx
│ ├── SaveLoadLayoutMenu.tsx
│ └── Window.tsx
├── context
│ ├── BlockContext.ts
│ ├── WindowContext.ts
│ ├── DialogContext.tsx
│ ├── BlockViewerContext.tsx
│ └── DesktopContext.tsx
├── hooks
│ └── useArena.ts
├── reducers
│ ├── blocksReducer.ts
│ └── channelsReducer.ts
├── pages
│ ├── _app.tsx
│ ├── index.tsx
│ └── api
│ │ └── auth
│ │ └── [...nextauth].ts
└── styles
│ └── globals.css
├── next.config.js
├── .gitignore
├── .eslintrc.json
├── .github
└── FUNDING.yml
├── tsconfig.json
├── tailwind.config.js
├── README.md
├── LICENSE.md
└── package.json
/.tool-versions:
--------------------------------------------------------------------------------
1 | nodejs 23.3.0
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "github": {
3 | "silent": true
4 | }
5 | }
--------------------------------------------------------------------------------
/public/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mguidetti/are.na-multiplexer/HEAD/public/screenshot.png
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {}
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/src/types/index.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'react-dnd-multi-backend'
2 | declare module 'react-dnd-multi-backend/dist/cjs/HTML5toTouch'
3 |
--------------------------------------------------------------------------------
/src/icons/square.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/src/lib/getErrorMessage.ts:
--------------------------------------------------------------------------------
1 | // https://kentcdodds.com/blog/get-a-catch-block-error-message-with-typescript
2 |
3 | export default function getErrorMessage (error: unknown) {
4 | if (error instanceof Error) return error.message
5 | return String(error)
6 | }
7 |
--------------------------------------------------------------------------------
/src/components/WindowFooter.tsx:
--------------------------------------------------------------------------------
1 | import { useWindowContext } from '@/context/WindowContext'
2 | import Spinner from './Spinner'
3 |
4 | const WindowFooter = () => {
5 | const { isLoading } = useWindowContext()
6 |
7 | return (
8 |
9 | {isLoading && }
10 |
11 | )
12 | }
13 |
14 | export default WindowFooter
15 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 |
3 | const nextConfig = {
4 | reactStrictMode: true,
5 | env: {
6 | npm_package_version: process.env.npm_package_version
7 | },
8 | webpack: config => {
9 | config.module.rules.push({
10 | test: /\.svg$/i,
11 | issuer: /\.[jt]sx?$/,
12 | use: ['@svgr/webpack']
13 | })
14 |
15 | return config
16 | }
17 | }
18 |
19 | module.exports = nextConfig
20 |
--------------------------------------------------------------------------------
/src/components/ZeroState.tsx:
--------------------------------------------------------------------------------
1 | import Spinner from './Spinner'
2 |
3 | function ZeroState ({ isLoadingLayout }: {isLoadingLayout: boolean}) {
4 | return (
5 |
6 | {isLoadingLayout &&
}
7 |
{isLoadingLayout ? 'Loading channels' : 'No channels loaded'}
8 |
9 | )
10 | }
11 |
12 | export default ZeroState
13 |
--------------------------------------------------------------------------------
/src/icons/save.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 | next-env.d.ts
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 | .pnpm-debug.log*
28 |
29 | # local env files
30 | .env*.local
31 |
32 | # vercel
33 | .vercel
34 |
--------------------------------------------------------------------------------
/src/components/WindowScroller.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Components } from 'react-virtuoso'
3 |
4 | const WindowScroller: Components['Scroller'] = React.forwardRef(function VirtuosoScroller ({ style, ...props }, ref) {
5 | return (
6 |
12 | )
13 | })
14 |
15 | export default WindowScroller
16 |
--------------------------------------------------------------------------------
/src/types/nextauth.d.ts:
--------------------------------------------------------------------------------
1 | import { DefaultSession } from 'next-auth'
2 |
3 | declare module 'next-auth' {
4 | /**
5 | * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
6 | */
7 | interface Session {
8 | user: {
9 | id: number
10 | accessToken: string,
11 | initials: string
12 | } & DefaultSession['user']
13 | }
14 |
15 | interface Profile {
16 | id: number,
17 | username: string,
18 | avatar: string,
19 | initials: string
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/components/Spinner.tsx:
--------------------------------------------------------------------------------
1 | export default function Spinner ({ className = 'h-12 w-12' }) {
2 | return (
3 |
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "plugin:@typescript-eslint/recommended",
4 | // "plugin:@typescript-eslint/recommended-requiring-type-checking",
5 | "next/core-web-vitals",
6 | "plugin:tailwindcss/recommended",
7 | "standard"
8 | ],
9 | "parser": "@typescript-eslint/parser",
10 | "parserOptions": {
11 | "project": "./tsconfig.json",
12 | "tsconfigRootDir": "./"
13 | },
14 | "plugins": [
15 | "@typescript-eslint"
16 | ],
17 | "rules": {
18 | "@next/next/no-img-element": "off",
19 | "@typescript-eslint/indent": "off"
20 | }
21 | }
--------------------------------------------------------------------------------
/src/context/BlockContext.ts:
--------------------------------------------------------------------------------
1 | import { ArenaChannelContents } from 'arena-ts'
2 | import { createContext, useContext } from 'react'
3 |
4 | export interface BlockContextType {
5 | data: ArenaChannelContents,
6 | handleDelete: () => void,
7 | handleView: () => void,
8 | isDragging: boolean,
9 | isHovering: boolean
10 | isPending: boolean
11 | }
12 |
13 | export const BlockContext = createContext({} as BlockContextType)
14 |
15 | export const useBlockContext = () => {
16 | const blockContext = useContext(BlockContext)
17 |
18 | return blockContext
19 | }
20 |
--------------------------------------------------------------------------------
/src/hooks/useArena.ts:
--------------------------------------------------------------------------------
1 | import { ArenaClient } from 'arena-ts'
2 | import { useSession } from 'next-auth/react'
3 | import { useEffect, useState } from 'react'
4 |
5 | export const useArena = () => {
6 | const [client, setClient] = useState()
7 | const { data, status } = useSession()
8 | const loading = status === 'loading'
9 |
10 | useEffect(() => {
11 | const accessToken = data?.user.accessToken
12 | const options = data ? { token: accessToken } : undefined
13 | const arena = new ArenaClient(options)
14 |
15 | setClient(arena)
16 | // eslint-disable-next-line react-hooks/exhaustive-deps
17 | }, [loading])
18 |
19 | return client
20 | }
21 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [mguidetti]
4 | patreon: # Replace with a single Patreon username
5 | open_collective: # Replace with a single Open Collective username
6 | ko_fi: # Replace with a single Ko-fi username
7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
9 | liberapay: # Replace with a single Liberapay username
10 | issuehunt: # Replace with a single IssueHunt username
11 | otechie: # Replace with a single Otechie username
12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
14 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": {
5 | "@/*": [
6 | "./src/*"
7 | ],
8 | },
9 | "typeRoots": ["./src/types"],
10 | "lib": [
11 | "dom",
12 | "dom.iterable",
13 | "esnext"
14 | ],
15 | "allowJs": true,
16 | "skipLibCheck": true,
17 | "strict": true,
18 | "forceConsistentCasingInFileNames": true,
19 | "noEmit": true,
20 | "incremental": true,
21 | "esModuleInterop": true,
22 | "module": "esnext",
23 | "moduleResolution": "node",
24 | "resolveJsonModule": true,
25 | "isolatedModules": true,
26 | "jsx": "preserve"
27 | },
28 | "include": [
29 | "next-env.d.ts",
30 | "**/*.ts",
31 | "**/*.tsx"
32 | ],
33 | "exclude": [
34 | "node_modules"
35 | ]
36 | }
--------------------------------------------------------------------------------
/src/icons/github.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/context/WindowContext.ts:
--------------------------------------------------------------------------------
1 | import { ChannelWindowState } from '@/components/Desktop'
2 | import { ArenaChannelContents, ArenaChannelWithDetails } from 'arena-ts'
3 | import { createContext, useContext } from 'react'
4 |
5 | export interface WindowContextType {
6 | canDelete: boolean,
7 | channel: ArenaChannelWithDetails,
8 | connectBlock: (block: ArenaChannelContents) => void,
9 | disconnectBlock: (block: ArenaChannelContents) => void,
10 | isLoading: boolean,
11 | loadMore: () => void,
12 | scale: ChannelWindowState['scale'],
13 | view: ChannelWindowState['view'],
14 | }
15 |
16 | export const WindowContext = createContext({} as WindowContextType)
17 |
18 | export const useWindowContext = () => {
19 | const windowContext = useContext(WindowContext)
20 |
21 | return windowContext
22 | }
23 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | content: ['./src/**/*.{js,ts,jsx,tsx}'],
3 | theme: {
4 | extend: {
5 | colors: {
6 | primary: 'rgb(var(--primary-color) / )',
7 | secondary: 'rgb(var(--secondary-color) / )',
8 | background: 'rgb(var(--background-color) / )',
9 | 'private-channel': 'rgb(var(--private-channel-color) / )',
10 | 'public-channel': 'rgb(var(--public-channel-color) / )'
11 | },
12 | fontSize: {
13 | 'xs-relative': '0.75em',
14 | 'sm-relative': '0.875em',
15 | 'base-relative': '1em'
16 | },
17 | dropShadow: {
18 | panel: '0 10px 10px rgba(0, 0, 0, 0.9)'
19 | }
20 | }
21 | },
22 | plugins: [require('@tailwindcss/typography'), require('tailwind-scrollbar')]
23 | }
24 |
--------------------------------------------------------------------------------
/src/reducers/blocksReducer.ts:
--------------------------------------------------------------------------------
1 | import { ArenaChannelContents } from 'arena-ts'
2 |
3 | export type BlocksReducerAction =
4 | | { type: 'append', blocks: ArenaChannelContents[] }
5 | | { type: 'prepend', blocks: ArenaChannelContents[] }
6 | | { type: 'update', block: ArenaChannelContents }
7 | | { type: 'remove', id: number }
8 |
9 | function blocksReducer (blocks: ArenaChannelContents[], action: BlocksReducerAction): ArenaChannelContents[] {
10 | switch (action.type) {
11 | case 'append': {
12 | return [...action.blocks, ...blocks]
13 | }
14 | case 'prepend': {
15 | return [...blocks, ...action.blocks]
16 | }
17 | case 'update': {
18 | return blocks.map(b => (b.id === action.block.id ? action.block : b))
19 | }
20 | case 'remove': {
21 | return blocks.filter(b => b.id !== action.id)
22 | }
23 | }
24 | }
25 |
26 | export default blocksReducer
27 |
--------------------------------------------------------------------------------
/src/components/BlockDragOverlay.tsx:
--------------------------------------------------------------------------------
1 | import { ArenaChannelContents } from 'arena-ts'
2 | import { CSSProperties } from 'react'
3 | import BlocksGridItem from './BlocksGridItem'
4 | import BlocksListItem from './BlocksListItem'
5 | import { ChannelWindowState } from './Desktop'
6 |
7 | function BlockDragOverlay ({ data, window }: {data: ArenaChannelContents, window: ChannelWindowState }) {
8 | const renderItem = () => {
9 | switch (window.view) {
10 | case 'grid':
11 | return
12 | case 'list':
13 | return
14 | }
15 | }
16 |
17 | return (
18 |
19 | {renderItem()}
20 |
21 |
22 | )
23 | }
24 |
25 | export default BlockDragOverlay
26 |
--------------------------------------------------------------------------------
/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { Analytics } from '@vercel/analytics/react'
2 | import { SessionProvider } from 'next-auth/react'
3 | import { AppProps } from 'next/app'
4 | import { DndProvider } from 'react-dnd-multi-backend'
5 | import HTML5toTouch from 'react-dnd-multi-backend/dist/cjs/HTML5toTouch'
6 | import 'react-mosaic-component/react-mosaic-component.css'
7 | import '../styles/globals.css'
8 |
9 | // DndProvider included to fix https://github.com/nomcopter/react-mosaic/issues/162
10 | // Fix from https://github.com/nomcopter/react-mosaic/issues/162#issuecomment-1194558777 *
11 |
12 | export default function App ({ Component, pageProps: { session, ...pageProps } }: AppProps) {
13 | return (
14 | <>
15 |
16 |
17 |
18 |
19 |
20 |
21 | >
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/src/context/DialogContext.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, Dispatch, ReactNode, SetStateAction, useContext, useState } from 'react'
2 |
3 | export interface DialogState {
4 | isOpen: boolean,
5 | title?: string,
6 | message?: string,
7 | onConfirm?: () => void
8 | }
9 |
10 | export const DialogContext = createContext({} as DialogState)
11 | export const DialogActionsContext = createContext({} as Dispatch>)
12 |
13 | export const useDialogContext = () => useContext(DialogContext)
14 | export const useDialogActionsContext = () => useContext(DialogActionsContext)
15 |
16 | export const DialogContextProvider = ({ children }: {children: ReactNode}) => {
17 | const [dialog, setDialog] = useState({ isOpen: false })
18 |
19 | return (
20 |
21 |
22 | {children}
23 |
24 |
25 | )
26 | }
27 |
--------------------------------------------------------------------------------
/src/components/AuthButton.tsx:
--------------------------------------------------------------------------------
1 | import { useSession, signIn } from 'next-auth/react'
2 | import ArenaMark from '@/icons/arena-mark.svg'
3 | import Spinner from './Spinner'
4 |
5 | export default function AuthButton () {
6 | const session = useSession()
7 | const { status } = session
8 | const loading = status === 'loading'
9 |
10 | if (loading) {
11 | return (
12 |
16 | )
17 | } else {
18 | return (
19 |
20 |
27 |
28 | )
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import Dialog from '@/components/Dialog'
2 | import { DialogContextProvider } from '@/context/DialogContext'
3 | import { useSession } from 'next-auth/react'
4 | import Head from 'next/head'
5 | import Desktop from '../components/Desktop'
6 | import Welcome from '../components/Welcome'
7 |
8 | export default function Home () {
9 | const session = useSession() || {}
10 | const { data } = session
11 |
12 | return (
13 | <>
14 |
15 | Are.na Multiplexer
16 |
17 |
18 |
19 |
20 | {data
21 | ? (
22 |
23 |
24 |
25 |
26 | )
27 | :
28 | }
29 |
30 | >
31 | )
32 | }
33 |
--------------------------------------------------------------------------------
/src/context/BlockViewerContext.tsx:
--------------------------------------------------------------------------------
1 | import { ArenaBlock, ConnectionData } from 'arena-ts'
2 | import { createContext, Dispatch, ReactNode, SetStateAction, useContext, useState } from 'react'
3 |
4 | export type BlockViewerState = ArenaBlock & ConnectionData | null
5 |
6 | export const BlockViewerContext = createContext({} as BlockViewerState)
7 | export const BlockViewerActionsContext = createContext({} as Dispatch>)
8 |
9 | export const useBlockViewerContext = () => useContext(BlockViewerContext)
10 | export const useBlockViewerActionsContext = () => useContext(BlockViewerActionsContext)
11 |
12 | export const BlockViewerContextProvider = ({ children }: {children: ReactNode}) => {
13 | const [blockViewerData, setBlockViewerData] = useState(null)
14 |
15 | return (
16 |
17 |
18 | {children}
19 |
20 |
21 | )
22 | }
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | # Are.na Multiplexer
4 |
5 | Are.na Multiplexer is a tiling window manager for [Are.na](https://are.na)
6 |
7 | Hosted at https://arena-mux.michaelguidetti.info
8 |
9 |
10 |
11 | ## Development
12 |
13 | ### Setup
14 | - Fork and clone this repo
15 | - Register an application with Are.na at https://dev.are.na/oauth/applications
16 | - Add `https://localhost:3001/api/auth/callback/arena` as a callback URL in your registered application's settings
17 | - Set the following variables in `.env.local`
18 | | KEY | VALUE |
19 | | --- | --- |
20 | | `ARENA_APP_ID` | `UID` from your registered app at dev.are.na |
21 | | `ARENA_APP_SECRET` | `Secret` from your registered app at dev.are.na |
22 | | `NEXT_AUTH_SECRET` | Generate local secret by running `openssl rand -base64 32` |
23 | | `NEXT_AUTH_URL` | `https://localhost:3001` |
24 | - Run `yarn install`
25 | - Run `yarn dev`
26 | - Visit https://localhost:3001 (bypass unsigned certificate warning)
27 |
--------------------------------------------------------------------------------
/src/components/BlockActions.tsx:
--------------------------------------------------------------------------------
1 | import { useBlockContext } from '@/context/BlockContext'
2 | import { useWindowContext } from '@/context/WindowContext'
3 | import { EyeIcon, LinkIcon, TrashIcon } from '@heroicons/react/20/solid'
4 |
5 | function BlockActions () {
6 | const { data, handleView, handleDelete } = useBlockContext()
7 | const windowCtx = useWindowContext()
8 |
9 | return (
10 | <>
11 | {data.class === 'Link' && (
12 |
19 |
20 |
21 | )}
22 |
25 | {windowCtx.canDelete && (
26 |
29 | )}
30 | >
31 | )
32 | }
33 |
34 | export default BlockActions
35 |
--------------------------------------------------------------------------------
/src/reducers/channelsReducer.ts:
--------------------------------------------------------------------------------
1 | import { ChannelsState, ChannelWindowState } from '@/components/Desktop'
2 | import { ArenaChannelWithDetails } from 'arena-ts'
3 |
4 | export type ChannelsReducerAction =
5 | | { type: 'add', channel: ArenaChannelWithDetails }
6 | | { type: 'update', id: number, payload: Partial}
7 | | { type: 'replace', channels: ChannelsState }
8 | | { type: 'remove', id: number }
9 |
10 | function channelsReducer (channels: ChannelsState, action: ChannelsReducerAction): ChannelsState {
11 | switch (action.type) {
12 | case 'add': {
13 | return { ...channels, [action.channel.id]: { data: action.channel, scale: 1, view: 'grid' } }
14 | }
15 | case 'update': {
16 | return { ...channels, [action.id]: { ...channels[action.id], ...action.payload } }
17 | }
18 | case 'replace': {
19 | return action.channels
20 | }
21 | case 'remove': {
22 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
23 | const { [action.id]: tmp, ...rest } = channels
24 |
25 | return rest
26 | }
27 | }
28 | }
29 |
30 | export default channelsReducer
31 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Michael Guidetti
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/lib/mosaic.ts:
--------------------------------------------------------------------------------
1 | import { Corner, getNodeAtPath, getOtherDirection, getPathToCorner, MosaicNode, MosaicParent, updateTree } from 'react-mosaic-component'
2 | import { MosaicKey } from 'react-mosaic-component/lib/types'
3 |
4 | export const addWindow = (layout: MosaicNode | null, id: MosaicNode): MosaicNode => {
5 | if (layout) {
6 | const path = getPathToCorner(layout, Corner.TOP_RIGHT)
7 | const parent = getNodeAtPath(layout, path.slice(0, -1)) as MosaicParent
8 | const destination = getNodeAtPath(layout, path) as MosaicNode
9 | const direction = parent ? getOtherDirection(parent.direction) : 'row'
10 | let first: MosaicNode
11 | let second: MosaicNode
12 |
13 | if (direction === 'row') {
14 | first = destination
15 | second = id
16 | } else {
17 | first = id
18 | second = destination
19 | }
20 | const update = [
21 | {
22 | path,
23 | spec: {
24 | $set: {
25 | direction,
26 | first,
27 | second
28 | }
29 | }
30 | }
31 | ]
32 |
33 | return updateTree(layout, update)
34 | } else {
35 | return id
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/components/BlocksList.tsx:
--------------------------------------------------------------------------------
1 | import { useWindowContext } from '@/context/WindowContext'
2 | import { ArenaChannelContents } from 'arena-ts'
3 | import React from 'react'
4 | import { Components, Virtuoso } from 'react-virtuoso'
5 | import BlockContainer from './BlockContainer'
6 | import BlocksListItem from './BlocksListItem'
7 | import WindowFooter from './WindowFooter'
8 | import WindowScroller from './WindowScroller'
9 |
10 | const ListContainer: Components['List'] = React.forwardRef(function ListContainer (props, ref) {
11 | return
12 | })
13 |
14 | function BlocksList ({ blocks }: {blocks: ArenaChannelContents[]}) {
15 | const windowCtx = useWindowContext()
16 |
17 | return (
18 | (
28 |
29 |
30 |
31 | )}
32 | />
33 | )
34 | }
35 |
36 | export default React.memo(BlocksList)
37 |
--------------------------------------------------------------------------------
/src/components/Welcome.tsx:
--------------------------------------------------------------------------------
1 | import { BrowserView, MobileView } from 'react-device-detect'
2 | import AuthButton from './AuthButton'
3 | import AmuxIcon from '../icons/amux.svg'
4 | import GithubIcon from '../icons/github.svg'
5 |
6 | function Welcome () {
7 | return (
8 |
9 |
10 |
Are.na Multiplexer
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | This app is not designed for mobile devices.
20 |
21 | Please visit on a desktop computer.
22 |
23 |
24 |
25 |
26 |
27 |
33 |
34 |
35 |
36 |
37 | )
38 | }
39 |
40 | export default Welcome
41 |
--------------------------------------------------------------------------------
/src/components/BlocksGrid.tsx:
--------------------------------------------------------------------------------
1 | import { useWindowContext } from '@/context/WindowContext'
2 | import { ArenaChannelContents } from 'arena-ts'
3 | import React from 'react'
4 | import { Components, VirtuosoGrid } from 'react-virtuoso'
5 | import BlockContainer from './BlockContainer'
6 | import BlocksGridItem from './BlocksGridItem'
7 | import WindowFooter from './WindowFooter'
8 | import WindowScroller from './WindowScroller'
9 |
10 | const ListContainer: Components['List'] = React.forwardRef(function ListContainer (props, ref) {
11 | return (
12 |
15 | )
16 | })
17 |
18 | function BlocksGrid ({ blocks }: { blocks: ArenaChannelContents[] }) {
19 | const { loadMore } = useWindowContext()
20 |
21 | return (
22 | (
32 |
33 |
34 |
35 | )}
36 | />
37 | )
38 | }
39 |
40 | export default React.memo(BlocksGrid)
41 |
--------------------------------------------------------------------------------
/src/components/UserMenu.tsx:
--------------------------------------------------------------------------------
1 | import { useSession, signOut } from 'next-auth/react'
2 | import * as Popover from '@radix-ui/react-popover'
3 |
4 | function UserMenu () {
5 | const session = useSession()
6 | const { data: sessionData } = session
7 |
8 | return (
9 |
10 |
11 | {sessionData?.user.initials}
12 | {sessionData?.user.image &&
}
13 |
14 |
15 |
20 |
27 |
28 |
29 |
30 |
31 | )
32 | }
33 |
34 | export default UserMenu
35 |
--------------------------------------------------------------------------------
/src/icons/arena-mark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import AmuxIcon from '@/icons/amux.svg'
2 | import React from 'react'
3 | import ChannelCreator from './ChannelCreator'
4 | import ChannelLoader from './ChannelLoader'
5 | import ChannelsIndexMenu from './ChannelsIndexMenu'
6 | import Info from './Info'
7 | import SaveLoadLayoutMenu from './SaveLoadLayoutMenu'
8 | import UserMenu from './UserMenu'
9 |
10 | function Header () {
11 | return (
12 |
13 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | export default React.memo(Header)
43 |
--------------------------------------------------------------------------------
/src/context/DesktopContext.tsx:
--------------------------------------------------------------------------------
1 | import { ChannelsState, SavedLayoutsState } from '@/components/Desktop'
2 | import { ChannelsReducerAction } from '@/reducers/channelsReducer'
3 | import { ArenaChannelWithDetails } from 'arena-ts'
4 | import { createContext, Dispatch, ReactNode, useContext } from 'react'
5 |
6 | export interface DesktopContextType {
7 | channels: ChannelsState,
8 | savedLayouts: SavedLayoutsState,
9 | }
10 |
11 | export interface DesktopActionsContextType {
12 | addChannelWindow: (channel: ArenaChannelWithDetails) => void,
13 | dispatchChannels: Dispatch,
14 | restoreLayout: (layoutId: string) => Promise,
15 | removeSavedLayout: (id: string) => void,
16 | saveLayout: (name: string) => void
17 | }
18 |
19 | export const DesktopContext = createContext({} as DesktopContextType)
20 | export const DesktopActionsContext = createContext({} as DesktopActionsContextType)
21 |
22 | export const useDesktopContext = () => useContext(DesktopContext)
23 | export const useDesktopActionsContext = () => useContext(DesktopActionsContext)
24 |
25 | export const DesktopContextProvider = (
26 | { children, contextValue, actionsContextValue }: {children: ReactNode, contextValue: DesktopContextType, actionsContextValue: DesktopActionsContextType}
27 | ) => {
28 | return (
29 |
30 |
31 | {children}
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/src/pages/api/auth/[...nextauth].ts:
--------------------------------------------------------------------------------
1 | import NextAuth, { NextAuthOptions } from 'next-auth'
2 |
3 | const options: NextAuthOptions = {
4 | providers: [
5 | {
6 | id: 'arena',
7 | name: 'Are.na',
8 | type: 'oauth',
9 | authorization: {
10 | url: 'https://dev.are.na/oauth/authorize',
11 | params: {
12 | scope: ''
13 | }
14 | },
15 | token: 'https://dev.are.na/oauth/token',
16 | userinfo: 'https://api.are.na/v2/me',
17 | clientId: process.env.ARENA_APP_ID,
18 | clientSecret: process.env.ARENA_APP_SECRET,
19 | profile: (profile) => {
20 | const data = {
21 | id: profile.id,
22 | username: profile.username,
23 | avatar: profile.avatar,
24 | initials: profile.initials
25 | }
26 |
27 | return data
28 | }
29 | }
30 | ],
31 | jwt: {
32 | secret: process.env.ARENA_APP_SECRET
33 | },
34 | callbacks: {
35 | async jwt ({ token, account, profile }) {
36 | // Persist the OAuth access_token and or the user id to the token right after signin
37 | if (account && profile) {
38 | token.accessToken = account.access_token
39 | token.id = profile.id
40 | token.name = profile.username
41 | token.image = profile.avatar
42 | token.initials = profile.initials
43 | }
44 |
45 | return token
46 | },
47 | async session ({ session, token }) {
48 | session.user.id = token.id as number
49 | session.user.accessToken = token.accessToken as string
50 | session.user.name = token.name
51 | session.user.image = token.image as string
52 | session.user.initials = token.initials as string
53 |
54 | return session
55 | }
56 | }
57 | }
58 |
59 | export default NextAuth(options)
60 |
--------------------------------------------------------------------------------
/src/components/Dialog.tsx:
--------------------------------------------------------------------------------
1 | import { useDialogActionsContext, useDialogContext } from '@/context/DialogContext'
2 | import * as AlertDialog from '@radix-ui/react-alert-dialog'
3 |
4 | function Dialog () {
5 | const { isOpen, message, onConfirm, title } = useDialogContext()
6 | const setDialog = useDialogActionsContext()
7 |
8 | const handleConfirm = () => {
9 | if (onConfirm) {
10 | onConfirm()
11 | }
12 | }
13 |
14 | return (
15 | setDialog({ isOpen: open })}>
16 |
17 |
18 |
19 | {title && {title}}
20 | {message && {message}}
21 |
22 |
23 |
24 |
25 |
26 |
32 |
33 |
34 |
35 |
36 |
37 | )
38 | }
39 |
40 | export default Dialog
41 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "arena-multiplexer",
3 | "version": "0.2.0",
4 | "private": true,
5 | "license": "MIT",
6 | "repository": "https://github.com/mguidetti/arena-multiplexer",
7 | "author": "Michael Guidetti ",
8 | "scripts": {
9 | "dev": "next dev & local-ssl-proxy --source 3001 --target 3000",
10 | "build": "next build",
11 | "start": "next start",
12 | "lint": "next lint"
13 | },
14 | "dependencies": {
15 | "@dnd-kit/core": "^6.0.7",
16 | "@heroicons/react": "^2.0.16",
17 | "@radix-ui/react-alert-dialog": "^1.0.2",
18 | "@radix-ui/react-dialog": "^1.0.3",
19 | "@radix-ui/react-popover": "1.0.3",
20 | "@vercel/analytics": "^0.1.8",
21 | "arena-ts": "^0.1.4",
22 | "dayjs": "^1.11.7",
23 | "debounce-promise": "^3.1.2",
24 | "next": "14.2.35",
25 | "next-auth": "^4.24.12",
26 | "react": "18.2.0",
27 | "react-device-detect": "^2.2.2",
28 | "react-dom": "18.2.0",
29 | "react-hotkeys-hook": "^4.3.4",
30 | "react-mosaic-component": "^5.3.0",
31 | "react-select": "^5.7.0",
32 | "react-virtuoso": "^4.1.0",
33 | "uuid": "^9.0.0"
34 | },
35 | "devDependencies": {
36 | "@svgr/webpack": "^6.5.1",
37 | "@tailwindcss/typography": "^0.5.9",
38 | "@types/debounce-promise": "^3.1.6",
39 | "@types/node": "^18.14.1",
40 | "@types/react": "^18.0.28",
41 | "@types/uuid": "^9.0.1",
42 | "@typescript-eslint/eslint-plugin": "^5.53.0",
43 | "@typescript-eslint/parser": "^5.53.0",
44 | "autoprefixer": "^10.4.13",
45 | "eslint": "8.33.0",
46 | "eslint-config-next": "13.1.6",
47 | "eslint-config-standard": "^17.0.0",
48 | "eslint-import-resolver-typescript": "^3.5.3",
49 | "eslint-plugin-import": "^2.27.5",
50 | "eslint-plugin-n": "^15.6.1",
51 | "eslint-plugin-promise": "^6.1.1",
52 | "eslint-plugin-tailwindcss": "^3.9.0",
53 | "local-ssl-proxy": "^2.0.5",
54 | "postcss": "^8.4.31",
55 | "tailwind-scrollbar": "^2.1.0",
56 | "tailwindcss": "^3.2.4",
57 | "typescript": "^4.9.5"
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/components/BlockDndWrapper.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | CollisionDetection,
3 | DndContext,
4 | DragOverlay,
5 | DragStartEvent,
6 | PointerSensor,
7 | pointerWithin,
8 | rectIntersection,
9 | useSensor,
10 | useSensors
11 | } from '@dnd-kit/core'
12 | import { ArenaChannelContents } from 'arena-ts'
13 | import { ReactNode, useState } from 'react'
14 | import BlockDragOverlay from './BlockDragOverlay'
15 | import { ChannelWindowState } from './Desktop'
16 |
17 | interface DraggingBlockState {
18 | block: ArenaChannelContents,
19 | window: ChannelWindowState
20 | }
21 |
22 | function BlockDndWrapper ({ children }: { children: ReactNode }) {
23 | const [draggingBlock, setDraggingBlock] = useState()
24 |
25 | const pointerSensor = useSensor(PointerSensor, {
26 | activationConstraint: {
27 | distance: 5
28 | }
29 | })
30 |
31 | const sensors = useSensors(pointerSensor)
32 |
33 | const collisionDetection: CollisionDetection = (args) => {
34 | const pointerCollisions = pointerWithin(args)
35 |
36 | if (pointerCollisions.length > 0) {
37 | return pointerCollisions
38 | }
39 |
40 | return rectIntersection(args)
41 | }
42 |
43 | const handleDragStart = (event: DragStartEvent) => {
44 | const current = event.active.data.current
45 |
46 | setDraggingBlock({
47 | block: current?.block,
48 | window: current?.window
49 | })
50 | }
51 |
52 | const clearDraggingBlock = () => {
53 | setDraggingBlock(null)
54 | }
55 |
56 | return (
57 |
64 | {children}
65 |
66 | {draggingBlock && (
67 |
68 | )}
69 |
70 |
71 | )
72 | }
73 |
74 | export default BlockDndWrapper
75 |
--------------------------------------------------------------------------------
/src/icons/amux.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/BlocksListItem.tsx:
--------------------------------------------------------------------------------
1 | import { useBlockContext } from '@/context/BlockContext'
2 | import SquareIcon from '@/icons/square.svg'
3 | import { ArenaBlock, ArenaChannelContents, ArenaChannelWithDetails } from 'arena-ts'
4 | import classNames from 'classnames'
5 | import BlockActions from './BlockActions'
6 | import Spinner from './Spinner'
7 |
8 | function ChannelBody ({ data }: {data: ArenaChannelWithDetails}) {
9 | return (
10 |
17 |
18 |
19 |
20 |
{`${data.user?.username} / ${data.title}`}
21 |
22 | )
23 | }
24 |
25 | function BlockBody ({ data }: {data:ArenaBlock}) {
26 | return (
27 | <>
28 |
29 | {data.image &&

}
30 |
31 | {data.title || data.generated_title}
32 | >
33 | )
34 | }
35 |
36 | function BlocksListItem ({ data }: {data: ArenaChannelContents}) {
37 | const { isPending, isHovering } = useBlockContext()
38 |
39 | return (
40 |
43 | {data.class === 'Channel' ?
:
}
44 |
45 | {isPending && (
46 |
47 |
48 |
49 | )}
50 |
51 | {isHovering && (
52 |
53 |
54 |
55 | )}
56 |
57 | )
58 | }
59 |
60 | export default BlocksListItem
61 |
--------------------------------------------------------------------------------
/src/components/BlockConnnections.tsx:
--------------------------------------------------------------------------------
1 | import { useBlockViewerActionsContext } from '@/context/BlockViewerContext'
2 | import { useDesktopActionsContext } from '@/context/DesktopContext'
3 | import { useArena } from '@/hooks/useArena'
4 | import { ArenaBlock, ArenaChannelWithDetails, ConnectionData } from 'arena-ts'
5 | import classNames from 'classnames'
6 | import { useCallback, useEffect, useState } from 'react'
7 | import Spinner from './Spinner'
8 |
9 | function BlockConnections ({ blockData }: {blockData: ArenaBlock & ConnectionData}) {
10 | const [connections, setConnections] = useState([])
11 | const arena = useArena()
12 | const { addChannelWindow } = useDesktopActionsContext()
13 | const setBlockViewerData = useBlockViewerActionsContext()
14 |
15 | const fetchChannels = useCallback(async () => {
16 | if (!arena) return
17 |
18 | const results = await arena.block(blockData.id).channels()
19 |
20 | setConnections(results.channels)
21 | }, [arena, blockData])
22 |
23 | useEffect(() => {
24 | fetchChannels()
25 | }, [fetchChannels])
26 |
27 | const handleChannelClick = (channel: ArenaChannelWithDetails) => {
28 | addChannelWindow(channel)
29 | setBlockViewerData(null)
30 | }
31 |
32 | if (connections.length) {
33 | return (
34 |
35 | {connections.map(channel => (
36 | -
37 |
48 |
49 | ))}
50 |
51 | )
52 | } else {
53 | return Loading connections
54 | }
55 | }
56 |
57 | export default BlockConnections
58 |
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
13 |
--------------------------------------------------------------------------------
/src/components/BlockInfo.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowTopRightOnSquareIcon, ChevronRightIcon } from '@heroicons/react/24/solid'
2 | import { ArenaBlock, ConnectionData } from 'arena-ts'
3 | import dayjs from 'dayjs'
4 | import relativeTime from 'dayjs/plugin/relativeTime'
5 | import { Dispatch, SetStateAction } from 'react'
6 | import BlockConnections from './BlockConnnections'
7 |
8 | dayjs.extend(relativeTime)
9 |
10 | interface BlockInfoProps {
11 | blockData: ArenaBlock & ConnectionData,
12 | setInfoVisible: Dispatch>
13 | }
14 |
15 | function BlockInfo ({ blockData, setInfoVisible }: BlockInfoProps) {
16 | return (
17 |
18 |
21 |
22 |
23 |
{blockData.generated_title}
24 |
41 |
42 |
43 |
44 |
45 |
Connections
46 |
47 |
48 |
49 |
50 |
51 | )
52 | }
53 |
54 | export default BlockInfo
55 |
--------------------------------------------------------------------------------
/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --mosaic-spacing: 6px;
7 | --primary-color: 211 211 211;
8 | --secondary-color: 255 186 40;
9 | --private-channel-color: 226 73 55;
10 | --public-channel-color: 43 164 37;
11 | --background-color: 0 0 0;
12 | }
13 |
14 | html,
15 | body,
16 | main,
17 | #root {
18 | height: 100vh;
19 | width: 100vw;
20 | margin: 0;
21 | background: rgb(var(--background-color));
22 | font-family: Arial, 'Helvetica Neue', Helvetica, sans-serif;
23 | font-size: 16px;
24 | }
25 |
26 | #root {
27 | color: rgb(var(--primary-color));
28 | }
29 |
30 | .mosaic {
31 | background-color: rgb(var(--background-color));
32 | }
33 |
34 | .mosaic-root {
35 | top: var(--mosaic-spacing);
36 | right: var(--mosaic-spacing);
37 | bottom: var(--mosaic-spacing);
38 | left: var(--mosaic-spacing);
39 | }
40 |
41 | .mosaic-tile {
42 | margin: var(--mosaic-spacing);
43 | }
44 |
45 | .channel-status-closed {
46 | --color: var(--primary-color);
47 | }
48 |
49 | .channel-status-private {
50 | --color: var(--private-channel-color);
51 | }
52 |
53 | .channel-status-public {
54 | --color: var(--public-channel-color);
55 | }
56 |
57 | .mosaic-window,
58 | .mosaic-preview {
59 | border-width: 2px;
60 | border-radius: 2px;
61 |
62 | color: rgb(var(--color));
63 | border-color: rgb(var(--color));
64 | --scrollbar-thumb: rgb(var(--color) / 50%);
65 | --scrollbar-thumb-hover: rgb(var(--color) / 100%);
66 | --scrollbar-track: rgb(var(--color) / 33%);
67 | }
68 |
69 | .channel-block {
70 | color: rgb(var(--color));
71 | border-color: rgb(var(--color));
72 | }
73 |
74 | .mosaic-window .mosaic-window-toolbar,
75 | .mosaic-preview .mosaic-window-toolbar {
76 | border-bottom-width: 2px;
77 | border-color: inherit;
78 | background-color: rgb(var(--background-color));
79 | color: inherit;
80 | height: 35px;
81 | }
82 |
83 | .mosaic-window .mosaic-window-title,
84 | .mosaic-preview .mosaic-window-title {
85 | padding-left: 0.5rem;
86 | font-weight: bold;
87 | user-select: none;
88 | }
89 |
90 | .mosaic-window .mosaic-window-body,
91 | .mosaic-preview .mosaic-window-body {
92 | background-color: rgb(var(--background-color));
93 | }
94 |
95 | .drop-target-container .drop-target {
96 | border-width: 2;
97 | background: rgb(var(--secondary-color) / 50%) !important;
98 | border-color: rgb(var(--secondary-color)) !important;
99 | }
100 |
101 | .mosaic-split:hover {
102 | background: rgb(var(--secondary-color) / 50%);
103 | }
104 |
105 | .scrollbar-thin {
106 | overflow: auto;
107 | }
108 |
109 | @layer utilities {
110 | .bg-dot-grid-secondary {
111 | background: linear-gradient(
112 | 45deg,
113 | rgb(var(--secondary-color) / 30%),
114 | rgb(var(--secondary-color) / 30%) 50%,
115 | rgb(var(--secondary-color) / 20%) 50%,
116 | rgb(var(--secondary-color) / 20%)
117 | );
118 | background-size: 2px 2px;
119 | }
120 | }
121 |
--------------------------------------------------------------------------------
/src/components/BlockContainer.tsx:
--------------------------------------------------------------------------------
1 | import { BlockContext, BlockContextType } from '@/context/BlockContext'
2 | import { useBlockViewerActionsContext } from '@/context/BlockViewerContext'
3 | import { useDesktopActionsContext } from '@/context/DesktopContext'
4 | import { useDialogActionsContext } from '@/context/DialogContext'
5 | import { useWindowContext } from '@/context/WindowContext'
6 | import { UniqueIdentifier, useDraggable } from '@dnd-kit/core'
7 | import { ArenaChannelContents } from 'arena-ts'
8 | import classNames from 'classnames'
9 | import { ReactNode, useCallback, useMemo, useState } from 'react'
10 | import { ChannelWindowState } from './Desktop'
11 |
12 | export interface DraggingBlockData {
13 | type: 'block',
14 | block: ArenaChannelContents,
15 | window: {
16 | id: number,
17 | view: ChannelWindowState['view'],
18 | scale: ChannelWindowState['scale']
19 | }
20 | }
21 |
22 | interface BlockContainerProps {
23 | data: ArenaChannelContents,
24 | children: ReactNode
25 | }
26 |
27 | function BlockContainer ({ data, children }: BlockContainerProps) {
28 | const { addChannelWindow } = useDesktopActionsContext()
29 | const { channel, view, scale, disconnectBlock } = useWindowContext()
30 | const setBlockViewerData = useBlockViewerActionsContext()
31 | const setDialog = useDialogActionsContext()
32 | const [isHovering, setIsHovering] = useState(false)
33 | const isPending = useMemo(() => data.connection_id === undefined, [data])
34 |
35 | const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
36 | id: data.connection_id as UniqueIdentifier,
37 | data: {
38 | type: 'block',
39 | block: { ...data },
40 | window: {
41 | id: channel.id,
42 | view,
43 | scale
44 | }
45 | }
46 | })
47 |
48 | const handleView = useCallback(() => {
49 | if (data.class === 'Channel') {
50 | addChannelWindow(data)
51 | } else {
52 | setBlockViewerData(data)
53 | }
54 | }, [data, addChannelWindow, setBlockViewerData])
55 |
56 | const handleDelete = useCallback(() => {
57 | setDialog({
58 | isOpen: true,
59 | title: 'Are you sure you want to disconnect this block?',
60 | message: 'This cannot be undone',
61 | onConfirm: () => disconnectBlock(data)
62 | })
63 | }, [data, setDialog, disconnectBlock])
64 |
65 | const handleHover = () => {
66 | setIsHovering(prevState => !prevState)
67 | }
68 |
69 | const contextValues = useMemo(
70 | () => ({
71 | data,
72 | handleDelete,
73 | handleView,
74 | isPending,
75 | isDragging,
76 | isHovering
77 | }),
78 | [data, handleDelete, handleView, isPending, isDragging, isHovering]
79 | )
80 |
81 | return (
82 |
94 | {children}
95 |
96 | )
97 | }
98 |
99 | export default BlockContainer
100 |
--------------------------------------------------------------------------------
/src/components/ChannelLoader.tsx:
--------------------------------------------------------------------------------
1 | import { ArenaChannelWithDetails } from 'arena-ts'
2 | import classNames from 'classnames'
3 | import debounce from 'debounce-promise'
4 | import { useRef } from 'react'
5 | import { useHotkeys } from 'react-hotkeys-hook'
6 | import { SelectInstance, SingleValue } from 'react-select'
7 | import AsyncSelect from 'react-select/async'
8 | import { useDesktopActionsContext } from '../context/DesktopContext'
9 | import { useArena } from '../hooks/useArena'
10 |
11 | type Option = ArenaChannelWithDetails
12 |
13 | function ChannelLoader () {
14 | const { addChannelWindow } = useDesktopActionsContext()
15 | const arena = useArena()
16 | const select = useRef>(null)
17 |
18 | useHotkeys('/', () => select.current?.focus(), {
19 | ignoreModifiers: true,
20 | preventDefault: true
21 | })
22 |
23 | const loadOptions = debounce(async (inputValue: string) => {
24 | if (!inputValue || !arena) return []
25 |
26 | const results = await arena.search.channels(inputValue, { page: 1, per: 20 })
27 | return results.channels as ArenaChannelWithDetails[] // HACK: Type correction
28 | }, 200)
29 |
30 | const handleSelectChange = (channel: SingleValue