feat: files draft

This commit is contained in:
Tristan Yang
2023-08-01 20:48:21 +08:00
parent 2150213a25
commit 51c2067bd1
14 changed files with 616 additions and 9 deletions
+2 -2
View File
@@ -32,7 +32,7 @@ import { updateInfo } from "../slices/server";
import { updateCallInfo, upsertVoiceList } from "../slices/voice";
import { RootState } from "../store";
import baseQuery from "./base.query";
import { File, GetFilesDTO } from "@/types/resource";
import { GetFilesDTO, VoceChatFile } from "@/types/resource";
export const serverApi = createApi({
reducerPath: "serverApi",
@@ -239,7 +239,7 @@ export const serverApi = createApi({
getLoginConfig: builder.query<LoginConfig, void>({
query: () => ({ url: `/admin/login/config` })
}),
getFiles: builder.query<File[], GetFilesDTO>({
getFiles: builder.query<VoceChatFile[], GetFilesDTO>({
query: (params) => ({
url: `/admin/system/files?${new URLSearchParams(
params as Record<string, string>
+28
View File
@@ -0,0 +1,28 @@
import { VoceChatFile } from "@/types/resource";
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
const initialState: VoceChatFile[] = [];
const filesSlice = createSlice({
name: `files`,
initialState,
reducers: {
fillFiles(state, action: PayloadAction<VoceChatFile[]>) {
return action.payload;
},
addFile(state, action: PayloadAction<VoceChatFile>) {
state.push(action.payload);
},
deleteFile(state, action: PayloadAction<string>) {
const content = action.payload;
const idx = state.findIndex((f) => f.content == content);
if (idx > -1) {
state.splice(idx, 1);
}
}
}
});
export const { addFile, deleteFile, fillFiles } = filesSlice.actions;
export default filesSlice.reducer;
+2
View File
@@ -13,6 +13,7 @@ import channelsReducer from "./slices/channels";
import favoritesReducer from "./slices/favorites";
import footprintReducer from "./slices/footprint";
import messageReducer from "./slices/message";
import filesReducer from "./slices/files";
import archiveMsgReducer from "./slices/message.archive";
import channelMsgReducer from "./slices/message.channel";
import fileMsgReducer from "./slices/message.file";
@@ -38,6 +39,7 @@ const reducer = combineReducers({
fileMessage: fileMsgReducer,
archiveMessage: archiveMsgReducer,
message: messageReducer,
files: filesReducer,
[authApi.reducerPath]: authApi.reducer,
[messageApi.reducerPath]: messageApi.reducer,
[userApi.reducerPath]: userApi.reducer,
+19
View File
@@ -7,6 +7,25 @@
}
}
@layer utilities {
.col-count-2 {
column-count: 2;
}
.col-count-3 {
column-count: 3;
}
.col-count-4 {
column-count: 4;
}
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* word break */
.wb {
word-break: break-word;
+47
View File
@@ -0,0 +1,47 @@
import { FC } from "react";
import ChannelIcon from "@/components/ChannelIcon";
import useFilteredChannels from "@/hooks/useFilteredChannels";
import CheckSign from "@/assets/icons/check.sign.svg";
type Props = {
select: number;
updateFilter: (param: { channel?: number }) => void;
};
const Channel: FC<Props> = ({ select = 0, updateFilter }) => {
const { channels } = useFilteredChannels();
const handleClick = (gid?: number) => {
updateFilter({ channel: gid });
};
return (
<div className="rounded-lg p-1 pt-0 bg-white dark:bg-gray-800 overflow-auto max-h-[400px] flex flex-col items-start relative drop-shadow">
<ul className="w-full flex flex-col gap-4 p-2">
<li
className="relative cursor-pointer flex items-center gap-2"
onClick={handleClick.bind(null, undefined)}
>
<ChannelIcon />
<span className="text-gray-500 dark:text-gray-100 font-semibold text-sm">
Any Channel
</span>
{!select && <CheckSign className="absolute right-0 top-1/2 -translate-y-1/2" />}
</li>
{channels.map(({ gid, is_public, name }) => {
return (
<li
key={gid}
className="relative cursor-pointer flex items-center gap-2"
onClick={handleClick.bind(null, gid)}
>
<ChannelIcon personal={!is_public} />
<span className="text-gray-500 dark:text-gray-100 font-semibold text-sm">{name}</span>
{select == gid && <CheckSign className="absolute right-0 top-1/2 -translate-y-1/2" />}
</li>
);
})}
</ul>
</div>
);
};
export default Channel;
+60
View File
@@ -0,0 +1,60 @@
import { FC } from "react";
import CheckSign from "@/assets/icons/check.sign.svg";
export const Dates = {
today: {
title: "Today",
duration: 2222
},
in7d: {
title: "Last 7 Days"
},
in30d: {
title: "Last 30 Days"
},
in3m: {
title: "Last 3 months"
},
in12m: {
title: "Last 12 months"
}
};
type Props = {
select: number;
updateFilter: (param: { date?: string }) => void;
};
const DateFilter: FC<Props> = ({ select = "", updateFilter }) => {
const handleClick = (dur?: string) => {
updateFilter({ date: dur });
};
return (
<div className="p-3 bg-white dark:bg-gray-800 min-w-[200px] overflow-auto rounded-lg flex flex-col items-start relative drop-shadow">
<ul className="w-full flex flex-col gap-4">
<li
className="relative cursor-pointer flex items-center gap-4 text-gray-500 dark:text-gray-300 font-semibold text-sm"
onClick={handleClick.bind(null, undefined)}
>
Any Time
{!select && <CheckSign className="absolute right-0 top-1/2 -translate-y-1/2" />}
</li>
{Object.entries(Dates).map(([_key, { title }]) => {
return (
<li
key={title}
className="relative cursor-pointer flex items-center gap-4 text-gray-500 dark:text-gray-300 font-semibold text-sm"
onClick={handleClick.bind(null, _key)}
>
{title}
{select == _key && (
<CheckSign className="absolute right-0 -top-1/2 -translate-y-1/2" />
)}
</li>
);
})}
</ul>
</div>
);
};
export default DateFilter;
+45
View File
@@ -0,0 +1,45 @@
import { FC } from "react";
import User from "@/components/User";
import useFilteredUsers from "@/hooks/useFilteredUsers";
import CheckSign from "@/assets/icons/check.sign.svg";
import Search from "../Search";
type Props = {
select: number;
updateFilter: (param: { from?: number }) => void;
};
const From: FC<Props> = ({ select = "", updateFilter }) => {
const { input, updateInput, users } = useFilteredUsers();
const handleClick = (uid?: number) => {
updateFilter({ from: uid });
};
return (
<div className="rounded-lg p-1 pt-0 bg-white dark:bg-gray-800 overflow-auto max-h-[300px] flex flex-col items-start relative drop-shadow">
<div className="bg-white dark:bg-gray-800 sticky top-0 z-10 w-full">
<Search embed={true} value={input} updateSearchValue={updateInput} />
</div>
<ul className="w-full flex flex-col">
<li
className="relative cursor-pointer p-2.5 font-semibold text-sm text-gray-500"
onClick={handleClick.bind(null, undefined)}
>
Anyone
{!select && <CheckSign className="absolute right-1.5 top-1/2 -translate-y-1/2" />}
</li>
{users.map(({ uid }) => {
return (
<li key={uid} className="relative cursor-pointer" onClick={handleClick.bind(null, uid)}>
<User uid={uid} interactive={true} />
{select == uid && (
<CheckSign className="absolute right-1.5 top-1/2 -translate-y-1/2" />
)}
</li>
);
})}
</ul>
</div>
);
};
export default From;
+79
View File
@@ -0,0 +1,79 @@
import { FC } from "react";
import CheckSign from "@/assets/icons/check.sign.svg";
import IconAudio from "@/assets/icons/file.audio.svg";
import IconCode from "@/assets/icons/file.code.svg";
import IconDoc from "@/assets/icons/file.doc.svg";
import IconImage from "@/assets/icons/file.image.svg";
import IconPdf from "@/assets/icons/file.pdf.svg";
import IconUnknown from "@/assets/icons/file.unknown.svg";
import IconVideo from "@/assets/icons/file.video.svg";
export const FileTypes = {
doc: {
title: "Documents",
icon: <IconDoc className="w-4 h-auto" />
},
pdf: {
title: "PDFs",
icon: <IconPdf className="w-4 h-auto" />
},
image: {
title: "Images",
icon: <IconImage className="w-4 h-auto" />
},
audio: {
title: "Audio",
icon: <IconAudio className="w-4 h-auto" />
},
video: {
title: "Videos",
icon: <IconVideo className="w-4 h-auto" />
},
code: {
title: "Code Snippets",
icon: <IconCode className="w-4 h-auto" />
},
unknown: {
title: "Unknown Files",
icon: <IconUnknown className="w-4 h-auto" />
}
};
type Props = {
select: number;
updateFilter: (param: { type?: string }) => void;
};
const Type: FC<Props> = ({ select = "", updateFilter }) => {
const handleClick = (type?: string) => {
updateFilter({ type });
};
return (
<div className="p-3 bg-white dark:bg-gray-800 min-w-[180px] overflow-auto shadow-md rounded-lg flex flex-col items-start relative">
<ul className="w-full flex flex-col gap-4">
<li
className="relative cursor-pointer flex items-center gap-4 text-gray-500 dark:text-gray-300 font-semibold text-sm"
onClick={handleClick.bind(null, undefined)}
>
Any Type
{!select && <CheckSign className="absolute right-0 top-1/2 -translate-y-1/2" />}
</li>
{Object.entries(FileTypes).map(([type, { title, icon }]) => {
return (
<li
key={title}
className="relative cursor-pointer flex items-center gap-2 text-sm text-gray-500 dark:text-gray-300 font-semibold"
onClick={handleClick.bind(null, type)}
>
{icon} {title}
{select == type && (
<CheckSign className="absolute right-0 top-1/2 -translate-y-1/2" />
)}
</li>
);
})}
</ul>
</div>
);
};
export default Type;
+123
View File
@@ -0,0 +1,123 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import Tippy from "@tippyjs/react";
import clsx from "clsx";
import { useAppSelector } from "@/app/store";
import Avatar from "@/components/Avatar";
import ArrowDown from "@/assets/icons/arrow.down.svg";
import FilterChannel from "./Channel";
import FilterDate, { Dates } from "./Date";
import FilterFrom from "./From";
import FilterType, { FileTypes } from "./Type";
const getClass = (selected: boolean) => {
return clsx(
`cursor-pointer flex items-center gap-1 md:gap-2 shadow rounded-lg p-1 md:py-2 md:px-3 text-xs text-gray-900 dark:text-gray-200`,
selected
? "text-white bg-primary-400"
: "border border-solid border-gray-300 dark:border-gray-400 "
);
};
export default function Filter({ filter, updateFilter }) {
const { t } = useTranslation("file");
const [filtersVisible, setFiltersVisible] = useState({
channel: false,
date: false,
from: false,
type: false
});
const toggleFilterVisible = (obj: any) => {
setFiltersVisible((prev) => {
return { ...prev, ...obj };
});
};
const handleUpdateFilter = (data: any) => {
updateFilter(data);
let _key = Object.keys(data)[0];
let tmp = {
[_key]: false
};
toggleFilterVisible(tmp);
};
const { userMap, channelMap } = useAppSelector((store) => {
return { userMap: store.users.byId, channelMap: store.channels.byId };
});
const { from, channel, type, date } = filter;
const {
channel: channelVisible,
date: dateVisible,
type: typeVisible,
from: fromVisible
} = filtersVisible;
return (
<div className="flex items-center gap-2">
<Tippy
interactive
onClickOutside={toggleFilterVisible.bind(null, { from: false })}
visible={fromVisible}
placement="bottom-start"
content={<FilterFrom select={filter.from} updateFilter={handleUpdateFilter} />}
>
<div className={getClass(from)} onClick={toggleFilterVisible.bind(null, { from: true })}>
{from && (
<Avatar
width={16}
height={16}
className="rounded-full w-4 h-4"
name={userMap[from].name}
src={userMap[from].avatar}
/>
)}
<span className="txt">
{t("from")} {from && userMap[from].name}
</span>
<ArrowDown className="dark:stroke-gray-100" />
</div>
</Tippy>
<Tippy
interactive
onClickOutside={toggleFilterVisible.bind(null, { channel: false })}
visible={channelVisible}
placement="bottom-start"
content={<FilterChannel select={filter.channel} updateFilter={handleUpdateFilter} />}
>
<div
className={getClass(channel)}
onClick={toggleFilterVisible.bind(null, { channel: true })}
>
<span className="txt">{channel ? `In ${channelMap[channel].name}` : t("channel")}</span>
<ArrowDown className="dark:stroke-gray-100" />
</div>
</Tippy>
<Tippy
interactive
onClickOutside={toggleFilterVisible.bind(null, { type: false })}
visible={typeVisible}
placement="bottom-start"
content={<FilterType select={filter.type} updateFilter={handleUpdateFilter} />}
>
<div className={getClass(type)} onClick={toggleFilterVisible.bind(null, { type: true })}>
<span className="txt">{type ? FileTypes[type].title : t("type")}</span>
<ArrowDown className="dark:stroke-gray-100" />
</div>
</Tippy>
<Tippy
interactive
onClickOutside={toggleFilterVisible.bind(null, { date: false })}
visible={dateVisible}
placement="bottom-start"
content={<FilterDate select={filter.date} updateFilter={handleUpdateFilter} />}
>
<div className={getClass(date)} onClick={toggleFilterVisible.bind(null, { date: true })}>
<span className="txt">{date ? Dates[date].title : t("date")}</span>
<ArrowDown className="dark:stroke-gray-100" />
</div>
</Tippy>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { ChangeEvent, FC } from "react";
import { useTranslation } from "react-i18next";
import clsx from "clsx";
import IconSearch from "@/assets/icons/search.svg";
interface Props {
value?: string;
updateSearchValue?: (value: string) => void;
embed?: boolean;
}
const Search: FC<Props> = ({ value = "", updateSearchValue = null, embed = false }) => {
const { t } = useTranslation();
const handleChange = (evt: ChangeEvent<HTMLInputElement>) => {
if (updateSearchValue) {
updateSearchValue(evt.target.value);
}
};
return (
<div
className={clsx(
`hidden md:block relative w-full py-1.5 px-4 shadow`,
embed && "py-2 shadow-none"
)}
>
<IconSearch className="absolute left-6 top-1/2 -translate-y-1/2" />
<input
value={value}
onChange={handleChange}
className="bg-black/5 dark:bg-black/20 rounded-full text-sm text-gray-500 py-2.5 pl-9"
placeholder={`${t("action.search")}...`}
/>
</div>
);
};
export default Search;
+37
View File
@@ -0,0 +1,37 @@
import { MouseEvent } from "react";
import { useDispatch } from "react-redux";
import clsx from "clsx";
import { updateFileListView } from "@/app/slices/ui";
import IconGrid from "@/assets/icons/file.grid.svg";
import IconList from "@/assets/icons/file.list.svg";
const getClass = (selected: boolean) =>
clsx(
`cursor-pointer p-2 box-border flex-center`,
selected && `border border-solid border-primary-400 shadow rounded-lg`
);
type Props = {
view?: "item" | "grid";
};
export default function View({ view = "item" }: Props) {
const dispatch = useDispatch();
const handleChangeView = (evt: MouseEvent<HTMLLIElement>) => {
const { view: clickView } = evt.currentTarget.dataset;
if (clickView == view) return;
dispatch(updateFileListView(view == "item" ? "grid" : "item"));
};
const isGrid = view == "grid";
return (
<ul
className={`hidden md:flex border border-solid dark:border-gray-400 shadow rounded-lg box-border`}
>
<li className={getClass(!isGrid)} data-view={"item"} onClick={handleChangeView}>
<IconList className={`${!isGrid ? "fill-primary-400" : ""} dark:fill-gray-400`} />
</li>
<li className={getClass(isGrid)} data-view={"grid"} onClick={handleChangeView}>
<IconGrid className={`${isGrid ? "fill-primary-400" : ""} dark:fill-gray-400`} />
</li>
</ul>
);
}
+108
View File
@@ -0,0 +1,108 @@
// @ts-nocheck
import { useEffect, useRef, useState } from "react";
import clsx from "clsx";
import BASE_URL from "@/app/config";
import { useAppSelector } from "@/app/store";
import FileBox from "@/components/FileBox";
import Filter from "./Filter";
import Search from "./Search";
import View from "./View";
import { useLazyGetFilesQuery } from "@/app/services/server";
const checkFilter = (data, filter, channelMessage) => {
let selected = true;
const { mid, from_uid, properties } = data;
const { name: nameFilter, from: fromFilter, channel: channelFilter } = filter;
const name = properties ? properties.name : "";
if (fromFilter && fromFilter != from_uid) {
selected = false;
}
if (channelFilter && channelMessage[channelFilter].findIndex((id) => id == mid) == -1) {
selected = false;
}
if (nameFilter) {
let str = ["", ...nameFilter.toLowerCase(), ""].join(".*");
let reg = new RegExp(str);
if (!reg.test(name)) {
selected = false;
}
}
return selected;
};
function Files() {
const [getFiles, { data }] = useLazyGetFilesQuery();
const listContainerRef = useRef<HTMLDivElement>();
const [filter, setFilter] = useState({});
const { view } = useAppSelector((store) => {
return {
view: store.ui.fileListView
};
});
const updateFilter = (data) => {
setFilter((prev) => {
return { ...prev, ...data };
});
};
const handleUpdateSearch = (val) => {
setFilter((prev) => {
return { ...prev, name: val };
});
};
useEffect(() => {
getFiles();
}, []);
if (!data) return null;
// return null;
const nonExpiredFiles = data.filter((item) => !item.expired);
console.log("nonExpiredFiles", nonExpiredFiles);
return (
<div className="h-screen md:overflow-y-scroll flex flex-col items-start my-2 mr-6 rounded-2xl bg-white dark:bg-gray-700">
<Search value={filter.name} updateSearchValue={handleUpdateSearch} />
<div className="flex justify-between w-full px-4 py-5">
<Filter filter={filter} updateFilter={updateFilter} />
<View view={view} />
</div>
<div
className={clsx(
`h-full w-full px-4 overflow-y-scroll no-scrollbar`,
view == "item" && "flex gap-2 flex-col",
view == "grid" && "col-count-2 md:col-count-3 lg:col-count-5"
)}
style={{
columnGap: "5px"
}}
ref={listContainerRef}
>
{nonExpiredFiles.map((file) => {
const { mid, thumbnail, content, created_at, from_uid, properties } = file;
const { name, content_type, size } = properties ? JSON.parse(properties) : {};
const url = `${BASE_URL}/resource/file?file_path=${encodeURIComponent(
thumbnail || content
)}`;
return (
// <div key={mid} className="grid-box mb-2">
<FileBox
cla
preview={view == "grid"}
flex={view == "item"}
key={mid}
file_type={content_type}
content={url}
created_at={created_at}
from_uid={from_uid}
size={size}
name={name}
/>
// </div>
);
})}
</div>
</div>
);
}
export default Files;
+6 -7
View File
@@ -10,7 +10,7 @@ import useDeviceToken from "@/components/Notification/useDeviceToken";
import RequireAuth from "@/components/RequireAuth";
import RequireNoAuth from "@/components/RequireNoAuth";
import RequireSingleTab from "@/components/RequireSingleTab";
import { isElectronContext } from "@/utils";
import { compareVersion, isElectronContext } from "@/utils";
import { vapidKey } from "../app/config";
import store, { useAppSelector } from "../app/store";
import NotFoundPage from "./404";
@@ -18,9 +18,6 @@ import InvitePrivate from "./invitePrivate";
import LazyIt from "./lazy";
import InviteInMobile from "./reg/InviteInMobile";
// import Welcome from './Welcome'
// const HomePage = lazy(() => import("./home"));
// const ChatPage = lazy(() => import("./chat"));
const RegBasePage = lazy(() => import("./reg"));
const RegWithUsernamePage = lazy(() => import("./reg/RegWithUsername"));
const SendMagicLinkPage = lazy(() => import("./sendMagicLink"));
@@ -35,6 +32,7 @@ const SettingChannelPage = lazy(() => import("./settingChannel"));
const SettingDMPage = lazy(() => import("./settingDM"));
const SettingPage = lazy(() => import("./setting"));
const ResourceManagement = lazy(() => import("./resources"));
const FilesPage = lazy(() => import("./files"));
const GuestLogin = lazy(() => import("./guest"));
const ChatPage = lazy(() => import("./chat"));
const HomePage = lazy(() => import("./home"));
@@ -42,9 +40,10 @@ const HomePage = lazy(() => import("./home"));
let toastId: string;
const PageRoutes = () => {
const {
ui: { online }
ui: { online },
version
} = useAppSelector((store) => {
return { ui: store.ui };
return { ui: store.ui, version: store.server.version };
}, isEqual);
// 提前获取device token
useDeviceToken(vapidKey);
@@ -284,7 +283,7 @@ const PageRoutes = () => {
path="files"
element={
<LazyIt>
<ResourceManagement />
{compareVersion(version, "0.3.11") > -1 ? <FilesPage /> : <ResourceManagement />}
</LazyIt>
}
></Route>
+21
View File
@@ -63,3 +63,24 @@ export interface OG {
locale_alternate?: [];
site_name?: string;
}
export interface VoceChatFile {
mid: number;
from_uid: number;
gid: number;
ext: string;
content_type: string;
content: string;
properties: string;
created_at: number;
expired: boolean;
}
export type FileType = "Doc" | "PDF" | "Image" | "Audio" | "Video";
export type FileCreateTime = "Day1" | "Day7" | "Day30" | "Day90" | "Day180";
export interface GetFilesDTO {
uid?: number;
gid?: number;
file_type?: FileType;
creation_time_type?: FileCreateTime;
page?: number;
page_size?: number;
}