Compare commits
15 Commits
83c28a8d5e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d2f25ef04 | |||
| 9462a74437 | |||
| 1683c0d467 | |||
| 615292ac24 | |||
| 6bcb41820c | |||
| 6b8c922d5b | |||
| 91e75a2c72 | |||
| fd688021ea | |||
| fcb34aeacc | |||
| a287df5e0c | |||
| 9ca6770cd4 | |||
| 06b7c323ae | |||
| c12181fe16 | |||
| 9664eb07b8 | |||
| f38a34eb2a |
@@ -20,7 +20,7 @@ const App: React.FC = () => {
|
|||||||
},[data])
|
},[data])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dark:text-gray-300">
|
<div className="dark:text-gray-300 px-2 pt-1">
|
||||||
{
|
{
|
||||||
ready&&
|
ready&&
|
||||||
<Router>
|
<Router>
|
||||||
|
|||||||
55
src/Cache.ts
Normal file
55
src/Cache.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { InMemoryCache } from "@apollo/client"
|
||||||
|
import ObjID from "./types/ObjID"
|
||||||
|
|
||||||
|
const cache = new InMemoryCache({
|
||||||
|
typePolicies:{
|
||||||
|
File:{
|
||||||
|
fields:{
|
||||||
|
id:{
|
||||||
|
merge(_,incomming){
|
||||||
|
// HACK: i use the merge function to change the id from a string to ObjID object.
|
||||||
|
// afaik apollo does not yet support custom scalar types.
|
||||||
|
if (!incomming){
|
||||||
|
return incomming
|
||||||
|
}else if (incomming instanceof ObjID){
|
||||||
|
return incomming
|
||||||
|
}else{
|
||||||
|
return ObjID.fromString(incomming as string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Directory:{
|
||||||
|
fields:{
|
||||||
|
id:{
|
||||||
|
merge(_,incomming){
|
||||||
|
if (!incomming){
|
||||||
|
return incomming
|
||||||
|
}else if (incomming instanceof ObjID){
|
||||||
|
return incomming
|
||||||
|
}else{
|
||||||
|
return ObjID.fromString(incomming as string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Query: {
|
||||||
|
fields: {
|
||||||
|
files: {
|
||||||
|
merge(existing, incoming){
|
||||||
|
return incoming
|
||||||
|
}
|
||||||
|
},
|
||||||
|
directorys:{
|
||||||
|
merge(existing, incoming){
|
||||||
|
return incoming
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default cache
|
||||||
@@ -30,7 +30,7 @@ const Breadcrum: React.FC<Props> = ({path,onDirClick}) => {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="breadcrum-item">
|
<div className="breadcrum-item">
|
||||||
<BreadcrumImage className="h-5 w-auto text-gray-400" />
|
<BreadcrumImage className="h-5 w-auto text-gray-400 cursor-default" />
|
||||||
<li>
|
<li>
|
||||||
<a className={!keyParts.length?"text-blue-500":""} onClick={()=>{
|
<a className={!keyParts.length?"text-blue-500":""} onClick={()=>{
|
||||||
onDirClick?.(new ObjID(path.bucket,"/"))
|
onDirClick?.(new ObjID(path.bucket,"/"))
|
||||||
@@ -43,13 +43,13 @@ const Breadcrum: React.FC<Props> = ({path,onDirClick}) => {
|
|||||||
{keyParts.map((e,i,arr)=>{
|
{keyParts.map((e,i,arr)=>{
|
||||||
const last = i == arr.length - 1
|
const last = i == arr.length - 1
|
||||||
return <div key={e} className="breadcrum-item">
|
return <div key={e} className="breadcrum-item">
|
||||||
<BreadcrumImage className="h-5 w-auto text-gray-400" />
|
<BreadcrumImage className="h-5 w-auto text-gray-400 cursor-default" />
|
||||||
<li>
|
<li>
|
||||||
<a
|
<a
|
||||||
className={`${last?"text-blue-500":""}`}
|
className={`${last?"text-blue-500":""}`}
|
||||||
onClick={()=>{
|
onClick={()=>{
|
||||||
if (!last){
|
if (!last){
|
||||||
onDirClick?.(new ObjID(path.bucket,"/"+arr.slice(0,i-1).join("/")))
|
onDirClick?.(new ObjID(path.bucket,arr.slice(0,i+1).join("/")))
|
||||||
}
|
}
|
||||||
}}>{e}</a>
|
}}>{e}</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
import { useState } from "react"
|
import { useHistory } from "react-router-dom"
|
||||||
import { Link } from "react-router-dom"
|
import { useListBucktesQuery } from "../generated/graphql"
|
||||||
import Breadcrum from "./Breadcrum"
|
import Breadcrum from "./Breadcrum"
|
||||||
import MoreMenu from "./MoreMenu"
|
import MoreMenu from "./MoreMenu"
|
||||||
|
import { ReactComponent as Spinner } from "./../assets/spinner.svg"
|
||||||
|
import Table, { Data } from "./Table"
|
||||||
|
|
||||||
const BucketSelect: React.FC = () => {
|
const BucketSelect: React.FC = () => {
|
||||||
|
const history = useHistory()
|
||||||
const [buckets] = useState(["dev"])
|
const {data: buckets, loading} = useListBucktesQuery()
|
||||||
|
const tableData: Data = {
|
||||||
|
headers:[{name:"Bucket"}],
|
||||||
|
body:(!buckets?[]:buckets.buckets.map(e=>{return {cells:[{name:e||""}],onClick:()=>{history.push(`/f/${e}`)}}}))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -16,13 +22,12 @@ const BucketSelect: React.FC = () => {
|
|||||||
<MoreMenu />
|
<MoreMenu />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<Table data={tableData}/>
|
||||||
{buckets.map((e)=>
|
{loading &&
|
||||||
<li key={e}>
|
<div className="flex justify-center mt-4">
|
||||||
<Link to={`/f/${e}/`} >{e}</Link>
|
<Spinner className="animate-spin h-6 w-6 dark:text-white" />
|
||||||
</li>
|
</div>
|
||||||
)}
|
}
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import { Directory } from "../generated/graphql"
|
|
||||||
import { MdFolderOpen } from "react-icons/md"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
dir: Directory
|
|
||||||
}
|
|
||||||
|
|
||||||
const DirectoryElement: React.FC<Props> = (props) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<td>
|
|
||||||
<MdFolderOpen className="inline"/> {props.dir.name}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
</td>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DirectoryElement
|
|
||||||
@@ -5,10 +5,10 @@ import { RouteComponentProps } from "react-router-dom"
|
|||||||
import downloadFile from "../functions/downloadFile"
|
import downloadFile from "../functions/downloadFile"
|
||||||
import genDownloadLink from "../functions/genDownloadLink"
|
import genDownloadLink from "../functions/genDownloadLink"
|
||||||
import uploadFile from "../functions/uploadFile"
|
import uploadFile from "../functions/uploadFile"
|
||||||
import { useCopyMutation, useCreateDirMutation, useDeleteDirMutation, useDeleteFileMutation, useMoveMutation, useOpenDirQuery } from "../generated/graphql"
|
import { useCopyMutation, useCreateDirMutation, useDeleteDirMutation, useDeleteFileMutation, useMoveDirMutation, useMoveMutation, useOpenDirQuery } from "../generated/graphql"
|
||||||
import Breadcrum from "./Breadcrum"
|
import Breadcrum from "./Breadcrum"
|
||||||
import CreateDirButton from "./CreateDirButton"
|
import CreateDirButton from "./CreateDirButton"
|
||||||
import DragAndDrop from "./DragAndDrop"
|
import GlobalDragAndDrop from "./GlobalDragAndDrop"
|
||||||
import FileBrowserContextMenu, { Action, CONTEXT_MENU_DIR, CONTEXT_MENU_FILE } from "./FileBrowserContextMenu"
|
import FileBrowserContextMenu, { Action, CONTEXT_MENU_DIR, CONTEXT_MENU_FILE } from "./FileBrowserContextMenu"
|
||||||
import FileOpen from "./FileOpen"
|
import FileOpen from "./FileOpen"
|
||||||
import FileUploadButton from "./FileUploadButton"
|
import FileUploadButton from "./FileUploadButton"
|
||||||
@@ -16,15 +16,16 @@ import { ReactComponent as Spinner } from "./../assets/spinner.svg"
|
|||||||
import FileBrowserList from "./FileBrowserList"
|
import FileBrowserList from "./FileBrowserList"
|
||||||
import MoreMenu from "./MoreMenu"
|
import MoreMenu from "./MoreMenu"
|
||||||
import ObjID from "../types/ObjID"
|
import ObjID from "../types/ObjID"
|
||||||
|
import cache from "../Cache"
|
||||||
|
|
||||||
const FileBrowser: React.FC<RouteComponentProps> = (props) => {
|
const FileBrowser: React.FC<RouteComponentProps> = (props) => {
|
||||||
const path = ObjID.fromURI(props.location.pathname)
|
const path = ObjID.fromURI(props.location.pathname)
|
||||||
const [openFileId, setOpenFileId] = useState<ObjID>()
|
const [openFileId, setOpenFileId] = useState<ObjID>()
|
||||||
const [showFile, setShowFile] = useState(false)
|
const [showFile, setShowFile] = useState(false)
|
||||||
|
|
||||||
const [srcID,setSrcID] = useState<ObjID>(path)
|
const [srcID,setSrcID] = useState<ObjID | null>()
|
||||||
const [pasteAction,setPasteAction] = useState<Action>()
|
const [pasteAction,setPasteAction] = useState<Action>()
|
||||||
const [editID,setEditID] = useState<ObjID>(path)
|
const [editID,setEditID] = useState<ObjID>()
|
||||||
const [editEnable,setEditEnable] = useState(false)
|
const [editEnable,setEditEnable] = useState(false)
|
||||||
|
|
||||||
const [deleteMutation] = useDeleteFileMutation()
|
const [deleteMutation] = useDeleteFileMutation()
|
||||||
@@ -32,6 +33,7 @@ const FileBrowser: React.FC<RouteComponentProps> = (props) => {
|
|||||||
const [moveMutation] = useMoveMutation()
|
const [moveMutation] = useMoveMutation()
|
||||||
const [createDirMutation] = useCreateDirMutation()
|
const [createDirMutation] = useCreateDirMutation()
|
||||||
const [deleteDirMutation] = useDeleteDirMutation()
|
const [deleteDirMutation] = useDeleteDirMutation()
|
||||||
|
const [moveDirMutation] = useMoveDirMutation()
|
||||||
|
|
||||||
const { show: showFileContext } = useContextMenu({
|
const { show: showFileContext } = useContextMenu({
|
||||||
id: CONTEXT_MENU_FILE,
|
id: CONTEXT_MENU_FILE,
|
||||||
@@ -66,19 +68,33 @@ const FileBrowser: React.FC<RouteComponentProps> = (props) => {
|
|||||||
break
|
break
|
||||||
case Action.FileCopy:
|
case Action.FileCopy:
|
||||||
case Action.FileMove:
|
case Action.FileMove:
|
||||||
|
case Action.DirMove:
|
||||||
setSrcID(id)
|
setSrcID(id)
|
||||||
setPasteAction(action)
|
setPasteAction(action)
|
||||||
break
|
break
|
||||||
case Action.FilePaste:
|
case Action.FilePaste:
|
||||||
if (pasteAction === Action.FileCopy){
|
if (pasteAction === Action.FileCopy && srcID){
|
||||||
await copyMutation({variables:{src:srcID,dest:path}})
|
await copyMutation({variables:{src:srcID,dest:path}})
|
||||||
refetchDir()
|
refetchDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pasteAction === Action.FileMove){
|
if (pasteAction === Action.FileMove && srcID){
|
||||||
await moveMutation({variables:{src:srcID,dest:path}})
|
await moveMutation({variables:{src:srcID,dest:path}})
|
||||||
refetchDir()
|
refetchDir()
|
||||||
|
setSrcID(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pasteAction === Action.DirMove && srcID){
|
||||||
|
await moveDirMutation({variables:{src:srcID,dest:path}})
|
||||||
|
refetchDir()
|
||||||
|
|
||||||
|
cache.evict({
|
||||||
|
id: `Directory:${srcID.toString()}` // TODO: check for a better way to generate cache ids
|
||||||
|
})
|
||||||
|
|
||||||
|
setSrcID(null)
|
||||||
|
}
|
||||||
|
|
||||||
break
|
break
|
||||||
case Action.DirDelete:
|
case Action.DirDelete:
|
||||||
await deleteDirMutation({variables:{path:id}})
|
await deleteDirMutation({variables:{path:id}})
|
||||||
@@ -102,11 +118,10 @@ const FileBrowser: React.FC<RouteComponentProps> = (props) => {
|
|||||||
onSelect={onContextSelect}
|
onSelect={onContextSelect}
|
||||||
pasteActive={!!srcID}
|
pasteActive={!!srcID}
|
||||||
/>
|
/>
|
||||||
<DragAndDrop
|
<GlobalDragAndDrop
|
||||||
handleDrop={async (files)=>{
|
handleDrop={async (files)=>{
|
||||||
await handleDrop(files)
|
await handleDrop(files)
|
||||||
}}
|
}}/>
|
||||||
>
|
|
||||||
<div className="flex justify-between h-12">
|
<div className="flex justify-between h-12">
|
||||||
<Breadcrum path={path} onDirClick={(newPath)=>{
|
<Breadcrum path={path} onDirClick={(newPath)=>{
|
||||||
props.history.push(newPath.toURI())
|
props.history.push(newPath.toURI())
|
||||||
@@ -179,7 +194,6 @@ const FileBrowser: React.FC<RouteComponentProps> = (props) => {
|
|||||||
<FileOpen id={openFileId} show={showFile} onCloseClick={()=>{
|
<FileOpen id={openFileId} show={showFile} onCloseClick={()=>{
|
||||||
setShowFile(false)
|
setShowFile(false)
|
||||||
}} />
|
}} />
|
||||||
</DragAndDrop>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export enum Action {
|
|||||||
FileMove,
|
FileMove,
|
||||||
FileDownload,
|
FileDownload,
|
||||||
FileRename,
|
FileRename,
|
||||||
DirDelete
|
DirDelete,
|
||||||
|
DirMove,
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -40,7 +41,7 @@ const FileBrowserContextMenu: React.FC<Props> = (props) => {
|
|||||||
</Menu>
|
</Menu>
|
||||||
<Menu id={CONTEXT_MENU_DIR} animation={false} className="dark:bg-gray-400">
|
<Menu id={CONTEXT_MENU_DIR} animation={false} className="dark:bg-gray-400">
|
||||||
<Item onClick={onClick} data={Action.DirDelete} >Delete</Item>
|
<Item onClick={onClick} data={Action.DirDelete} >Delete</Item>
|
||||||
<Item onClick={onClick} >Item 2</Item>
|
<Item onClick={onClick} data={Action.DirMove} >Move</Item>
|
||||||
<Separator />
|
<Separator />
|
||||||
<Item onClick={onClick} data={Action.FilePaste} disabled={!props.pasteActive}>Paste</Item>
|
<Item onClick={onClick} data={Action.FilePaste} disabled={!props.pasteActive}>Paste</Item>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import { Directory, File } from "../generated/graphql"
|
|
||||||
import DirectoryComponent from "./DirectoryElement"
|
|
||||||
import FileElement from "./FileElement"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
file?: File
|
|
||||||
dir?: Directory
|
|
||||||
onClick?: (event: React.MouseEvent ,data: File | Directory) => void
|
|
||||||
onContextMenu?: (e:React.MouseEvent) => void
|
|
||||||
edit: boolean
|
|
||||||
onRename?: (newName: string)=>void
|
|
||||||
onCancleRename?: ()=>void
|
|
||||||
}
|
|
||||||
|
|
||||||
const FileBrowserElement: React.FC<Props> = (props) => {
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
className="hover:bg-gray-100 dark:hover:bg-gray-900 text-lg"
|
|
||||||
onClick={(e)=>{
|
|
||||||
if(props.file){
|
|
||||||
props.onClick?.(e,props.file)
|
|
||||||
}else if(props.dir){
|
|
||||||
props.onClick?.(e,props.dir)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
|
|
||||||
onContextMenu={(e)=>props.onContextMenu?.(e)}
|
|
||||||
|
|
||||||
>
|
|
||||||
{(props.file) ? <FileElement
|
|
||||||
edit={props.edit}
|
|
||||||
file={props.file}
|
|
||||||
onCancleRename={props.onCancleRename}
|
|
||||||
onRename={props.onRename}
|
|
||||||
/>:(props.dir)?<DirectoryComponent
|
|
||||||
dir={props.dir}
|
|
||||||
/>:<></>}
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default FileBrowserElement
|
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import React from "react"
|
import React from "react"
|
||||||
|
import { FaRegFileAlt } from "react-icons/fa"
|
||||||
|
import { MdFolderOpen } from "react-icons/md"
|
||||||
|
import dateFormat from "../functions/dateFomat"
|
||||||
|
import sizeToReadable from "../functions/sizeToReadable"
|
||||||
import { Directory, File } from "../generated/graphql"
|
import { Directory, File } from "../generated/graphql"
|
||||||
import ObjID from "../types/ObjID"
|
import ObjID from "../types/ObjID"
|
||||||
import FileBrowserElement from "./FileBrowserElement"
|
import Renameable from "./Renameable"
|
||||||
|
import Table, { Data, Row } from "./Table"
|
||||||
|
|
||||||
interface Props{
|
interface Props{
|
||||||
directorys: Directory[]
|
directorys: Directory[]
|
||||||
@@ -17,65 +22,59 @@ interface Props{
|
|||||||
onRenameDone?: (id: ObjID, changed: boolean, newName: string)=>void
|
onRenameDone?: (id: ObjID, changed: boolean, newName: string)=>void
|
||||||
}
|
}
|
||||||
|
|
||||||
const FileBrowserList: React.FC<Props> = (props) => {
|
const FileBrowserList: React.FC<Props> = ({
|
||||||
return <>
|
files,directorys,onDirClick,onDirContext,onFileClick,onFileContext,onRenameDone,editId,editEnable
|
||||||
<table className="w-full">
|
}) => {
|
||||||
<thead className="border-b-2 dark:border-gray-900">
|
|
||||||
<tr>
|
|
||||||
<th className="text-left">Name</th>
|
|
||||||
<th className="text-left">Last Modified</th>
|
|
||||||
<th className="text-left">Size</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y dark:divide-gray-900">
|
|
||||||
{ props.directorys.map(v => (<FileBrowserElement
|
|
||||||
key={v.id.toString()}
|
|
||||||
dir={v}
|
|
||||||
onClick={(e,dir)=>{
|
|
||||||
props.onDirClick?.(e,dir.id)
|
|
||||||
}}
|
|
||||||
onContextMenu={(e)=>{
|
|
||||||
props.onDirContext?.(e,v.id)
|
|
||||||
}}
|
|
||||||
|
|
||||||
edit={props.editEnable && (v.id === props.editId)}
|
const tableData: Data = {
|
||||||
|
headers: [
|
||||||
|
{name: "Name"},
|
||||||
|
{name: "Last Modified"},
|
||||||
|
{name: "Size"}
|
||||||
|
],
|
||||||
|
body:[
|
||||||
|
...directorys.map((e):Row<Directory>=>{return {
|
||||||
|
cells:[
|
||||||
|
{
|
||||||
|
name:e.name || "",
|
||||||
|
component: <div>
|
||||||
|
<MdFolderOpen className="inline" />
|
||||||
|
<Renameable
|
||||||
|
text={e.name || ""}
|
||||||
|
edit={editEnable && (e.id === editId)}
|
||||||
|
onRename={(newName)=>{e.name != newName?onRenameDone?.(e.id,true,newName):onRenameDone?.(e.id,false,newName)}}
|
||||||
|
onCancleRename={()=>onRenameDone?.(e.id,false,"")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}],
|
||||||
|
data: e,
|
||||||
|
onClick: ((e,data)=>onDirClick?.(e,data.id)),
|
||||||
|
onContextMenu: ((e,data)=>onDirContext?.(e,data.id))
|
||||||
|
}}),
|
||||||
|
...files.map((e):Row<File>=>{return {
|
||||||
|
cells:[
|
||||||
|
{
|
||||||
|
name:e.name || "",
|
||||||
|
component: <div>
|
||||||
|
<FaRegFileAlt className="inline" />
|
||||||
|
<Renameable
|
||||||
|
text={e.name || ""}
|
||||||
|
edit={editEnable && (e.id === editId)}
|
||||||
|
onRename={(newName)=>{e.name != newName?onRenameDone?.(e.id,true,newName):onRenameDone?.(e.id,false,newName)}}
|
||||||
|
onCancleRename={()=>onRenameDone?.(e.id,false,"")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
},
|
||||||
|
{name:dateFormat(e.lastModified)},{name:sizeToReadable(e.size)}],
|
||||||
|
data: e,
|
||||||
|
onClick: ((e,data)=>onFileClick?.(e,data.id)),
|
||||||
|
onContextMenu: ((e,data)=>onFileContext?.(e,data.id))
|
||||||
|
}}),
|
||||||
|
],
|
||||||
|
|
||||||
onRename={(newName)=>{
|
|
||||||
if (v.name != newName){
|
|
||||||
props.onRenameDone?.(v.id,true,newName)
|
|
||||||
}else{
|
|
||||||
props.onRenameDone?.(v.id,false,newName)
|
|
||||||
}
|
}
|
||||||
}}
|
|
||||||
|
|
||||||
onCancleRename={()=>props.onRenameDone?.(v.id,false,"")}
|
return <Table data={tableData} />
|
||||||
/>))}
|
|
||||||
|
|
||||||
{ props.files.map(v => (<FileBrowserElement
|
|
||||||
key={v.id.toString()}
|
|
||||||
file={v}
|
|
||||||
onClick={(e,file)=>{
|
|
||||||
props.onFileClick?.(e,file.id)
|
|
||||||
}}
|
|
||||||
onContextMenu={(e)=>{
|
|
||||||
props.onFileContext?.(e,v.id)
|
|
||||||
}}
|
|
||||||
|
|
||||||
edit={props.editEnable && (v.id === props.editId)}
|
|
||||||
|
|
||||||
onRename={(newName)=>{
|
|
||||||
if (v.name != newName){
|
|
||||||
props.onRenameDone?.(v.id,true,newName)
|
|
||||||
}else{
|
|
||||||
props.onRenameDone?.(v.id,false,newName)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
|
|
||||||
onCancleRename={()=>props.onRenameDone?.(v.id,false,"")}
|
|
||||||
/>))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default FileBrowserList
|
export default FileBrowserList
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
import React from "react"
|
|
||||||
import { File } from "../generated/graphql"
|
|
||||||
import sizeToReadable from "../functions/sizeToReadable"
|
|
||||||
import dateFormat from "../functions/dateFomat"
|
|
||||||
import { FaRegFileAlt } from "react-icons/fa"
|
|
||||||
import Renameable from "./Renameable"
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
file: File,
|
|
||||||
edit: boolean
|
|
||||||
onRename?: (newName: string)=>void
|
|
||||||
onCancleRename?: ()=>void
|
|
||||||
}
|
|
||||||
|
|
||||||
const FileElement: React.FC<Props> = (props) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<td>
|
|
||||||
<FaRegFileAlt className="inline" />
|
|
||||||
<Renameable
|
|
||||||
text={props.file.name || ""}
|
|
||||||
edit={props.edit}
|
|
||||||
onCancleRename={props.onCancleRename}
|
|
||||||
onRename={props.onRename}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{dateFormat(props.file.lastModified)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{sizeToReadable(props.file.size)}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default FileElement
|
|
||||||
@@ -10,12 +10,10 @@ interface Props {
|
|||||||
handleDrop?: (files: FileList)=>void
|
handleDrop?: (files: FileList)=>void
|
||||||
}
|
}
|
||||||
|
|
||||||
const DragAndDrop: React.FC<Props> = (props) => {
|
const GlobalDragAndDrop: React.FC<Props> = (props) => {
|
||||||
const dropRef = React.createRef<HTMLDivElement>()
|
|
||||||
const [hover,setHover] = useState(false)
|
const [hover,setHover] = useState(false)
|
||||||
|
|
||||||
function handleDragEnter(event: DragEvent) {
|
function handleDragEnter(event: DragEvent) {
|
||||||
// console.debug("dragenter",event)
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
setHover(true)
|
setHover(true)
|
||||||
@@ -23,7 +21,6 @@ const DragAndDrop: React.FC<Props> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDragLeave(event: DragEvent) {
|
function handleDragLeave(event: DragEvent) {
|
||||||
// console.debug("dragleave",event)
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
setHover(false)
|
setHover(false)
|
||||||
@@ -38,7 +35,6 @@ const DragAndDrop: React.FC<Props> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleDrop(event: DragEvent) {
|
function handleDrop(event: DragEvent) {
|
||||||
console.debug("drop",event)
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
setHover(false)
|
setHover(false)
|
||||||
@@ -49,31 +45,25 @@ const DragAndDrop: React.FC<Props> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(()=>{
|
useEffect(()=>{
|
||||||
if(dropRef.current){
|
document.addEventListener("dragenter",handleDragEnter)
|
||||||
dropRef.current.addEventListener("dragenter",handleDragEnter)
|
document.addEventListener("dragleave",handleDragLeave)
|
||||||
dropRef.current.addEventListener("dragleave",handleDragLeave)
|
document.addEventListener("dragover",handleDragOver)
|
||||||
dropRef.current.addEventListener("dragover",handleDragOver)
|
document.addEventListener("drop",handleDrop)
|
||||||
dropRef.current.addEventListener("drop",handleDrop)
|
|
||||||
|
|
||||||
return ()=>{
|
return ()=>{
|
||||||
if (dropRef.current){
|
document.removeEventListener("dragenter",handleDragEnter)
|
||||||
dropRef.current.removeEventListener("dragenter",handleDragEnter)
|
document.removeEventListener("dragleave",handleDragLeave)
|
||||||
dropRef.current.removeEventListener("dragleave",handleDragLeave)
|
document.removeEventListener("dragover",handleDragOver)
|
||||||
dropRef.current.removeEventListener("dragover",handleDragOver)
|
document.removeEventListener("drop",handleDrop)
|
||||||
dropRef.current.removeEventListener("drop",handleDrop)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
},[])
|
},[])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={dropRef}
|
<div className={`fixed top-0 left-0 w-full h-full z-10 pointer-events-none
|
||||||
className={`fixed top-0 left-0 w-full h-full z-10
|
${hover? "border-dashed border-gray-600 border-4":""}`} />
|
||||||
${hover? "border-dashed border-gray-600 border-4":""}`}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default DragAndDrop
|
export default GlobalDragAndDrop
|
||||||
61
src/components/Table.tsx
Normal file
61
src/components/Table.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import React from "react"
|
||||||
|
|
||||||
|
export interface Header {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export interface Row<T = any> {
|
||||||
|
cells: Cell[]
|
||||||
|
data?: T,
|
||||||
|
// TODO: maybe there is some TS magic to apply here so that rowData has the same type as data without using generics
|
||||||
|
onClick?: (e: React.MouseEvent, rowData: T ) => void
|
||||||
|
onContextMenu?: (e: React.MouseEvent, data: T)=>void
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Cell {
|
||||||
|
name: string
|
||||||
|
component?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Data {
|
||||||
|
headers: Header[]
|
||||||
|
body: Row[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: Data
|
||||||
|
}
|
||||||
|
|
||||||
|
const Table: React.FC<Props> = ({data:{headers,body}}) => {
|
||||||
|
const numCol = headers.length
|
||||||
|
return (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="border-b-2 dark:border-gray-900">
|
||||||
|
<tr>
|
||||||
|
{headers.map((e,i)=><th key={i} className="text-left">{e.name}</th>)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y dark:divide-gray-900">
|
||||||
|
{body.map((row,rowIndex)=>
|
||||||
|
<tr
|
||||||
|
className="hover:bg-gray-100 dark:hover:bg-gray-900 text-lg"
|
||||||
|
key={rowIndex}
|
||||||
|
onClick={e=>row.onClick?.(e,row.data)}
|
||||||
|
onContextMenu={e=>row.onContextMenu?.(e,row.data)}
|
||||||
|
>
|
||||||
|
{(row.cells.length >= numCol?
|
||||||
|
row.cells:
|
||||||
|
row.cells.concat(Array(numCol - row.cells.length).fill({name:""}))) // Pad array to fit numCol
|
||||||
|
.map((cell,cellIndex)=>
|
||||||
|
<td key={cellIndex}>
|
||||||
|
{cell.component || cell.name}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Table
|
||||||
@@ -62,7 +62,8 @@ export type RootMutation = {
|
|||||||
delete?: Maybe<Scalars["String"]>;
|
delete?: Maybe<Scalars["String"]>;
|
||||||
deleteDir: Scalars["String"];
|
deleteDir: Scalars["String"];
|
||||||
login: LoginResut;
|
login: LoginResut;
|
||||||
move?: Maybe<File>;
|
move: File;
|
||||||
|
moveDir: Array<Maybe<File>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -98,10 +99,18 @@ export type RootMutationMoveArgs = {
|
|||||||
dest: Scalars["objID"];
|
dest: Scalars["objID"];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type RootMutationMoveDirArgs = {
|
||||||
|
src: Scalars["objID"];
|
||||||
|
dest: Scalars["objID"];
|
||||||
|
};
|
||||||
|
|
||||||
export type RootQuery = {
|
export type RootQuery = {
|
||||||
__typename?: "RootQuery";
|
__typename?: "RootQuery";
|
||||||
/** True if the user is authorized */
|
/** True if the user is authorized */
|
||||||
authorized: Scalars["Boolean"];
|
authorized: Scalars["Boolean"];
|
||||||
|
/** List available buckets */
|
||||||
|
buckets: Array<Maybe<Scalars["String"]>>;
|
||||||
directories: Array<Directory>;
|
directories: Array<Directory>;
|
||||||
file?: Maybe<File>;
|
file?: Maybe<File>;
|
||||||
files: Array<File>;
|
files: Array<File>;
|
||||||
@@ -191,6 +200,14 @@ export type IsAuthQuery = (
|
|||||||
& Pick<RootQuery, "authorized">
|
& Pick<RootQuery, "authorized">
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export type ListBucktesQueryVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
|
|
||||||
|
export type ListBucktesQuery = (
|
||||||
|
{ __typename?: "RootQuery" }
|
||||||
|
& Pick<RootQuery, "buckets">
|
||||||
|
);
|
||||||
|
|
||||||
export type LoginMutationVariables = Exact<{
|
export type LoginMutationVariables = Exact<{
|
||||||
username: Scalars["String"];
|
username: Scalars["String"];
|
||||||
password: Scalars["String"];
|
password: Scalars["String"];
|
||||||
@@ -213,10 +230,24 @@ export type MoveMutationVariables = Exact<{
|
|||||||
|
|
||||||
export type MoveMutation = (
|
export type MoveMutation = (
|
||||||
{ __typename?: "RootMutation" }
|
{ __typename?: "RootMutation" }
|
||||||
& { move?: Maybe<(
|
& { move: (
|
||||||
{ __typename?: "File" }
|
{ __typename?: "File" }
|
||||||
& Pick<File, "id">
|
& Pick<File, "id">
|
||||||
)> }
|
) }
|
||||||
|
);
|
||||||
|
|
||||||
|
export type MoveDirMutationVariables = Exact<{
|
||||||
|
src: Scalars["objID"];
|
||||||
|
dest: Scalars["objID"];
|
||||||
|
}>;
|
||||||
|
|
||||||
|
|
||||||
|
export type MoveDirMutation = (
|
||||||
|
{ __typename?: "RootMutation" }
|
||||||
|
& { moveDir: Array<Maybe<(
|
||||||
|
{ __typename?: "File" }
|
||||||
|
& Pick<File, "id">
|
||||||
|
)>> }
|
||||||
);
|
);
|
||||||
|
|
||||||
export type OpenDirQueryVariables = Exact<{
|
export type OpenDirQueryVariables = Exact<{
|
||||||
@@ -436,6 +467,38 @@ export function useIsAuthLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<IsA
|
|||||||
export type IsAuthQueryHookResult = ReturnType<typeof useIsAuthQuery>;
|
export type IsAuthQueryHookResult = ReturnType<typeof useIsAuthQuery>;
|
||||||
export type IsAuthLazyQueryHookResult = ReturnType<typeof useIsAuthLazyQuery>;
|
export type IsAuthLazyQueryHookResult = ReturnType<typeof useIsAuthLazyQuery>;
|
||||||
export type IsAuthQueryResult = Apollo.QueryResult<IsAuthQuery, IsAuthQueryVariables>;
|
export type IsAuthQueryResult = Apollo.QueryResult<IsAuthQuery, IsAuthQueryVariables>;
|
||||||
|
export const ListBucktesDocument = gql`
|
||||||
|
query listBucktes {
|
||||||
|
buckets
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useListBucktesQuery__
|
||||||
|
*
|
||||||
|
* To run a query within a React component, call `useListBucktesQuery` and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useListBucktesQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||||
|
* you can use to render your UI.
|
||||||
|
*
|
||||||
|
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { data, loading, error } = useListBucktesQuery({
|
||||||
|
* variables: {
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useListBucktesQuery(baseOptions?: Apollo.QueryHookOptions<ListBucktesQuery, ListBucktesQueryVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useQuery<ListBucktesQuery, ListBucktesQueryVariables>(ListBucktesDocument, options)
|
||||||
|
}
|
||||||
|
export function useListBucktesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<ListBucktesQuery, ListBucktesQueryVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useLazyQuery<ListBucktesQuery, ListBucktesQueryVariables>(ListBucktesDocument, options)
|
||||||
|
}
|
||||||
|
export type ListBucktesQueryHookResult = ReturnType<typeof useListBucktesQuery>;
|
||||||
|
export type ListBucktesLazyQueryHookResult = ReturnType<typeof useListBucktesLazyQuery>;
|
||||||
|
export type ListBucktesQueryResult = Apollo.QueryResult<ListBucktesQuery, ListBucktesQueryVariables>;
|
||||||
export const LoginDocument = gql`
|
export const LoginDocument = gql`
|
||||||
mutation Login($username: String!, $password: String!) {
|
mutation Login($username: String!, $password: String!) {
|
||||||
login(username: $username, password: $password) {
|
login(username: $username, password: $password) {
|
||||||
@@ -505,6 +568,40 @@ export function useMoveMutation(baseOptions?: Apollo.MutationHookOptions<MoveMut
|
|||||||
export type MoveMutationHookResult = ReturnType<typeof useMoveMutation>;
|
export type MoveMutationHookResult = ReturnType<typeof useMoveMutation>;
|
||||||
export type MoveMutationResult = Apollo.MutationResult<MoveMutation>;
|
export type MoveMutationResult = Apollo.MutationResult<MoveMutation>;
|
||||||
export type MoveMutationOptions = Apollo.BaseMutationOptions<MoveMutation, MoveMutationVariables>;
|
export type MoveMutationOptions = Apollo.BaseMutationOptions<MoveMutation, MoveMutationVariables>;
|
||||||
|
export const MoveDirDocument = gql`
|
||||||
|
mutation moveDir($src: objID!, $dest: objID!) {
|
||||||
|
moveDir(src: $src, dest: $dest) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
export type MoveDirMutationFn = Apollo.MutationFunction<MoveDirMutation, MoveDirMutationVariables>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* __useMoveDirMutation__
|
||||||
|
*
|
||||||
|
* To run a mutation, you first call `useMoveDirMutation` within a React component and pass it any options that fit your needs.
|
||||||
|
* When your component renders, `useMoveDirMutation` returns a tuple that includes:
|
||||||
|
* - A mutate function that you can call at any time to execute the mutation
|
||||||
|
* - An object with fields that represent the current status of the mutation's execution
|
||||||
|
*
|
||||||
|
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const [moveDirMutation, { data, loading, error }] = useMoveDirMutation({
|
||||||
|
* variables: {
|
||||||
|
* src: // value for 'src'
|
||||||
|
* dest: // value for 'dest'
|
||||||
|
* },
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useMoveDirMutation(baseOptions?: Apollo.MutationHookOptions<MoveDirMutation, MoveDirMutationVariables>) {
|
||||||
|
const options = {...defaultOptions, ...baseOptions}
|
||||||
|
return Apollo.useMutation<MoveDirMutation, MoveDirMutationVariables>(MoveDirDocument, options)
|
||||||
|
}
|
||||||
|
export type MoveDirMutationHookResult = ReturnType<typeof useMoveDirMutation>;
|
||||||
|
export type MoveDirMutationResult = Apollo.MutationResult<MoveDirMutation>;
|
||||||
|
export type MoveDirMutationOptions = Apollo.BaseMutationOptions<MoveDirMutation, MoveDirMutationVariables>;
|
||||||
export const OpenDirDocument = gql`
|
export const OpenDirDocument = gql`
|
||||||
query openDir($path: objID!) {
|
query openDir($path: objID!) {
|
||||||
files(path: $path) {
|
files(path: $path) {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
3
src/graphql/listBuckets.graphql
Normal file
3
src/graphql/listBuckets.graphql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
query listBucktes{
|
||||||
|
buckets
|
||||||
|
}
|
||||||
5
src/graphql/moveDir.graphql
Normal file
5
src/graphql/moveDir.graphql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mutation moveDir($src: objID!, $dest: objID!) {
|
||||||
|
moveDir(src: $src,dest: $dest){
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,62 +2,13 @@ import React from "react"
|
|||||||
import ReactDOM from "react-dom"
|
import ReactDOM from "react-dom"
|
||||||
import "./index.scss"
|
import "./index.scss"
|
||||||
import App from "./App"
|
import App from "./App"
|
||||||
import { ApolloClient, ApolloProvider, InMemoryCache } from "@apollo/client"
|
import { ApolloClient, ApolloProvider } from "@apollo/client"
|
||||||
import "react-contexify/dist/ReactContexify.css"
|
import "react-contexify/dist/ReactContexify.css"
|
||||||
import ObjID from "./types/ObjID"
|
import cache from "./Cache"
|
||||||
|
|
||||||
const client = new ApolloClient({
|
const client = new ApolloClient({
|
||||||
uri: "/api/graphql",
|
uri: "/api/graphql",
|
||||||
cache: new InMemoryCache({
|
cache: cache,
|
||||||
typePolicies:{
|
|
||||||
File:{
|
|
||||||
fields:{
|
|
||||||
id:{
|
|
||||||
merge(_,incomming){
|
|
||||||
// HACK: i use the merge function to change the id from a string to ObjID object.
|
|
||||||
// afaik apollo does not yet support custom scalar types.
|
|
||||||
if (!incomming){
|
|
||||||
return incomming
|
|
||||||
}else if (incomming instanceof ObjID){
|
|
||||||
return incomming
|
|
||||||
}else{
|
|
||||||
return ObjID.fromString(incomming as string)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Directory:{
|
|
||||||
fields:{
|
|
||||||
id:{
|
|
||||||
merge(_,incomming){
|
|
||||||
if (!incomming){
|
|
||||||
return incomming
|
|
||||||
}else if (incomming instanceof ObjID){
|
|
||||||
return incomming
|
|
||||||
}else{
|
|
||||||
return ObjID.fromString(incomming as string)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Query: {
|
|
||||||
fields: {
|
|
||||||
files: {
|
|
||||||
merge(existing, incoming){
|
|
||||||
return incoming
|
|
||||||
}
|
|
||||||
},
|
|
||||||
directorys:{
|
|
||||||
merge(existing, incoming){
|
|
||||||
return incoming
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// Disable drag and drop behaviour on document
|
// Disable drag and drop behaviour on document
|
||||||
|
|||||||
@@ -53,6 +53,30 @@ class ObjID {
|
|||||||
return new ObjID(this.bucket,parts.join("/"))
|
return new ObjID(this.bucket,parts.join("/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the parent of the object. If Obj is a file then the containing directory
|
||||||
|
* if Obj is a directory it returns the parent
|
||||||
|
* @returns parent ObjID or null if already at root
|
||||||
|
*/
|
||||||
|
public parent(): ObjID | null {
|
||||||
|
if (this.key == "/") {
|
||||||
|
// Already at root. We dont have a parent
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isDirectory()) {
|
||||||
|
const parts = this.key.split("/")
|
||||||
|
const parent = new ObjID(this.bucket, parts.slice(0,-2).join("/") + "/")
|
||||||
|
parent.normalize()
|
||||||
|
return parent
|
||||||
|
} else {
|
||||||
|
const parts = this.key.split("/")
|
||||||
|
const parent = new ObjID(this.bucket, parts.slice(0,-1).join("/") + "/")
|
||||||
|
parent.normalize()
|
||||||
|
return parent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static fromString(from: string): ObjID{
|
public static fromString(from: string): ObjID{
|
||||||
const match = stringRegex.exec(from)
|
const match = stringRegex.exec(from)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user