├── .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 | 2 | 3 | 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 | 4 | 5 | 10 | 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 |
13 | 14 |
Loading
15 |
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 | Are.na Multiplexer logo 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 | Screenshot of Are.na Multiplexer 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 |
13 |
14 |
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 && User avatar} 13 | 14 | 15 | 20 | 27 | 28 | 29 | 30 | 31 | ) 32 | } 33 | 34 | export default UserMenu 35 | -------------------------------------------------------------------------------- /src/icons/arena-mark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /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 | 2 | 10 | 11 | 12 | 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 |
25 | {blockData.description &&

{blockData.description}

} 26 |

27 | Created {dayjs(blockData.created_at).fromNow()} by{' '} 28 | 29 | {blockData.user.username} 30 | 31 |

32 | {blockData.source && ( 33 | 34 | Source:{' '} 35 | 36 | {blockData.source.title} 37 | 38 | 39 | )} 40 |
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