Version 0.7.0

This commit is contained in:
Aslan 2026-01-20 14:09:05 -05:00
parent 64ad8498f5
commit 6b6bbdc142
112 changed files with 3828 additions and 188 deletions

View file

@ -1,6 +1,6 @@
{ {
"name": "pulsar-web", "name": "pulsar-web",
"version": "0.6.0", "version": "0.7.0",
"description": "", "description": "",
"type": "module", "type": "module",
"scripts": { "scripts": {

View file

@ -4,6 +4,7 @@ import { StartView } from "./views/StartView";
import { AppView } from "./views/AppView"; import { AppView } from "./views/AppView";
import { LoginView } from "./views/LoginView"; import { LoginView } from "./views/LoginView";
import { RegisterView } from "./views/RegisterView"; import { RegisterView } from "./views/RegisterView";
import { InviteView } from "./views/InviteView";
const App: Component = () => { const App: Component = () => {
return ( return (
@ -12,6 +13,7 @@ const App: Component = () => {
<Route path="/app" component={AppView} /> <Route path="/app" component={AppView} />
<Route path="/login" component={LoginView} /> <Route path="/login" component={LoginView} />
<Route path="/register" component={RegisterView} /> <Route path="/register" component={RegisterView} />
<Route path="/invite/:id" component={InviteView} />
</Router> </Router>
); );
}; };

View file

@ -4,6 +4,8 @@ interface IFetchChannel {
id: string; id: string;
name: string; name: string;
description?: string; description?: string;
category: string;
order: number;
communityId: string; communityId: string;
creationDate: number; creationDate: number;
} }
@ -25,6 +27,7 @@ interface IUpdateChannelRequest {
id: string; id: string;
name?: string; name?: string;
description?: string; description?: string;
category?: string | null;
} }
interface IUpdateChannelResponse extends IResponseSuccess, IFetchChannel {} interface IUpdateChannelResponse extends IResponseSuccess, IFetchChannel {}

View file

@ -17,6 +17,14 @@ import {
IFetchCommunityMembersResponse, IFetchCommunityMembersResponse,
IFetchCommunityInvitesRequest, IFetchCommunityInvitesRequest,
IFetchCommunityInvitesResponse, IFetchCommunityInvitesResponse,
IUpdateCommunityChannelOrderRequest,
IUpdateCommunityChannelOrderResponse,
IUpdateCommunityRoleOrderRequest,
IUpdateCommunityRoleOrderResponse,
IRemoveCommunityMemberRequest,
IRemoveCommunityMemberResponse,
ICreateCommunityInviteRequest,
ICreateCommunityInviteResponse,
} from "./types"; } from "./types";
const fetchCommunityApi = async ( const fetchCommunityApi = async (
@ -67,6 +75,45 @@ const fetchCommunityInvitesApi = async (
return await callApi(HTTP.GET, `community/${request.id}/invites`); return await callApi(HTTP.GET, `community/${request.id}/invites`);
}; };
const updateCommunityChannelOrderApi = async (
request: IUpdateCommunityChannelOrderRequest,
): Promise<IUpdateCommunityChannelOrderResponse | IResponseError> => {
return await callApi(
HTTP.PATCH,
`community/${request.id}/channels/order`,
request,
);
};
const updateCommunityRoleOrderApi = async (
request: IUpdateCommunityRoleOrderRequest,
): Promise<IUpdateCommunityRoleOrderResponse | IResponseError> => {
return await callApi(
HTTP.PATCH,
`community/${request.id}/roles/order`,
request,
);
};
const removeCommunityMemberApi = async (
request: IRemoveCommunityMemberRequest,
): Promise<IRemoveCommunityMemberResponse | IResponseError> => {
return await callApi(
HTTP.DELETE,
`community/${request.id}/members/${request.memberId}`,
);
};
const createCommunityInviteApi = async (
request: ICreateCommunityInviteRequest,
): Promise<ICreateCommunityInviteResponse | IResponseError> => {
return await callApi(
HTTP.POST,
`community/${request.communityId}/invite`,
request,
);
};
export { export {
fetchCommunityApi, fetchCommunityApi,
createCommunityApi, createCommunityApi,
@ -76,4 +123,8 @@ export {
fetchCommunityRolesApi, fetchCommunityRolesApi,
fetchCommunityMembersApi, fetchCommunityMembersApi,
fetchCommunityInvitesApi, fetchCommunityInvitesApi,
updateCommunityChannelOrderApi,
updateCommunityRoleOrderApi,
removeCommunityMemberApi,
createCommunityInviteApi,
}; };

View file

@ -48,6 +48,9 @@ interface IFetchCommunityChannelsResponse extends IResponseSuccess {
interface IFetchCommunityChannel { interface IFetchCommunityChannel {
id: string; id: string;
name: string; name: string;
description: string;
category: string;
order: number;
} }
interface IFetchCommunityRolesRequest { interface IFetchCommunityRolesRequest {
@ -62,6 +65,9 @@ interface IFetchCommunityRolesResponse extends IResponseSuccess {
interface IFetchCommunityRole { interface IFetchCommunityRole {
id: string; id: string;
name: string; name: string;
color: string;
order: number;
showInMembers: boolean;
} }
interface IFetchCommunityMembersRequest { interface IFetchCommunityMembersRequest {
@ -92,6 +98,45 @@ interface IFetchCommunityInvite {
id: string; id: string;
} }
interface IUpdateCommunityChannelOrderRequest {
id: string;
order: string[];
}
interface IUpdateCommunityChannelOrderResponse extends IResponseSuccess {
id: string;
}
interface IUpdateCommunityRoleOrderRequest {
id: string;
order: string[];
}
interface IUpdateCommunityRoleOrderResponse extends IResponseSuccess {
id: string;
}
interface IRemoveCommunityMemberRequest {
id: string;
memberId: string;
}
interface IRemoveCommunityMemberResponse extends IResponseSuccess {
id: string;
userId: string;
}
interface ICreateCommunityInviteRequest {
communityId: string;
totalInvites?: number;
expirationDate?: number;
}
interface ICreateCommunityInviteResponse extends IResponseSuccess {
id: string;
inviteId: string;
}
export { export {
type IFetchCommunity, type IFetchCommunity,
type IFetchCommunityRequest, type IFetchCommunityRequest,
@ -114,4 +159,12 @@ export {
type IFetchCommunityInvitesRequest, type IFetchCommunityInvitesRequest,
type IFetchCommunityInvitesResponse, type IFetchCommunityInvitesResponse,
type IFetchCommunityInvite, type IFetchCommunityInvite,
type IUpdateCommunityChannelOrderRequest,
type IUpdateCommunityChannelOrderResponse,
type IUpdateCommunityRoleOrderRequest,
type IUpdateCommunityRoleOrderResponse,
type IRemoveCommunityMemberRequest,
type IRemoveCommunityMemberResponse,
type ICreateCommunityInviteRequest,
type ICreateCommunityInviteResponse,
}; };

View file

@ -17,6 +17,7 @@ interface IUploadCommunityAvatarResponse extends IResponseSuccess {}
interface IFetchAttachment { interface IFetchAttachment {
id: string; id: string;
iv: string;
filename: string; filename: string;
mimetype: string; mimetype: string;
size: number; size: number;
@ -33,6 +34,7 @@ interface IFetchAttachmentRequest {
interface IFetchAttachmentResponse extends IResponseSuccess, IFetchAttachment {} interface IFetchAttachmentResponse extends IResponseSuccess, IFetchAttachment {}
interface ICreateAttachmentRequest { interface ICreateAttachmentRequest {
iv: string;
filename: string; filename: string;
mimetype: string; mimetype: string;
size: number; size: number;

View file

@ -32,6 +32,7 @@ interface ICreateMessageResponse extends IResponseSuccess, IFetchMessage {}
interface IUpdateMessageRequest { interface IUpdateMessageRequest {
id: string; id: string;
iv: string;
text: string; text: string;
} }

View file

@ -9,6 +9,11 @@ import {
IUpdateRoleResponse, IUpdateRoleResponse,
IRemoveRoleRequest, IRemoveRoleRequest,
IRemoveRoleResponse, IRemoveRoleResponse,
IAssignRoleRequest,
IAssignRoleResponse,
IUnssignRoleRequest,
IUnssignRoleResponse,
IFetchPermissionsResponse,
} from "./types"; } from "./types";
const fetchRoleApi = async ( const fetchRoleApi = async (
@ -35,4 +40,30 @@ const removeRoleApi = async (
return await callApi(HTTP.DELETE, `role/${request.id}`); return await callApi(HTTP.DELETE, `role/${request.id}`);
}; };
export { fetchRoleApi, createRoleApi, updateRoleApi, removeRoleApi }; const assignRoleApi = async (
request: IAssignRoleRequest,
): Promise<IAssignRoleResponse | IResponseError> => {
return await callApi(HTTP.POST, `role/${request.id}/assign`, request);
};
const unassignRoleApi = async (
request: IUnssignRoleRequest,
): Promise<IUnssignRoleResponse | IResponseError> => {
return await callApi(HTTP.POST, `role/${request.id}/unassign`, request);
};
const fetchPermissionsApi = async (): Promise<
IFetchPermissionsResponse | IResponseError
> => {
return await callApi(HTTP.GET, `role/permissions`);
};
export {
fetchRoleApi,
createRoleApi,
updateRoleApi,
removeRoleApi,
assignRoleApi,
unassignRoleApi,
fetchPermissionsApi,
};

View file

@ -4,6 +4,9 @@ interface IFetchRole {
id: string; id: string;
name: string; name: string;
description?: string; description?: string;
color: string;
order: number;
showInMembers: boolean;
communityId: string; communityId: string;
permissions: string[]; permissions: string[];
creationDate: number; creationDate: number;
@ -18,6 +21,7 @@ interface IFetchRoleResponse extends IResponseSuccess, IFetchRole {}
interface ICreateRoleRequest { interface ICreateRoleRequest {
name: string; name: string;
communityId: string; communityId: string;
permissions: string[];
} }
interface ICreateRoleResponse extends IResponseSuccess, IFetchRole {} interface ICreateRoleResponse extends IResponseSuccess, IFetchRole {}
@ -25,6 +29,10 @@ interface ICreateRoleResponse extends IResponseSuccess, IFetchRole {}
interface IUpdateRoleRequest { interface IUpdateRoleRequest {
id: string; id: string;
name?: string; name?: string;
description?: string;
color?: string;
showInMembers?: boolean;
permissions?: string[];
} }
interface IUpdateRoleResponse extends IResponseSuccess, IFetchRole {} interface IUpdateRoleResponse extends IResponseSuccess, IFetchRole {}
@ -38,6 +46,34 @@ interface IRemoveRoleResponse extends IResponseSuccess {
communityId: string; communityId: string;
} }
interface IAssignRoleRequest {
id: string;
userId: string;
}
interface IAssignRoleResponse extends IResponseSuccess {
id: string;
name: string;
communityId: string;
userId: string;
}
interface IUnssignRoleRequest {
id: string;
userId: string;
}
interface IUnssignRoleResponse extends IResponseSuccess {
id: string;
name: string;
communityId: string;
userId: string;
}
interface IFetchPermissionsResponse extends IResponseSuccess {
permissions: string[];
}
export { export {
type IFetchRole, type IFetchRole,
type IFetchRoleRequest, type IFetchRoleRequest,
@ -48,4 +84,9 @@ export {
type IUpdateRoleResponse, type IUpdateRoleResponse,
type IRemoveRoleResponse, type IRemoveRoleResponse,
type IRemoveRoleRequest, type IRemoveRoleRequest,
type IAssignRoleRequest,
type IAssignRoleResponse,
type IUnssignRoleRequest,
type IUnssignRoleResponse,
type IFetchPermissionsResponse,
}; };

View file

@ -53,6 +53,23 @@ interface IFetchUserCommunity {
name: string; name: string;
} }
interface IFetchUserCommunityRolesRequest {
id: string;
communityId: string;
}
interface IFetchUserCommunityRolesResponse extends IResponseSuccess {
id: string;
communityId: string;
roles: IFetchUserCommunityRole[];
}
interface IFetchUserCommunityRole {
id: string;
name: string;
description: string;
}
interface IUpdateUserRequest { interface IUpdateUserRequest {
id: string; id: string;
nickname?: string; nickname?: string;
@ -73,6 +90,9 @@ export {
type IFetchUserCommunitiesRequest, type IFetchUserCommunitiesRequest,
type IFetchUserCommunitiesResponse, type IFetchUserCommunitiesResponse,
type IFetchUserCommunity, type IFetchUserCommunity,
type IFetchUserCommunityRolesRequest,
type IFetchUserCommunityRolesResponse,
type IFetchUserCommunityRole,
type IUpdateUserRequest, type IUpdateUserRequest,
type IUpdateUserResponse, type IUpdateUserResponse,
}; };

View file

@ -8,6 +8,8 @@ import {
IFetchUserSessionsResponse, IFetchUserSessionsResponse,
IFetchUserCommunitiesRequest, IFetchUserCommunitiesRequest,
IFetchUserCommunitiesResponse, IFetchUserCommunitiesResponse,
IFetchUserCommunityRolesRequest,
IFetchUserCommunityRolesResponse,
IUpdateUserRequest, IUpdateUserRequest,
IUpdateUserResponse, IUpdateUserResponse,
} from "./types"; } from "./types";
@ -36,6 +38,15 @@ const fetchUserCommunitiesApi = async (
return await callApi(HTTP.GET, `user/${request.id}/communities`); return await callApi(HTTP.GET, `user/${request.id}/communities`);
}; };
const fetchUserCommunityRolesApi = async (
request: IFetchUserCommunityRolesRequest,
): Promise<IFetchUserCommunityRolesResponse | IResponseError> => {
return await callApi(
HTTP.GET,
`user/${request.id}/community/${request.communityId}/roles`,
);
};
const updateUserApi = async ( const updateUserApi = async (
request: IUpdateUserRequest, request: IUpdateUserRequest,
): Promise<IUpdateUserResponse | IResponseError> => { ): Promise<IUpdateUserResponse | IResponseError> => {
@ -47,5 +58,6 @@ export {
fetchUserApi, fetchUserApi,
fetchUserSessionsApi, fetchUserSessionsApi,
fetchUserCommunitiesApi, fetchUserCommunitiesApi,
fetchUserCommunityRolesApi,
updateUserApi, updateUserApi,
}; };

View file

@ -0,0 +1,23 @@
import { createSignal, type Component } from "solid-js";
import { IBadgeItemProps } from "./types";
import { RemoveIcon, RemoveStrokeIcon } from "../../icons";
const BadgeItem: Component<IBadgeItemProps> = (props: IBadgeItemProps) => {
const [getHover, setHover] = createSignal<boolean>(false);
return (
<div class={`badge bg-stone-700 h-8 pl-3 p-1 rounded-full`}>
{props.text}
<div
class="w-6 cursor-pointer"
onClick={() => props.onRemove?.(props.id)}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => setHover(false)}
>
{getHover() ? <RemoveIcon /> : <RemoveStrokeIcon />}
</div>
</div>
);
};
export { BadgeItem };

View file

@ -0,0 +1,2 @@
export * from "./BadgeItem";
export * from "./types";

View file

@ -0,0 +1,8 @@
interface IBadgeItemProps {
id: string;
text: string;
removable: boolean;
onRemove?: (id: string) => void;
}
export { type IBadgeItemProps };

View file

@ -6,6 +6,7 @@ const Channel: Component<IChannelProps> = (props: IChannelProps) => {
<li <li
class={`flex flex-row gap-2 items-center p-1 cursor-pointer rounded-lg ${props.active ? "bg-stone-700 hover:bg-stone-700" : "hover:bg-stone-800"}`} class={`flex flex-row gap-2 items-center p-1 cursor-pointer rounded-lg ${props.active ? "bg-stone-700 hover:bg-stone-700" : "hover:bg-stone-800"}`}
onClick={() => props.onChannelClick?.(props.id)} onClick={() => props.onChannelClick?.(props.id)}
onContextMenu={(e) => props.onChannelRightClick?.(props.id, e)}
> >
<div class="font-bold text-xl">&nbsp#</div> <div class="font-bold text-xl">&nbsp#</div>
<div class="font-bold">{props.name}</div> <div class="font-bold">{props.name}</div>

View file

@ -3,6 +3,7 @@ interface IChannelProps {
name: string; name: string;
active: boolean; active: boolean;
onChannelClick?: (id: string) => void; onChannelClick?: (id: string) => void;
onChannelRightClick?: (id: string, event: MouseEvent) => void;
} }
export { type IChannelProps }; export { type IChannelProps };

View file

@ -0,0 +1,15 @@
import type { Component } from "solid-js";
import { ICheckItemProps } from "./types";
const CheckItem: Component<ICheckItemProps> = (props: ICheckItemProps) => {
return (
<div
class={`w-48 py-2 cursor-pointer rounded-xl text-center text-md font-semibold transition-all border-2 border-stone-700 ${props.checked ? "bg-stone-700 hover:bg-stone-700" : "bg-stone-900 hover:bg-stone-800"}`}
onClick={() => props.onClick?.(props.id, !props.checked)}
>
{props.text}
</div>
);
};
export { CheckItem };

View file

@ -0,0 +1,2 @@
export * from "./CheckItem";
export * from "./types";

View file

@ -0,0 +1,8 @@
interface ICheckItemProps {
id: string;
text: string;
checked: boolean;
onClick?: (id: string, checked: boolean) => void;
}
export { type ICheckItemProps };

View file

@ -7,6 +7,7 @@ const Community: Component<ICommunityProps> = (props: ICommunityProps) => {
<div <div
class="avatar cursor-pointer" class="avatar cursor-pointer"
onClick={() => props.onCommunityClick?.(props.id)} onClick={() => props.onCommunityClick?.(props.id)}
onContextMenu={(e) => props.onCommunityRightClick?.(props.id, e)}
> >
<div <div
class={`w-full transition-all duration-300 hover:outline-stone-300 ${props.active ? "rounded-lg outline-3 outline-stone-300 hover:outline-3" : "outline-transparent rounded-4xl outline-2"}`} class={`w-full transition-all duration-300 hover:outline-stone-300 ${props.active ? "rounded-lg outline-3 outline-stone-300 hover:outline-3" : "outline-transparent rounded-4xl outline-2"}`}

View file

@ -4,6 +4,7 @@ interface ICommunityProps {
avatar?: string; avatar?: string;
active: boolean; active: boolean;
onCommunityClick?: (id: string) => void; onCommunityClick?: (id: string) => void;
onCommunityRightClick?: (id: string, event: MouseEvent) => void;
} }
export { type ICommunityProps }; export { type ICommunityProps };

View file

@ -0,0 +1,54 @@
import { createMemo, type Component, type JSXElement } from "solid-js";
import { IContextMenuItem, IContextMenuProps } from "./types";
import { Dynamic, Portal } from "solid-js/web";
const ContextMenu: Component<IContextMenuProps> = (
props: IContextMenuProps,
) => {
let menuRef: HTMLUListElement | undefined;
const isRight = createMemo<boolean>(() => {
const offsetWidth = props.offset ?? 0;
return props.position.x + offsetWidth >= window.innerWidth;
});
const mapItem = (item: IContextMenuItem): JSXElement => {
return (
<li
class="flex flex-row items-center gap-3 hover:bg-stone-800 w-full h-10 p-3 rounded-xl cursor-pointer z-50"
style={{ color: item.color }}
onClick={item.onClick}
>
<div class="w-5">
<Dynamic component={item.icon} />
</div>
<span>{item.label}</span>
</li>
);
};
return (
<>
{props.visible && props.items.length > 0 ? (
<Portal>
<ul
ref={menuRef}
class="fixed flex flex-col gap-1 bg-stone-900 w-fit border-2 border-stone-800 shadow-xl rounded-2xl p-1 z-50"
style={{
top: `${props.position.y}px`,
left: isRight()
? undefined
: `${props.position.x}px`,
right: isRight() ? "16px" : undefined,
}}
>
{props.items.map(mapItem)}
</ul>
</Portal>
) : undefined}
</>
);
};
export { ContextMenu };

View file

@ -0,0 +1,3 @@
export * from "./ContextMenu";
export * from "./types";
export * from "./useContextMenu";

View file

@ -0,0 +1,23 @@
import { JSXElement } from "solid-js";
import { IconParameters } from "../../icons";
interface IContextMenuProps {
visible: boolean;
position: IPosition;
offset?: number;
items: IContextMenuItem[];
}
interface IPosition {
x: number;
y: number;
}
interface IContextMenuItem {
label: string;
icon: (props: IconParameters) => JSXElement;
color?: string;
onClick?: () => void;
}
export { type IContextMenuProps, type IPosition, type IContextMenuItem };

View file

@ -0,0 +1,44 @@
import { createSignal, onCleanup } from "solid-js";
import { resetContextMenuOpenId, setContextMenuOpenId } from "../../store/app";
import { state } from "../../store/state";
const useContextMenu = <T>(id: string) => {
const [getData, setData] = createSignal<T | undefined>(undefined);
const [getPos, setPos] = createSignal({ x: 0, y: 0 });
const getVisible = (): boolean => {
return state.app.contextMenuOpenId === id;
};
const open = (e: MouseEvent, data: T) => {
e.preventDefault();
e.stopPropagation();
setData(data as Exclude<T, Function>);
setPos({ x: e.clientX, y: e.clientY });
setContextMenuOpenId(id);
};
const close = () => resetContextMenuOpenId();
const handleGlobal = (e: MouseEvent | KeyboardEvent) => {
if (e instanceof KeyboardEvent && e.key === "Escape") {
close();
}
if (e instanceof MouseEvent) {
close();
}
};
window.addEventListener("click", handleGlobal);
window.addEventListener("keydown", handleGlobal);
onCleanup(() => {
window.removeEventListener("click", handleGlobal);
window.removeEventListener("keydown", handleGlobal);
});
return { getData, getVisible, getPos, open, close };
};
export { useContextMenu };

View file

@ -32,7 +32,7 @@ const FileInput: Component<IFileInputProps> = (props: IFileInputProps) => {
class={`bg-stone-800 h-40 w-40 p-2 ${props.rounded ? "rounded-full" : "rounded-2xl"} ${props.outline ? "outline-2" : ""}`} class={`bg-stone-800 h-40 w-40 p-2 ${props.rounded ? "rounded-full" : "rounded-2xl"} ${props.outline ? "outline-2" : ""}`}
> >
<label <label
class={`relative inline-block bg-stone-900 input w-full h-full p-0 focus:border-none outline-none cursor-pointer ${props.rounded ? "rounded-full" : "rounded-xl"}`} class={`relative inline-block bg-stone-900 input w-full h-full p-0 focus:border-none outline-none border-2 border-stone-700 cursor-pointer ${props.rounded ? "rounded-full" : "rounded-xl"}`}
> >
{props.picture ? pictureHtml() : iconOnlyHtml()} {props.picture ? pictureHtml() : iconOnlyHtml()}
<input <input

View file

@ -7,10 +7,10 @@ const FilePreview: Component<IFilePreviewProps> = (
) => { ) => {
return ( return (
<div <div
class={`bg-stone-800 h-40 w-40 p-2 ${props.rounded ? "rounded-full" : "rounded-2xl"} ${props.outline ? "outline-2" : ""}`} class={`bg-stone-800 p-2 ${props.allowResize ? "min-h-40 min-w-40 max-h-fit max-w-82" : "h-40 w-40"} ${props.rounded ? "rounded-full" : "rounded-2xl"} ${props.outline ? "outline-2" : ""}`}
> >
<label <label
class={`relative inline-block bg-stone-900 input w-full h-full p-0 focus:border-none outline-none ${props.clickable ? "cursor-pointer" : "cursor-default"} ${props.rounded ? "rounded-full" : "rounded-xl"}`} class={`relative inline-block bg-stone-900 w-full h-full p-0 border-2 border-stone-700 focus:border-none outline-none ${props.clickable ? "cursor-pointer" : "cursor-default"} ${props.rounded ? "rounded-full" : "rounded-xl"}`}
> >
{props.picture ? ( {props.picture ? (
<div class="avatar w-full h-full"> <div class="avatar w-full h-full">
@ -22,11 +22,13 @@ const FilePreview: Component<IFilePreviewProps> = (
/> />
</div> </div>
) : ( ) : (
<div class="w-full h-full flex flex-col items-center justify-center"> <div class="w-full h-full flex flex-col items-center justify-center min-w-0">
<div class="w-12"> <div class="w-12">
<Dynamic component={props.icon} /> <Dynamic component={props.icon} />
</div> </div>
<p class="w-full text-center px-2 truncate">
{props.filename} {props.filename}
</p>
</div> </div>
)} )}
{props.clickable ? ( {props.clickable ? (
@ -34,7 +36,7 @@ const FilePreview: Component<IFilePreviewProps> = (
class={`absolute inset-0 flex items-center justify-center bg-black/50 text-white transition-opacity opacity-0 hover:opacity-100 ${props.rounded ? "rounded-full" : "rounded-xl"}`} class={`absolute inset-0 flex items-center justify-center bg-black/50 text-white transition-opacity opacity-0 hover:opacity-100 ${props.rounded ? "rounded-full" : "rounded-xl"}`}
onClick={() => props.onClick?.(props.id)} onClick={() => props.onClick?.(props.id)}
> >
<div class="w-full p-12"> <div class="w-12">
<Dynamic component={props.clickIcon} /> <Dynamic component={props.clickIcon} />
</div> </div>
</div> </div>

View file

@ -8,6 +8,7 @@ interface IFilePreviewProps {
rounded?: boolean; rounded?: boolean;
picture?: string; picture?: string;
outline?: boolean; outline?: boolean;
allowResize?: boolean;
clickable?: boolean; clickable?: boolean;
clickIcon?: (props: IconParameters) => JSXElement; clickIcon?: (props: IconParameters) => JSXElement;
onClick?: (id: string | number) => void; onClick?: (id: string | number) => void;

View file

@ -1,10 +1,20 @@
import { type Component, type JSXElement } from "solid-js"; import { type Component, type JSXElement } from "solid-js";
import { IInputProps } from "./types"; import { IInputProps } from "./types";
import { Dynamic } from "solid-js/web";
const Input: Component<IInputProps> = (props: IInputProps) => { const Input: Component<IInputProps> = (props: IInputProps) => {
const handleEnter = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === "Enter") { if (e.key === "Enter" && !props.textArea) {
props.onSubmit?.(); props.onSubmit?.();
return;
}
if (props.type !== "number") {
return;
}
if (!/^[0-9]$/.test(e.key) && e.key !== "Backspace") {
e.preventDefault();
} }
}; };
@ -23,7 +33,7 @@ const Input: Component<IInputProps> = (props: IInputProps) => {
placeholder={props.placeholder} placeholder={props.placeholder}
value={props.value} value={props.value}
onInput={(e) => props.onChange?.(e.currentTarget.value)} onInput={(e) => props.onChange?.(e.currentTarget.value)}
onKeyDown={handleEnter} onKeyDown={handleKey}
/> />
); );
@ -33,7 +43,7 @@ const Input: Component<IInputProps> = (props: IInputProps) => {
placeholder={props.placeholder} placeholder={props.placeholder}
value={props.value} value={props.value}
onInput={(e) => props.onChange?.(e.currentTarget.value)} onInput={(e) => props.onChange?.(e.currentTarget.value)}
onKeyDown={handleEnter} onKeyDown={handleKey}
/> />
); );
@ -44,6 +54,14 @@ const Input: Component<IInputProps> = (props: IInputProps) => {
<label <label
class={`bg-stone-800 input ${props.textArea ? "px-0" : "px-5"} w-full h-full focus:border-none outline-none ${props.rounded ? "rounded-full" : "rounded-xl"}`} class={`bg-stone-800 input ${props.textArea ? "px-0" : "px-5"} w-full h-full focus:border-none outline-none ${props.rounded ? "rounded-full" : "rounded-xl"}`}
> >
{props.icon ? (
<div class="w-5 mr-2">
<Dynamic component={props.icon} />
</div>
) : undefined}
{props.label ? (
<div class="mr-2">{props.label}</div>
) : undefined}
{props.textArea ? textAreaHtml() : inputHtml()} {props.textArea ? textAreaHtml() : inputHtml()}
</label> </label>
{props.submitText ? submitHtml() : undefined} {props.submitText ? submitHtml() : undefined}

View file

@ -1,10 +1,15 @@
import { JSXElement } from "solid-js";
import { IconParameters } from "../../icons";
interface IInputProps { interface IInputProps {
value: string; value: string;
type?: "text" | "password" | "email"; type?: "text" | "password" | "email" | "number" | "date";
outline?: boolean; outline?: boolean;
rounded?: boolean; rounded?: boolean;
textArea?: boolean; textArea?: boolean;
placeholder?: string; placeholder?: string;
label?: string;
icon?: (props: IconParameters) => JSXElement;
submitText?: string; submitText?: string;
onChange?: (value: string) => void; onChange?: (value: string) => void;
onSubmit?: () => void; onSubmit?: () => void;

View file

@ -7,6 +7,7 @@ const Member: Component<IMemberProps> = (props: IMemberProps) => {
<li <li
class={`flex flex-row gap-4 items-center p-1 cursor-pointer rounded-lg ${props.active ? "bg-stone-700 hover:bg-stone-700" : "hover:bg-stone-800"}`} class={`flex flex-row gap-4 items-center p-1 cursor-pointer rounded-lg ${props.active ? "bg-stone-700 hover:bg-stone-700" : "hover:bg-stone-800"}`}
onClick={() => props.onMemberClick?.(props.id)} onClick={() => props.onMemberClick?.(props.id)}
onContextMenu={(e) => props.onMemberRightClick?.(props.id, e)}
> >
<div class="avatar"> <div class="avatar">
{props.avatar ? ( {props.avatar ? (
@ -19,7 +20,9 @@ const Member: Component<IMemberProps> = (props: IMemberProps) => {
</div> </div>
)} )}
</div> </div>
<div class="font-bold">{props.username}</div> <div class="font-bold" style={{ color: props.color }}>
{props.nickname}
</div>
</li> </li>
); );
}; };

View file

@ -1,9 +1,11 @@
interface IMemberProps { interface IMemberProps {
id: string; id: string;
username: string; nickname: string;
avatar?: string; avatar?: string;
active: boolean; active: boolean;
color?: string;
onMemberClick?: (id: string) => void; onMemberClick?: (id: string) => void;
onMemberRightClick?: (id: string, event: MouseEvent) => void;
} }
export { type IMemberProps }; export { type IMemberProps };

View file

@ -1,4 +1,4 @@
import type { Component, JSXElement } from "solid-js"; import { createSignal, type Component, type JSXElement } from "solid-js";
import { IMessageProps } from "./types"; import { IMessageProps } from "./types";
import { import {
DownloadIcon, DownloadIcon,
@ -12,8 +12,11 @@ import { state } from "../../store/state";
import { FilePreview } from "../FilePreview"; import { FilePreview } from "../FilePreview";
import { fetchFile } from "../../services/file"; import { fetchFile } from "../../services/file";
import { getActiveCommunity } from "../../store/community"; import { getActiveCommunity } from "../../store/community";
import { Input } from "../Input";
const Message: Component<IMessageProps> = (props: IMessageProps) => { const Message: Component<IMessageProps> = (props: IMessageProps) => {
const [getEditedText, setEditedText] = createSignal<string>(props.message);
const avatarHtml = (): JSXElement => ( const avatarHtml = (): JSXElement => (
<div <div
class="avatar cursor-pointer" class="avatar cursor-pointer"
@ -31,9 +34,9 @@ const Message: Component<IMessageProps> = (props: IMessageProps) => {
</div> </div>
); );
const nameHtml = (): JSXElement => ( const onUpdateMessage = () => {
<div class="font-bold">{props.username}</div> props.onMessageEdit?.(props.messageId, getEditedText());
); };
const onDownloadAttachment = (attachmentId: string | number) => { const onDownloadAttachment = (attachmentId: string | number) => {
const attachment = state.file.attachments[attachmentId]; const attachment = state.file.attachments[attachmentId];
@ -100,6 +103,7 @@ const Message: Component<IMessageProps> = (props: IMessageProps) => {
<FilePreview <FilePreview
id={attachmentId} id={attachmentId}
picture={URL.createObjectURL(attachment.fullFile)} picture={URL.createObjectURL(attachment.fullFile)}
allowResize={props.attachments.length === 1}
clickable={true} clickable={true}
clickIcon={ZoomIcon} clickIcon={ZoomIcon}
onClick={onOpenAttachment} onClick={onOpenAttachment}
@ -113,23 +117,56 @@ const Message: Component<IMessageProps> = (props: IMessageProps) => {
</div> </div>
); );
return ( const nameHtml = (): JSXElement => (
<li <div class="font-bold" style={{ color: props.color }}>
class={`flex list-row hover:bg-stone-700 after:hidden ${props.plain ? "py-1" : "py-2 mt-3"} ${props.plain ? "" : "before:absolute before:top-0 before:left-2 before:right-2 before:h-px before:bg-stone-700 first:before:hidden"}`} {props.username}
> </div>
{props.plain ? undefined : avatarHtml()} );
<div class={`pr-14 ${props.plain ? "pl-14" : ""}`}>
{props.plain ? undefined : nameHtml()} const timeHtml = (): JSXElement => (
{props.message.length === <div class="flex-1 text-[0.6rem] opacity-40 text-end text-nowrap">
0 ? undefined : props.decryptionStatus ? ( {new Date(props.time).toLocaleTimeString()}
<p class="list-col-wrap text-xs">{props.message}</p> </div>
);
const editingHtml = (): JSXElement => (
<div class="">
<Input
value={getEditedText()}
onChange={setEditedText}
onSubmit={onUpdateMessage}
/>
</div>
);
const messageHtml = (): JSXElement => (
<>
{props.message.length === 0 ? undefined : props.decryptionStatus ? (
<p class="list-col-wrap text-xs">
{props.editing ? editingHtml() : props.message}
</p>
) : ( ) : (
<p class="list-col-wrap text-xs italic opacity-75"> <p class="list-col-wrap text-xs italic opacity-75">
Decryption failed Decryption failed
</p> </p>
)} )}
</>
);
return (
<li
class={`flex list-row hover:bg-stone-700 after:hidden ${props.plain ? "py-1" : "py-2 mt-3"} ${props.plain ? "" : "before:absolute before:top-0 before:left-2 before:right-2 before:h-px before:bg-stone-700 first:before:hidden"}`}
onContextMenu={(e) =>
props.onMessageRightClick?.(props.messageId, e)
}
>
{props.plain ? undefined : avatarHtml()}
<div class={`w-full pr-14 ${props.plain ? "pl-14" : ""}`}>
{props.plain ? undefined : nameHtml()}
{messageHtml()}
{props.attachments.length > 0 ? attachmentsHtml() : undefined} {props.attachments.length > 0 ? attachmentsHtml() : undefined}
</div> </div>
{timeHtml()}
</li> </li>
); );
}; };

View file

@ -3,11 +3,16 @@ interface IMessageProps {
message: string; message: string;
userId: string; userId: string;
username: string; username: string;
color?: string;
avatar?: string; avatar?: string;
attachments: string[]; attachments: string[];
time: number;
plain: boolean; plain: boolean;
decryptionStatus: boolean; decryptionStatus: boolean;
editing: boolean;
onProfileClick?: (userId: string) => void; onProfileClick?: (userId: string) => void;
onMessageRightClick?: (id: string, event: MouseEvent) => void;
onMessageEdit?: (id: string, text: string) => void;
} }
export { type IMessageProps }; export { type IMessageProps };

View file

@ -46,7 +46,7 @@ const RichSettingsItem: Component<IRichSettingsItemProps> = (
return ( return (
<div <div
class={`collapse collapse-arrow rounded-xl transition-all border-2 border-stone-700 ${props.active ? "bg-stone-700 hover:bg-stone-700" : "bg-stone-900 hover:bg-stone-800"}`} class={`collapse collapse-arrow rounded-xl transition-all border-2 ${props.active ? "bg-stone-700 hover:bg-stone-700 border-stone-700 hover:border-stone-700" : "bg-stone-900 hover:bg-stone-800 border-stone-800 hover:border-stone-700"}`}
> >
<input type="checkbox" /> <input type="checkbox" />
<div class="collapse-title font-semibold flex flex-row items-center gap-4 p-1"> <div class="collapse-title font-semibold flex flex-row items-center gap-4 p-1">

View file

@ -4,7 +4,6 @@ import { IconParameters } from "../../icons";
interface IRichSettingsItemProps { interface IRichSettingsItemProps {
id: string; id: string;
title: string; title: string;
text?: string;
info?: string; info?: string;
avatar?: string; avatar?: string;
icon?: (props: IconParameters) => JSXElement; icon?: (props: IconParameters) => JSXElement;

View file

@ -0,0 +1,44 @@
import { type Component, type JSXElement } from "solid-js";
import { ISelectOption, ISelectProps } from "./types";
const Select: Component<ISelectProps> = (props: ISelectProps) => {
const submitHtml = (): JSXElement => (
<button
class={`bg-stone-950 btn btn-neutral h-full ${props.rounded ? "rounded-full" : "rounded-xl"}`}
onClick={props.onSubmit}
>
{props.submitText}
</button>
);
const mapOption = (option: ISelectOption): JSXElement => (
<option
value={option.value}
disabled={option.disabled}
selected={option.value === props.value}
>
{option.label}
</option>
);
return (
<div
class={`bg-stone-800 h-16 p-2 flex flex-row gap-2 ${props.rounded ? "rounded-full" : "rounded-2xl"} ${props.outline ? "outline-2" : ""}`}
>
<select
class={`select bg-stone-800 px-5 w-full h-full outline-none ${props.rounded ? "rounded-full" : "rounded-xl"}`}
onChange={(e) => props.onChange?.(e.currentTarget.value)}
>
{mapOption({
value: "",
label: props.placeholder ?? "",
disabled: true,
})}
{props.options.map(mapOption)}
</select>
{props.submitText ? submitHtml() : undefined}
</div>
);
};
export { Select };

View file

@ -0,0 +1,2 @@
export * from "./Select";
export * from "./types";

View file

@ -0,0 +1,18 @@
interface ISelectProps {
value: string;
options: ISelectOption[];
outline?: boolean;
rounded?: boolean;
placeholder?: string;
submitText?: string;
onChange?: (value: string) => void;
onSubmit?: () => void;
}
interface ISelectOption {
value: string;
label: string;
disabled?: boolean;
}
export { type ISelectProps, type ISelectOption };

View file

@ -6,7 +6,7 @@ const SettingsItem: Component<ISettingsItemProps> = (
) => { ) => {
return ( return (
<div <div
class={`w-48 py-2 cursor-pointer rounded-xl text-center text-md font-semibold transition-all border-2 border-stone-700 ${props.active ? "bg-stone-700 hover:bg-stone-700" : "bg-stone-900 hover:bg-stone-800"}`} class={`w-48 py-3 cursor-pointer rounded-xl text-center text-md font-semibold transition-all border-2 ${props.active ? "bg-stone-700 hover:bg-stone-700 border-stone-700 hover:border-stone-700" : "bg-stone-900 hover:bg-stone-800 border-stone-800 hover:border-stone-700"}`}
onClick={() => props.onClick?.(props.id)} onClick={() => props.onClick?.(props.id)}
> >
{props.text} {props.text}

34
src/icons/CheckIcon.tsx Normal file
View file

@ -0,0 +1,34 @@
import type { Component } from "solid-js";
import {
IconParameters,
defaultStrokeIconParameters as defaults,
} from "./types";
const CheckIcon: Component<IconParameters> = ({
width,
height,
fill = defaults.fill,
stroke = defaults.stroke,
strokeWidth = defaults.strokeWidth,
}: IconParameters) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
fill={fill}
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke={stroke}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m4.5 12.75 6 6 9-13.5"
/>
</svg>
);
};
export default CheckIcon;

34
src/icons/DownIcon.tsx Normal file
View file

@ -0,0 +1,34 @@
import type { Component } from "solid-js";
import {
IconParameters,
defaultStrokeIconParameters as defaults,
} from "./types";
const DownIcon: Component<IconParameters> = ({
width,
height,
fill = defaults.fill,
stroke = defaults.stroke,
strokeWidth = defaults.strokeWidth,
}: IconParameters) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
fill={fill}
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke={stroke}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 13.5 12 21m0 0-7.5-7.5M12 21V3"
/>
</svg>
);
};
export default DownIcon;

27
src/icons/EditIcon.tsx Normal file
View file

@ -0,0 +1,27 @@
import type { Component } from "solid-js";
import { IconParameters, defaultFillIconParameters as defaults } from "./types";
const EditIcon: Component<IconParameters> = ({
width,
height,
fill = defaults.fill,
stroke = defaults.stroke,
strokeWidth = defaults.strokeWidth,
}: IconParameters) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
fill={fill}
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke={stroke}
>
<path d="M21.731 2.269a2.625 2.625 0 0 0-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 0 0 0-3.712ZM19.513 8.199l-3.712-3.712-12.15 12.15a5.25 5.25 0 0 0-1.32 2.214l-.8 2.685a.75.75 0 0 0 .933.933l2.685-.8a5.25 5.25 0 0 0 2.214-1.32L19.513 8.2Z" />
</svg>
);
};
export default EditIcon;

34
src/icons/HashIcon.tsx Normal file
View file

@ -0,0 +1,34 @@
import type { Component } from "solid-js";
import {
IconParameters,
defaultStrokeIconParameters as defaults,
} from "./types";
const HashIcon: Component<IconParameters> = ({
width,
height,
fill = defaults.fill,
stroke = defaults.stroke,
strokeWidth = defaults.strokeWidth,
}: IconParameters) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
fill={fill}
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke={stroke}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 8.25h15m-16.5 7.5h15m-1.8-13.5-3.9 19.5m-2.1-19.5-3.9 19.5"
/>
</svg>
);
};
export default HashIcon;

34
src/icons/InviteIcon.tsx Normal file
View file

@ -0,0 +1,34 @@
import type { Component } from "solid-js";
import {
IconParameters,
defaultStrokeIconParameters as defaults,
} from "./types";
const InviteIcon: Component<IconParameters> = ({
width,
height,
fill = defaults.fill,
stroke = defaults.stroke,
strokeWidth = defaults.strokeWidth,
}: IconParameters) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
fill={fill}
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke={stroke}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"
/>
</svg>
);
};
export default InviteIcon;

31
src/icons/KeyIcon.tsx Normal file
View file

@ -0,0 +1,31 @@
import type { Component } from "solid-js";
import { IconParameters, defaultFillIconParameters as defaults } from "./types";
const KeyIcon: Component<IconParameters> = ({
width,
height,
fill = defaults.fill,
stroke = defaults.stroke,
strokeWidth = defaults.strokeWidth,
}: IconParameters) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
fill={fill}
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke={stroke}
>
<path
fill-rule="evenodd"
d="M15.75 1.5a6.75 6.75 0 0 0-6.651 7.906c.067.39-.032.717-.221.906l-6.5 6.499a3 3 0 0 0-.878 2.121v2.818c0 .414.336.75.75.75H6a.75.75 0 0 0 .75-.75v-1.5h1.5A.75.75 0 0 0 9 19.5V18h1.5a.75.75 0 0 0 .53-.22l2.658-2.658c.19-.189.517-.288.906-.22A6.75 6.75 0 1 0 15.75 1.5Zm0 3a.75.75 0 0 0 0 1.5A2.25 2.25 0 0 1 18 8.25a.75.75 0 0 0 1.5 0 3.75 3.75 0 0 0-3.75-3.75Z"
clip-rule="evenodd"
/>
</svg>
);
};
export default KeyIcon;

34
src/icons/LeaveIcon.tsx Normal file
View file

@ -0,0 +1,34 @@
import type { Component } from "solid-js";
import {
IconParameters,
defaultStrokeIconParameters as defaults,
} from "./types";
const LeaveIcon: Component<IconParameters> = ({
width,
height,
fill = defaults.fill,
stroke = defaults.stroke,
strokeWidth = defaults.strokeWidth,
}: IconParameters) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
fill={fill}
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke={stroke}
>
<path
fill-rule="evenodd"
d="M7.5 3.75A1.5 1.5 0 0 0 6 5.25v13.5a1.5 1.5 0 0 0 1.5 1.5h6a1.5 1.5 0 0 0 1.5-1.5V15a.75.75 0 0 1 1.5 0v3.75a3 3 0 0 1-3 3h-6a3 3 0 0 1-3-3V5.25a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3V9A.75.75 0 0 1 15 9V5.25a1.5 1.5 0 0 0-1.5-1.5h-6Zm5.03 4.72a.75.75 0 0 1 0 1.06l-1.72 1.72h10.94a.75.75 0 0 1 0 1.5H10.81l1.72 1.72a.75.75 0 1 1-1.06 1.06l-3-3a.75.75 0 0 1 0-1.06l3-3a.75.75 0 0 1 1.06 0Z"
clip-rule="evenodd"
/>
</svg>
);
};
export default LeaveIcon;

34
src/icons/LeftIcon.tsx Normal file
View file

@ -0,0 +1,34 @@
import type { Component } from "solid-js";
import {
IconParameters,
defaultStrokeIconParameters as defaults,
} from "./types";
const LeftIcon: Component<IconParameters> = ({
width,
height,
fill = defaults.fill,
stroke = defaults.stroke,
strokeWidth = defaults.strokeWidth,
}: IconParameters) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
fill={fill}
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke={stroke}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18"
/>
</svg>
);
};
export default LeftIcon;

View file

@ -0,0 +1,31 @@
import type { Component } from "solid-js";
import { IconParameters, defaultFillIconParameters as defaults } from "./types";
const PinIcon: Component<IconParameters> = ({
width,
height,
fill = defaults.fill,
stroke = defaults.stroke,
strokeWidth = defaults.strokeWidth,
}: IconParameters) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
fill={fill}
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke={stroke}
>
<path
fill-rule="evenodd"
d="m11.54 22.351.07.04.028.016a.76.76 0 0 0 .723 0l.028-.015.071-.041a16.975 16.975 0 0 0 1.144-.742 19.58 19.58 0 0 0 2.683-2.282c1.944-1.99 3.963-4.98 3.963-8.827a8.25 8.25 0 0 0-16.5 0c0 3.846 2.02 6.837 3.963 8.827a19.58 19.58 0 0 0 2.682 2.282 16.975 16.975 0 0 0 1.145.742ZM12 13.5a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"
clip-rule="evenodd"
/>
</svg>
);
};
export default PinIcon;

31
src/icons/PinIcon.tsx Normal file
View file

@ -0,0 +1,31 @@
import type { Component } from "solid-js";
import { IconParameters, defaultFillIconParameters as defaults } from "./types";
const PinIcon: Component<IconParameters> = ({
width,
height,
fill = defaults.fill,
stroke = defaults.stroke,
strokeWidth = defaults.strokeWidth,
}: IconParameters) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
fill={fill}
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke={stroke}
>
<path
fill-rule="evenodd"
d="m11.54 22.351.07.04.028.016a.76.76 0 0 0 .723 0l.028-.015.071-.041a16.975 16.975 0 0 0 1.144-.742 19.58 19.58 0 0 0 2.683-2.282c1.944-1.99 3.963-4.98 3.963-8.827a8.25 8.25 0 0 0-16.5 0c0 3.846 2.02 6.837 3.963 8.827a19.58 19.58 0 0 0 2.682 2.282 16.975 16.975 0 0 0 1.145.742ZM12 13.5a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"
clip-rule="evenodd"
/>
</svg>
);
};
export default PinIcon;

31
src/icons/ReactIcon.tsx Normal file
View file

@ -0,0 +1,31 @@
import type { Component } from "solid-js";
import { IconParameters, defaultFillIconParameters as defaults } from "./types";
const ReactIcon: Component<IconParameters> = ({
width,
height,
fill = defaults.fill,
stroke = defaults.stroke,
strokeWidth = defaults.strokeWidth,
}: IconParameters) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
fill={fill}
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke={stroke}
>
<path
fill-rule="evenodd"
d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25Zm-2.625 6c-.54 0-.828.419-.936.634a1.96 1.96 0 0 0-.189.866c0 .298.059.605.189.866.108.215.395.634.936.634.54 0 .828-.419.936-.634.13-.26.189-.568.189-.866 0-.298-.059-.605-.189-.866-.108-.215-.395-.634-.936-.634Zm4.314.634c.108-.215.395-.634.936-.634.54 0 .828.419.936.634.13.26.189.568.189.866 0 .298-.059.605-.189.866-.108.215-.395.634-.936.634-.54 0-.828-.419-.936-.634a1.96 1.96 0 0 1-.189-.866c0-.298.059-.605.189-.866Zm2.023 6.828a.75.75 0 1 0-1.06-1.06 3.75 3.75 0 0 1-5.304 0 .75.75 0 0 0-1.06 1.06 5.25 5.25 0 0 0 7.424 0Z"
clip-rule="evenodd"
/>
</svg>
);
};
export default ReactIcon;

31
src/icons/RemoveIcon.tsx Normal file
View file

@ -0,0 +1,31 @@
import type { Component } from "solid-js";
import { IconParameters, defaultFillIconParameters as defaults } from "./types";
const RemoveIcon: Component<IconParameters> = ({
width,
height,
fill = defaults.fill,
stroke = defaults.stroke,
strokeWidth = defaults.strokeWidth,
}: IconParameters) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
fill={fill}
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke={stroke}
>
<path
fill-rule="evenodd"
d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25Zm-1.72 6.97a.75.75 0 1 0-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 1 0 1.06 1.06L12 13.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L13.06 12l1.72-1.72a.75.75 0 1 0-1.06-1.06L12 10.94l-1.72-1.72Z"
clip-rule="evenodd"
/>
</svg>
);
};
export default RemoveIcon;

View file

@ -0,0 +1,34 @@
import type { Component } from "solid-js";
import {
IconParameters,
defaultStrokeIconParameters as defaults,
} from "./types";
const RemoveStrokeIcon: Component<IconParameters> = ({
width,
height,
fill = defaults.fill,
stroke = defaults.stroke,
strokeWidth = defaults.strokeWidth,
}: IconParameters) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
fill={fill}
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke={stroke}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
);
};
export default RemoveStrokeIcon;

34
src/icons/RightIcon.tsx Normal file
View file

@ -0,0 +1,34 @@
import type { Component } from "solid-js";
import {
IconParameters,
defaultStrokeIconParameters as defaults,
} from "./types";
const RightIcon: Component<IconParameters> = ({
width,
height,
fill = defaults.fill,
stroke = defaults.stroke,
strokeWidth = defaults.strokeWidth,
}: IconParameters) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width}
height={height}
fill={fill}
viewBox="0 0 24 24"
stroke-width={strokeWidth}
stroke={stroke}
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"
/>
</svg>
);
};
export default RightIcon;

View file

@ -5,7 +5,12 @@ import PlusIcon from "./PlusIcon";
import MinusIcon from "./MinusIcon"; import MinusIcon from "./MinusIcon";
import DeviceIcon from "./DeviceIcon"; import DeviceIcon from "./DeviceIcon";
import TrashIcon from "./TrashIcon"; import TrashIcon from "./TrashIcon";
import RemoveIcon from "./RemoveIcon";
import RemoveStrokeIcon from "./RemoveStrokeIcon";
import UpIcon from "./UpIcon"; import UpIcon from "./UpIcon";
import DownIcon from "./DownIcon";
import LeftIcon from "./LeftIcon";
import RightIcon from "./RightIcon";
import UploadIcon from "./UploadIcon"; import UploadIcon from "./UploadIcon";
import UploadMultiIcon from "./UploadMultiIcon"; import UploadMultiIcon from "./UploadMultiIcon";
import AttachmentIcon from "./AttachmentIcon"; import AttachmentIcon from "./AttachmentIcon";
@ -16,6 +21,15 @@ import DownloadIcon from "./DownloadIcon";
import ErrorIcon from "./ErrorIcon"; import ErrorIcon from "./ErrorIcon";
import ZoomIcon from "./ZoomIcon"; import ZoomIcon from "./ZoomIcon";
import PictureIcon from "./PictureIcon"; import PictureIcon from "./PictureIcon";
import LeaveIcon from "./LeaveIcon";
import InviteIcon from "./InviteIcon";
import CheckIcon from "./CheckIcon";
import KeyIcon from "./KeyIcon";
import HashIcon from "./HashIcon";
import EditIcon from "./EditIcon";
import ReactIcon from "./ReactIcon";
import PinIcon from "./PinIcon";
import LocationIcon from "./LocationIcon";
import type { IconParameters } from "./types"; import type { IconParameters } from "./types";
@ -28,7 +42,12 @@ export {
MinusIcon, MinusIcon,
DeviceIcon, DeviceIcon,
TrashIcon, TrashIcon,
RemoveIcon,
RemoveStrokeIcon,
UpIcon, UpIcon,
DownIcon,
LeftIcon,
RightIcon,
UploadIcon, UploadIcon,
UploadMultiIcon, UploadMultiIcon,
AttachmentIcon, AttachmentIcon,
@ -39,4 +58,13 @@ export {
ErrorIcon, ErrorIcon,
ZoomIcon, ZoomIcon,
PictureIcon, PictureIcon,
LeaveIcon,
InviteIcon,
CheckIcon,
KeyIcon,
HashIcon,
EditIcon,
ReactIcon,
PinIcon,
LocationIcon,
}; };

View file

@ -11,7 +11,7 @@ import {
setAuthSessionIV, setAuthSessionIV,
} from "../../store/auth"; } from "../../store/auth";
import { state } from "../../store/state"; import { state } from "../../store/state";
import { hexToBytes } from "../crypto"; import { base64ToBuffer } from "../crypto";
const fetchRegister = async ( const fetchRegister = async (
username: string, username: string,
@ -61,14 +61,14 @@ const fetchRefresh = async () => {
if ( if (
!state.auth.session?.storageSecret || !state.auth.session?.storageSecret ||
state.auth.session.storageSecret.length !== 89 state.auth.session.storageSecret.length !== 61
) { ) {
return; return;
} }
const [keyHex, ivHex] = state.auth.session.storageSecret.split(";"); const [keyBase64, ivBase64] = state.auth.session.storageSecret.split(";");
const key = hexToBytes(keyHex); const key = base64ToBuffer(keyBase64);
const iv = hexToBytes(ivHex); const iv = base64ToBuffer(ivBase64);
if (key && iv) { if (key && iv) {
setAuthSessionKey(new Uint8Array(key)); setAuthSessionKey(new Uint8Array(key));

View file

@ -4,6 +4,7 @@ import {
updateChannelApi, updateChannelApi,
removeChannelApi, removeChannelApi,
fetchChannelMessagesApi, fetchChannelMessagesApi,
IUpdateChannelRequest,
} from "../../api/channel"; } from "../../api/channel";
import { import {
deleteChannel, deleteChannel,
@ -39,16 +40,8 @@ const createChannel = async (name: string, communityId: string) => {
setChannel(data); setChannel(data);
}; };
const updateChannel = async ( const updateChannel = async (updateChannelData: IUpdateChannelRequest) => {
id: string, const data = await updateChannelApi(updateChannelData);
name?: string,
description?: string,
) => {
const data = await updateChannelApi({
id: id,
name: name,
description: description,
});
if (typeof data.error === "string") { if (typeof data.error === "string") {
return; return;

View file

@ -9,7 +9,12 @@ import {
fetchCommunityRolesApi, fetchCommunityRolesApi,
fetchCommunityMembersApi, fetchCommunityMembersApi,
fetchCommunityInvitesApi, fetchCommunityInvitesApi,
updateCommunityChannelOrderApi,
updateCommunityRoleOrderApi,
removeCommunityMemberApi,
createCommunityInviteApi,
IUpdateCommunityRequest, IUpdateCommunityRequest,
ICreateCommunityInviteRequest,
} from "../../api/community"; } from "../../api/community";
import { setChannel } from "../../store/channel"; import { setChannel } from "../../store/channel";
import { import {
@ -24,7 +29,7 @@ import {
import { setInvite } from "../../store/invite"; import { setInvite } from "../../store/invite";
import { setRole } from "../../store/role"; import { setRole } from "../../store/role";
import { state } from "../../store/state"; import { state } from "../../store/state";
import { setUser } from "../../store/user"; import { getLoggedInUser, setUser } from "../../store/user";
import { DB_STORE, dbLoadEncrypted } from "../database"; import { DB_STORE, dbLoadEncrypted } from "../database";
import { fetchUserCommunities } from "../user"; import { fetchUserCommunities } from "../user";
@ -152,6 +157,69 @@ const fetchCommunityInvites = async (id: string) => {
}); });
}; };
const updateCommunityChannelOrder = async (id: string, order: string[]) => {
const data = await updateCommunityChannelOrderApi({
id: id,
order: order,
});
if (typeof data.error === "string") {
return;
}
fetchCommunityChannels(id);
};
const updateCommunityRoleOrder = async (id: string, order: string[]) => {
const data = await updateCommunityRoleOrderApi({
id: id,
order: order,
});
if (typeof data.error === "string") {
return;
}
fetchCommunityRoles(id);
fetchCommunityMembers(id);
};
const createCommunityInvite = async (
createCommunityInviteData: ICreateCommunityInviteRequest,
) => {
const data = await createCommunityInviteApi(createCommunityInviteData);
if (typeof data.error === "string") {
return;
}
setInvite(data);
fetchCommunityInvites(createCommunityInviteData.communityId);
};
const removeCommunityMember = async (id: string, memberId: string) => {
const data = await removeCommunityMemberApi({
id: id,
memberId: memberId,
});
if (typeof data.error === "string") {
return;
}
if (memberId !== getLoggedInUser()?.id) {
fetchCommunityMembers(data.id);
return;
}
deleteCommunity(data.id);
if (state.user.loggedUserId) {
fetchUserCommunities(state.user.loggedUserId);
}
};
const loadCommunityCryptoStates = async () => { const loadCommunityCryptoStates = async () => {
if (!state.user.loggedUserId) { if (!state.user.loggedUserId) {
return; return;
@ -189,6 +257,10 @@ export {
fetchCommunityRoles, fetchCommunityRoles,
fetchCommunityMembers, fetchCommunityMembers,
fetchCommunityInvites, fetchCommunityInvites,
updateCommunityChannelOrder,
updateCommunityRoleOrder,
createCommunityInvite,
removeCommunityMember,
loadCommunityCryptoStates, loadCommunityCryptoStates,
getCommunityAvatarUrl, getCommunityAvatarUrl,
}; };

View file

@ -86,26 +86,6 @@ const generateIv = (): Uint8Array<ArrayBuffer> => {
return crypto.getRandomValues(new Uint8Array(12)); return crypto.getRandomValues(new Uint8Array(12));
}; };
const hexToBytes = (hex: string): ArrayBuffer | undefined => {
if (hex.length % 2 !== 0) {
return;
}
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i++) {
bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
}
return bytes.buffer;
};
const bytesToHex = (bytes: ArrayBuffer): string => {
const bytesUint8 = new Uint8Array(bytes);
let hex = "";
for (const byte of bytesUint8) {
hex += byte.toString(16).padStart(2, "0");
}
return hex;
};
const bufferToBase64 = (buffer: ArrayBuffer): string => { const bufferToBase64 = (buffer: ArrayBuffer): string => {
const bytes = new Uint8Array(buffer); const bytes = new Uint8Array(buffer);
let binary = ""; let binary = "";
@ -137,8 +117,6 @@ export {
encryptBytes, encryptBytes,
decryptBytes, decryptBytes,
generateIv, generateIv,
hexToBytes,
bytesToHex,
bufferToBase64, bufferToBase64,
base64ToBuffer, base64ToBuffer,
}; };

View file

@ -18,9 +18,12 @@ import {
} from "../../store/file"; } from "../../store/file";
import { state } from "../../store/state"; import { state } from "../../store/state";
import { import {
base64ToBuffer,
bufferToBase64, bufferToBase64,
decryptBytes, decryptBytes,
decryptData,
encryptBytes, encryptBytes,
encryptData,
generateIv, generateIv,
} from "../crypto"; } from "../crypto";
@ -52,6 +55,7 @@ const uploadCommunityAvatar = async (
const fetchAttachment = async ( const fetchAttachment = async (
id: string, id: string,
communityId: string,
): Promise<IAttachment | undefined> => { ): Promise<IAttachment | undefined> => {
const data = await fetchAttachmentApi({ const data = await fetchAttachmentApi({
id: id, id: id,
@ -61,9 +65,52 @@ const fetchAttachment = async (
return; return;
} }
setAttachment(data); const key = state.community.communities[communityId]?.derivedKey;
if (!key) {
return;
}
return data; try {
const ivBytes = base64ToBuffer(data.iv);
const filenameBytes = base64ToBuffer(data.filename);
const mimetypeBytes = base64ToBuffer(data.mimetype);
if (!ivBytes || !filenameBytes || !mimetypeBytes) {
return;
}
const filename = await decryptData<string>({
key: key,
iv: new Uint8Array(ivBytes),
encryptedData: filenameBytes,
});
const mimetype = await decryptData<string>({
key: key,
iv: new Uint8Array(ivBytes),
encryptedData: mimetypeBytes,
});
const decryptedData: IAttachment = {
...data,
filename: filename,
mimetype: mimetype,
decryptionStatus: true,
};
setAttachment(decryptedData);
return decryptedData;
} catch {
setAttachment({
...data,
decryptionStatus: false,
});
return {
...data,
decryptionStatus: false,
};
}
}; };
const createAttachment = async ( const createAttachment = async (
@ -72,10 +119,33 @@ const createAttachment = async (
mimetype: string, mimetype: string,
size: number, size: number,
): Promise<string | undefined> => { ): Promise<string | undefined> => {
try {
const key = state.community.communities[communityId]?.derivedKey;
if (!key) {
return;
}
const iv = generateIv();
const encryptedFilename = await encryptData<string>({
key: key,
iv: iv,
data: filename,
});
const encryptedMimetype = await encryptData<string>({
key: key,
iv: iv,
data: mimetype,
});
if (!encryptedFilename || !encryptedMimetype) {
return;
}
const data = await createAttachmentApi({ const data = await createAttachmentApi({
iv: bufferToBase64(iv.buffer),
communityId: communityId, communityId: communityId,
filename: filename, filename: bufferToBase64(encryptedFilename),
mimetype: mimetype, mimetype: bufferToBase64(encryptedMimetype),
size: size, size: size,
}); });
@ -86,6 +156,9 @@ const createAttachment = async (
setAttachment({ ...data, fullFile: undefined }); setAttachment({ ...data, fullFile: undefined });
return data.id; return data.id;
} catch {
return;
}
}; };
const finishAttachment = async (id: string) => { const finishAttachment = async (id: string) => {
@ -170,7 +243,7 @@ const fetchChunks = async (
}; };
const fetchFile = async (communityId: string, attachmentId: string) => { const fetchFile = async (communityId: string, attachmentId: string) => {
const attachment = await fetchAttachment(attachmentId); const attachment = await fetchAttachment(attachmentId, communityId);
if (!attachment) { if (!attachment) {
return; return;
} }
@ -192,12 +265,15 @@ const fetchFile = async (communityId: string, attachmentId: string) => {
}; };
const fetchMedia = async (communityId: string, attachmentId: string) => { const fetchMedia = async (communityId: string, attachmentId: string) => {
const attachment = await fetchAttachment(attachmentId); const attachment = await fetchAttachment(attachmentId, communityId);
if (!attachment) { if (!attachment) {
return; return;
} }
if (attachment.mimetype.startsWith("image/")) { if (
attachment.decryptionStatus &&
attachment.mimetype.startsWith("image/")
) {
await fetchChunks( await fetchChunks(
communityId, communityId,
attachmentId, attachmentId,
@ -260,7 +336,12 @@ const uploadChunks = async (
const end = Math.min(start + CHUNK_SIZE, file.size); const end = Math.min(start + CHUNK_SIZE, file.size);
const blob = file.slice(start, end); const blob = file.slice(start, end);
const uploaded = uploadChunk(communityId, attachmentId, index, blob); const uploaded = await uploadChunk(
communityId,
attachmentId,
index,
blob,
);
if (!uploaded) { if (!uploaded) {
return false; return false;
} }

View file

@ -3,7 +3,7 @@ import {
removeInviteApi, removeInviteApi,
acceptInviteApi, acceptInviteApi,
} from "../../api/invite"; } from "../../api/invite";
import { deleteInvite, setInvite } from "../../store/invite"; import { deleteInvite, setInvite, setInviteView } from "../../store/invite";
import { state } from "../../store/state"; import { state } from "../../store/state";
import { fetchUserCommunities } from "../user"; import { fetchUserCommunities } from "../user";
@ -19,6 +19,19 @@ const fetchInvite = async (id: string) => {
setInvite(data); setInvite(data);
}; };
const fetchInviteView = async (id: string) => {
const data = await fetchInviteApi({
id: id,
});
if (typeof data.error === "string") {
setInviteView(false);
return;
}
setInviteView(data);
};
const removeInvite = async (id: string) => { const removeInvite = async (id: string) => {
const data = await removeInviteApi({ const data = await removeInviteApi({
id: id, id: id,
@ -45,4 +58,4 @@ const acceptInvite = async (id: string) => {
} }
}; };
export { fetchInvite, removeInvite, acceptInvite }; export { fetchInvite, fetchInviteView, removeInvite, acceptInvite };

View file

@ -4,15 +4,16 @@ import {
updateMessageApi, updateMessageApi,
removeMessageApi, removeMessageApi,
} from "../../api/message"; } from "../../api/message";
import { deleteChannelMessage, setChannelMessage } from "../../store/channel";
import { deleteAttachment } from "../../store/file"; import { deleteAttachment } from "../../store/file";
import { deleteMessage, setMessage } from "../../store/message"; import { deleteMessage, setMessage } from "../../store/message";
import { state } from "../../store/state"; import { state } from "../../store/state";
import { import {
bytesToHex, base64ToBuffer,
bufferToBase64,
decryptData, decryptData,
encryptData, encryptData,
generateIv, generateIv,
hexToBytes,
} from "../crypto"; } from "../crypto";
import { fetchMedia } from "../file"; import { fetchMedia } from "../file";
@ -66,10 +67,10 @@ const createMessage = async (
if (typeof data.error === "string") { if (typeof data.error === "string") {
return; return;
} }
setMessage({ setMessage({
...data, ...data,
text: text, text: text,
decryptionStatus: true,
}); });
attachments.forEach((attachmentId) => { attachments.forEach((attachmentId) => {
@ -77,20 +78,31 @@ const createMessage = async (
}); });
}; };
const updateMessage = async (id: string, text: string) => { const updateMessage = async (id: string, text: string, communityId: string) => {
const encrypted = await encryptMessage(communityId, text);
if (!encrypted) {
return;
}
const [encryptedMessage, iv] = encrypted;
const data = await updateMessageApi({ const data = await updateMessageApi({
id: id, id: id,
text: text, iv: iv,
text: encryptedMessage,
}); });
if (typeof data.error === "string") { if (typeof data.error === "string") {
return; return;
} }
setMessage(data); setMessage({
...data,
text: text,
decryptionStatus: true,
});
}; };
const removeMessage = async (id: string) => { const removeMessage = async (id: string, channelId?: string) => {
const data = await removeMessageApi({ const data = await removeMessageApi({
id: id, id: id,
}); });
@ -100,6 +112,10 @@ const removeMessage = async (id: string) => {
} }
deleteMessage(); deleteMessage();
if (channelId) {
deleteChannelMessage(channelId, id);
}
}; };
const encryptMessage = async ( const encryptMessage = async (
@ -114,7 +130,7 @@ const encryptMessage = async (
const iv = generateIv(); const iv = generateIv();
if (text.length === 0) { if (text.length === 0) {
return ["", bytesToHex(iv.buffer)]; return ["", bufferToBase64(iv.buffer)];
} }
const encrypted = await encryptData<string>({ const encrypted = await encryptData<string>({
@ -123,7 +139,7 @@ const encryptMessage = async (
data: text, data: text,
}); });
return [bytesToHex(encrypted), bytesToHex(iv.buffer)]; return [bufferToBase64(encrypted), bufferToBase64(iv.buffer)];
}; };
const decryptMessage = async ( const decryptMessage = async (
@ -136,13 +152,13 @@ const decryptMessage = async (
return; return;
} }
const ivBytes = hexToBytes(iv); const ivBytes = base64ToBuffer(iv);
const textBytes = hexToBytes(text); const textBytes = base64ToBuffer(text);
if (!ivBytes || !textBytes) { if (!ivBytes || !textBytes) {
return; return;
} }
return await decryptData({ return await decryptData<string>({
key: key, key: key,
iv: new Uint8Array(ivBytes), iv: new Uint8Array(ivBytes),
encryptedData: textBytes, encryptedData: textBytes,

View file

@ -3,8 +3,14 @@ import {
createRoleApi, createRoleApi,
updateRoleApi, updateRoleApi,
removeRoleApi, removeRoleApi,
assignRoleApi,
unassignRoleApi,
fetchPermissionsApi,
IUpdateRoleRequest,
} from "../../api/role"; } from "../../api/role";
import { deleteRole, setRole } from "../../store/role"; import { deleteRole, setPermissions, setRole } from "../../store/role";
import { state } from "../../store/state";
import { fetchUserCommunityRoles } from "../user";
const fetchRole = async (id: string) => { const fetchRole = async (id: string) => {
const data = await fetchRoleApi({ const data = await fetchRoleApi({
@ -22,6 +28,7 @@ const createRole = async (name: string, communityId: string) => {
const data = await createRoleApi({ const data = await createRoleApi({
name: name, name: name,
communityId: communityId, communityId: communityId,
permissions: [],
}); });
if (typeof data.error === "string") { if (typeof data.error === "string") {
@ -31,11 +38,8 @@ const createRole = async (name: string, communityId: string) => {
setRole(data); setRole(data);
}; };
const updateRole = async (id: string, name?: string) => { const updateRole = async (updateRoleData: IUpdateRoleRequest) => {
const data = await updateRoleApi({ const data = await updateRoleApi(updateRoleData);
id: id,
name: name,
});
if (typeof data.error === "string") { if (typeof data.error === "string") {
return; return;
@ -56,4 +60,91 @@ const removeRole = async (id: string) => {
deleteRole(data.id); deleteRole(data.id);
}; };
export { fetchRole, createRole, updateRole, removeRole }; const assignRole = async (id: string, userId: string) => {
const data = await assignRoleApi({
id: id,
userId: userId,
});
if (typeof data.error === "string") {
return;
}
fetchUserCommunityRoles(data.userId, data.communityId);
};
const unassignRole = async (id: string, userId: string) => {
const data = await unassignRoleApi({
id: id,
userId: userId,
});
if (typeof data.error === "string") {
return;
}
fetchUserCommunityRoles(data.userId, data.communityId);
};
const fetchPermissions = async () => {
const data = await fetchPermissionsApi();
if (typeof data.error === "string") {
return;
}
setPermissions(data.permissions);
};
const getUserBestRoleId = (
userId: string,
communityId: string,
): string | undefined => {
const user = state.user.users[userId];
const community = state.community.communities[communityId];
if (!user || !community) {
return;
}
const communityRoleIds = community.roles;
if (!communityRoleIds) {
return;
}
const communityRoles = communityRoleIds
.map((roleId) => {
const role = state.role.roles[roleId];
if (!role) {
return {
id: roleId,
};
}
return role;
})
.filter((role) => role.id !== "" && role.showInMembers);
let bestRoleOrder = 999999;
let bestRoleId: string | undefined = undefined;
communityRoles.forEach((role) => {
if (
(role.order ?? 999999) < bestRoleOrder &&
(user.roles ?? {})[communityId].includes(role.id)
) {
bestRoleOrder = role.order ?? 999999;
bestRoleId = role.id;
}
});
return bestRoleId;
};
export {
fetchRole,
createRole,
updateRole,
removeRole,
assignRole,
unassignRole,
fetchPermissions,
getUserBestRoleId,
};

View file

@ -5,15 +5,18 @@ import {
fetchUserApi, fetchUserApi,
fetchUserSessionsApi, fetchUserSessionsApi,
fetchUserCommunitiesApi, fetchUserCommunitiesApi,
fetchUserCommunityRolesApi,
updateUserApi, updateUserApi,
IUpdateUserRequest, IUpdateUserRequest,
} from "../../api/user"; } from "../../api/user";
import { setCommunity } from "../../store/community"; import { setCommunity } from "../../store/community";
import { setRole } from "../../store/role";
import { setSession } from "../../store/session"; import { setSession } from "../../store/session";
import { import {
setLoggedUserId, setLoggedUserId,
setUser, setUser,
setUserCommunities, setUserCommunities,
setUserCommunityRoles,
setUserSessions, setUserSessions,
} from "../../store/user"; } from "../../store/user";
@ -71,6 +74,23 @@ const fetchUserCommunities = async (id: string) => {
}); });
}; };
const fetchUserCommunityRoles = async (id: string, communityId: string) => {
const data = await fetchUserCommunityRolesApi({
id: id,
communityId: communityId,
});
if (typeof data.error === "string") {
return;
}
setUserCommunityRoles(data);
data.roles.forEach((role) => {
setRole(role);
});
};
const updateUser = async (updateUserData: IUpdateUserRequest) => { const updateUser = async (updateUserData: IUpdateUserRequest) => {
const data = await updateUserApi(updateUserData); const data = await updateUserApi(updateUserData);
@ -93,6 +113,7 @@ export {
fetchUser, fetchUser,
fetchUserSessions, fetchUserSessions,
fetchUserCommunities, fetchUserCommunities,
fetchUserCommunityRoles,
updateUser, updateUser,
getUserAvatarUrl, getUserAvatarUrl,
}; };

View file

@ -8,6 +8,7 @@ enum SocketMessageTypes {
ANNOUNCEMENT = "ANNOUNCEMENT", ANNOUNCEMENT = "ANNOUNCEMENT",
SET_MESSAGE = "SET_MESSAGE", SET_MESSAGE = "SET_MESSAGE",
DELETE_MESSAGE = "DELETE_MESSAGE", DELETE_MESSAGE = "DELETE_MESSAGE",
UPDATE_COMMUNITY = "UPDATE_COMMUNITY",
UPDATE_CHANNELS = "UPDATE_CHANNELS", UPDATE_CHANNELS = "UPDATE_CHANNELS",
UPDATE_ROLES = "UPDATE_ROLES", UPDATE_ROLES = "UPDATE_ROLES",
UPDATE_MEMBERS = "UPDATE_MEMBERS", UPDATE_MEMBERS = "UPDATE_MEMBERS",
@ -22,7 +23,7 @@ type SocketMessage =
type: SocketMessageTypes.ANNOUNCEMENT; type: SocketMessageTypes.ANNOUNCEMENT;
payload: { payload: {
title: string; title: string;
description: string; text: string;
}; };
} }
| { | {
@ -45,6 +46,12 @@ type SocketMessage =
communityId: string; communityId: string;
}; };
} }
| {
type: SocketMessageTypes.UPDATE_COMMUNITY;
payload: {
communityId: string;
};
}
| { | {
type: SocketMessageTypes.UPDATE_ROLES; type: SocketMessageTypes.UPDATE_ROLES;
payload: { payload: {

View file

@ -1,6 +1,8 @@
import { setAnnouncement, setAnnouncementOpen } from "../../store/app";
import { deleteChannelMessage, setChannelMessage } from "../../store/channel"; import { deleteChannelMessage, setChannelMessage } from "../../store/channel";
import { state } from "../../store/state"; import { state } from "../../store/state";
import { import {
fetchCommunity,
fetchCommunityChannels, fetchCommunityChannels,
fetchCommunityMembers, fetchCommunityMembers,
fetchCommunityRoles, fetchCommunityRoles,
@ -10,9 +12,13 @@ import { decryptMessage } from "../message";
import config from "./config.json"; import config from "./config.json";
import { SocketMessage, SocketMessageTypes } from "./types"; import { SocketMessage, SocketMessageTypes } from "./types";
let connection: WebSocket; let connection: WebSocket | undefined = undefined;
const connectWs = () => { const connectWs = () => {
if (connection) {
return;
}
connection = new WebSocket( connection = new WebSocket(
`${config.schema}://${config.url}:${config.port}/${config.path}`, `${config.schema}://${config.url}:${config.port}/${config.path}`,
); );
@ -90,6 +96,10 @@ const handleMessage = (message: SocketMessage) => {
); );
break; break;
} }
case SocketMessageTypes.UPDATE_COMMUNITY: {
fetchCommunity(message.payload.communityId);
break;
}
case SocketMessageTypes.UPDATE_CHANNELS: { case SocketMessageTypes.UPDATE_CHANNELS: {
fetchCommunityChannels(message.payload.communityId); fetchCommunityChannels(message.payload.communityId);
break; break;
@ -103,6 +113,11 @@ const handleMessage = (message: SocketMessage) => {
break; break;
} }
case SocketMessageTypes.ANNOUNCEMENT: { case SocketMessageTypes.ANNOUNCEMENT: {
setAnnouncement({
title: message.payload.title,
text: message.payload.text,
});
setAnnouncementOpen(true);
break; break;
} }
} }

View file

@ -1,27 +1,69 @@
import { setState } from "../state"; import { setState } from "../state";
import { IAnnouncement } from "./types";
const setHomeOpen = (value: boolean) => { const setAnnouncement = (announcement: IAnnouncement) => {
setState("app", "homeOpen", value); setState("app", "announcement", announcement);
}; };
const setSettingsOpen = (value: boolean) => { const setHomeOpen = (open: boolean) => {
setState("app", "homeOpen", open);
};
const setAnnouncementOpen = (open: boolean) => {
setState("app", "dialogsOpen", "announcementOpen", false);
setState("app", "dialogsOpen", "announcementOpen", open);
};
const setSettingsOpen = (open: boolean) => {
setState("app", "dialogsOpen", "settingsOpen", false); setState("app", "dialogsOpen", "settingsOpen", false);
setState("app", "dialogsOpen", "settingsOpen", value); setState("app", "dialogsOpen", "settingsOpen", open);
}; };
const setAddCommunityOpen = (value: boolean) => { const setAddCommunityOpen = (open: boolean) => {
setState("app", "dialogsOpen", "addCommunityOpen", false); setState("app", "dialogsOpen", "addCommunityOpen", false);
setState("app", "dialogsOpen", "addCommunityOpen", value); setState("app", "dialogsOpen", "addCommunityOpen", open);
}; };
const setCommunitySettingsOpen = (value: boolean) => { const setCommunitySettingsOpen = (open: boolean) => {
setState("app", "dialogsOpen", "communitySettingsOpen", false); setState("app", "dialogsOpen", "communitySettingsOpen", false);
setState("app", "dialogsOpen", "communitySettingsOpen", value); setState("app", "dialogsOpen", "communitySettingsOpen", open);
};
const setCreateChannelOpen = (open: boolean) => {
setState("app", "dialogsOpen", "createChannelOpen", false);
setState("app", "dialogsOpen", "createChannelOpen", open);
};
const setCreateRoleOpen = (open: boolean) => {
setState("app", "dialogsOpen", "createRoleOpen", false);
setState("app", "dialogsOpen", "createRoleOpen", open);
};
const setCreateInviteOpen = (open: boolean) => {
setState("app", "dialogsOpen", "createInviteOpen", false);
setState("app", "dialogsOpen", "createInviteOpen", open);
};
const setContextMenuOpenId = (contextMenuOpenId: string) => {
setState("app", "contextMenuOpenId", contextMenuOpenId);
setState("app", "contextMenuOpenId", contextMenuOpenId);
};
const resetContextMenuOpenId = () => {
setState("app", "contextMenuOpenId", null);
setState("app", "contextMenuOpenId", null);
}; };
export { export {
setAnnouncement,
setHomeOpen, setHomeOpen,
setAnnouncementOpen,
setSettingsOpen, setSettingsOpen,
setAddCommunityOpen, setAddCommunityOpen,
setCommunitySettingsOpen, setCommunitySettingsOpen,
setCreateChannelOpen,
setCreateRoleOpen,
setCreateInviteOpen,
setContextMenuOpenId,
resetContextMenuOpenId,
}; };

View file

@ -1,12 +1,23 @@
interface IAppState { interface IAppState {
homeOpen: boolean; homeOpen: boolean;
announcement: IAnnouncement;
dialogsOpen: IDialogsOpen; dialogsOpen: IDialogsOpen;
contextMenuOpenId: string | null;
}
interface IAnnouncement {
title: string;
text: string;
} }
interface IDialogsOpen { interface IDialogsOpen {
announcementOpen: boolean;
settingsOpen: boolean; settingsOpen: boolean;
addCommunityOpen: boolean; addCommunityOpen: boolean;
communitySettingsOpen: boolean; communitySettingsOpen: boolean;
createChannelOpen: boolean;
createRoleOpen: boolean;
createInviteOpen: boolean;
} }
export { type IAppState, type IDialogsOpen }; export { type IAppState, type IAnnouncement, type IDialogsOpen };

View file

@ -9,6 +9,8 @@ interface IChannel {
id: string; id: string;
name?: string; name?: string;
description?: string; description?: string;
category?: string;
order?: number;
communityId?: string; communityId?: string;
creationDate?: number; creationDate?: number;
text?: string; text?: string;

View file

@ -5,6 +5,7 @@ interface IFileState {
interface IAttachment { interface IAttachment {
id: string; id: string;
iv: string;
filename: string; filename: string;
mimetype: string; mimetype: string;
size: number; size: number;

View file

@ -5,8 +5,12 @@ const setInvite = (invite: IInvite) => {
setState("invite", "invites", invite.id, invite); setState("invite", "invites", invite.id, invite);
}; };
const setInviteView = (invite: IInvite | false) => {
setState("invite", "inviteView", invite);
};
const deleteInvite = (inviteId: string) => { const deleteInvite = (inviteId: string) => {
setState("invite", "invites", inviteId, undefined); setState("invite", "invites", inviteId, undefined);
}; };
export { setInvite, deleteInvite }; export { setInvite, setInviteView, deleteInvite };

View file

@ -1,4 +1,5 @@
interface IInviteState { interface IInviteState {
inviteView?: IInvite | false;
invites: Record<string, IInvite | undefined>; invites: Record<string, IInvite | undefined>;
} }

View file

@ -5,8 +5,20 @@ const setRole = (role: IRole) => {
setState("role", "roles", role.id, role); setState("role", "roles", role.id, role);
}; };
const setRoleName = (roleId: string, name: string) => {
setState("role", "roles", roleId, "name", name);
};
const setRolePermissions = (roleId: string, permissions: string[]) => {
setState("role", "roles", roleId, "permissions", permissions);
};
const deleteRole = (roleId: string) => { const deleteRole = (roleId: string) => {
setState("role", "roles", roleId, undefined); setState("role", "roles", roleId, undefined);
}; };
export { setRole, deleteRole }; const setPermissions = (permissions: string[]) => {
setState("role", "permissions", permissions);
};
export { setRole, setRoleName, setRolePermissions, deleteRole, setPermissions };

View file

@ -1,11 +1,15 @@
interface IRoleState { interface IRoleState {
roles: Record<string, IRole | undefined>; roles: Record<string, IRole | undefined>;
permissions: string[];
} }
interface IRole { interface IRole {
id: string; id: string;
name?: string; name?: string;
description?: string; description?: string;
color?: string;
order?: number;
showInMembers?: boolean;
communityId?: string; communityId?: string;
permissions?: string[]; permissions?: string[];
creationDate?: number; creationDate?: number;

View file

@ -4,11 +4,20 @@ import { IState } from "./types";
const [state, setState] = createStore<IState>({ const [state, setState] = createStore<IState>({
app: { app: {
homeOpen: true, homeOpen: true,
announcement: {
title: "",
text: "",
},
dialogsOpen: { dialogsOpen: {
announcementOpen: false,
settingsOpen: false, settingsOpen: false,
addCommunityOpen: false, addCommunityOpen: false,
communitySettingsOpen: false, communitySettingsOpen: false,
createChannelOpen: false,
createRoleOpen: false,
createInviteOpen: false,
}, },
contextMenuOpenId: null,
}, },
auth: { auth: {
registerSuccess: undefined, registerSuccess: undefined,
@ -27,11 +36,13 @@ const [state, setState] = createStore<IState>({
}, },
role: { role: {
roles: {}, roles: {},
permissions: [],
}, },
session: { session: {
sessions: {}, sessions: {},
}, },
invite: { invite: {
inviteView: undefined,
invites: {}, invites: {},
}, },
message: { message: {

View file

@ -15,6 +15,7 @@ interface IUser {
lastLogin?: number; lastLogin?: number;
sessions?: string[]; sessions?: string[];
communities?: string[]; communities?: string[];
roles?: Record<string, string[]>;
} }
export { type IUserState, type IUser }; export { type IUserState, type IUser };

View file

@ -1,5 +1,6 @@
import { import {
IFetchUserCommunitiesResponse, IFetchUserCommunitiesResponse,
IFetchUserCommunityRolesResponse,
IFetchUserSessionsResponse, IFetchUserSessionsResponse,
} from "../../api/user"; } from "../../api/user";
import { loadCommunityCryptoStates } from "../../services/community"; import { loadCommunityCryptoStates } from "../../services/community";
@ -80,6 +81,21 @@ const setUserCommunities = (communities: IFetchUserCommunitiesResponse) => {
loadCommunityCryptoStates(); loadCommunityCryptoStates();
}; };
const setUserCommunityRoles = (roles: IFetchUserCommunityRolesResponse) => {
if (!state.user.users[roles.id].roles) {
setState("user", "users", roles.id, "roles", {});
}
setState(
"user",
"users",
roles.id,
"roles",
roles.communityId,
roles.roles.map((role) => role.id),
);
};
export { export {
getLoggedInUser, getLoggedInUser,
setUser, setUser,
@ -94,4 +110,5 @@ export {
setLoggedUserId, setLoggedUserId,
setUserSessions, setUserSessions,
setUserCommunities, setUserCommunities,
setUserCommunityRoles,
}; };

View file

@ -0,0 +1,31 @@
import { type Component } from "solid-js";
import { IAnnouncementModalViewProps } from "./types";
import { state } from "../../store/state";
const AnnouncementModalView: Component<IAnnouncementModalViewProps> = (
props: IAnnouncementModalViewProps,
) => {
return (
<div>
<dialog
ref={props.dialogRef}
class="modal outline-none bg-[#00000050]"
>
<div class="modal-box bg-stone-950 rounded-3xl">
<h3 class="text-lg font-bold text-center">
{state.app.announcement.title}
</h3>
<div class="divider"></div>
<p>{state.app.announcement.text}</p>
</div>
<form
onClick={props.onClose}
method="dialog"
class="modal-backdrop"
></form>
</dialog>
</div>
);
};
export default AnnouncementModalView;

View file

@ -0,0 +1,2 @@
export * from "./AnnouncementModalView";
export * from "./types";

View file

@ -0,0 +1,6 @@
interface IAnnouncementModalViewProps {
dialogRef?: (element: HTMLDialogElement) => void;
onClose?: () => void;
}
export { type IAnnouncementModalViewProps };

View file

@ -14,6 +14,7 @@ import {
fetchUser, fetchUser,
fetchUserCommunities, fetchUserCommunities,
} from "../../services/user"; } from "../../services/user";
import { fetchPermissions } from "../../services/role";
const AppView: Component = () => { const AppView: Component = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -32,13 +33,15 @@ const AppView: Component = () => {
if (state.user.loggedUserId) { if (state.user.loggedUserId) {
fetchUser(state.user.loggedUserId); fetchUser(state.user.loggedUserId);
fetchUserCommunities(state.user.loggedUserId); fetchUserCommunities(state.user.loggedUserId);
fetchPermissions();
} }
}); });
createEffect(() => { createEffect(() => {
console.log(state.auth.loggedIn);
if (state.auth.loggedIn === false) { if (state.auth.loggedIn === false) {
navigate("/login"); navigate("/login");
} else { } else if (state.auth.loggedIn === true) {
connectWs(); connectWs();
} }
}); });

View file

@ -1,28 +1,126 @@
import { createEffect, createMemo, type Component } from "solid-js"; import { createEffect, createMemo, JSXElement, type Component } from "solid-js";
import { state } from "../../store/state"; import { state } from "../../store/state";
import { ICommunity } from "../../store/community"; import {
getActiveCommunity,
ICommunity,
resetActiveCommunity,
} from "../../store/community";
import { Channel } from "../../components/Channel"; import { Channel } from "../../components/Channel";
import { CommunityBar } from "../../components/CommunityBar"; import { CommunityBar } from "../../components/CommunityBar";
import { setCommunitySettingsOpen } from "../../store/app"; import {
import { fetchCommunityChannels } from "../../services/community"; setCommunitySettingsOpen,
import { fetchChannel } from "../../services/channel"; setCreateChannelOpen,
import { setActiveChannel } from "../../store/channel"; } from "../../store/app";
import {
fetchCommunityChannels,
getCommunityAvatarUrl,
updateCommunityChannelOrder,
} from "../../services/community";
import { fetchChannel, removeChannel } from "../../services/channel";
import {
getActiveChannel,
IChannel,
resetActiveChannel,
setActiveChannel,
} from "../../store/channel";
import {
ContextMenu,
IContextMenuItem,
useContextMenu,
} from "../../components/ContextMenu";
import { DownIcon, PlusIcon, TrashIcon, UpIcon } from "../../icons";
const ChannelView: Component = () => { const ChannelView: Component = () => {
const channelIds = createMemo(() => { const listMenu = useContextMenu("channel-list-menu");
const activeCommunityId = state.community.active; const categoryMenu = useContextMenu<string | null>("channel-category-menu");
if (!activeCommunityId) { const channelMenu = useContextMenu<string>("channel-channel-menu");
const listMenuItems: IContextMenuItem[] = [
{
label: "Add new channel",
icon: PlusIcon,
color: "var(--color-stone-200)",
onClick: () => onCreateChannel(),
},
];
const categoryMenuItems = createMemo<IContextMenuItem[]>(() => {
if (!categoryMenu.getData()) {
return []; return [];
} }
const community = state.community.communities[activeCommunityId]; return [
{
label: "Move category up",
icon: UpIcon,
onClick: () => onSwapCategory("prev", categoryMenu.getData()),
},
{
label: "Move category down",
icon: DownIcon,
onClick: () => onSwapCategory("next", categoryMenu.getData()),
},
];
});
const channelMenuItems: IContextMenuItem[] = [
{
label: "Move up",
icon: UpIcon,
onClick: () => onSwapChannel("prev", channelMenu.getData()),
},
{
label: "Move down",
icon: DownIcon,
onClick: () => onSwapChannel("next", channelMenu.getData()),
},
{
label: "Remove channel",
icon: TrashIcon,
color: "var(--color-error)",
onClick: () => onRemoveChannel(channelMenu.getData()),
},
];
const channelIds = createMemo(() => {
const community = getActiveCommunity();
if (!community) { if (!community) {
resetActiveChannel();
resetActiveCommunity();
return []; return [];
} }
const channel = getActiveChannel();
if (!channel) {
resetActiveChannel();
}
return community.channels ?? []; return community.channels ?? [];
}); });
const channels = createMemo(() => {
return channelIds()
.map((channelId) => {
const channel = state.channel.channels[channelId];
if (!channel) {
return {
id: channelId,
};
}
return channel;
})
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
});
const categories = createMemo<string[]>(() => {
const categoriesDuplicate = channels()
.map((channel) => channel.category ?? "")
.filter((category) => category !== "");
return [...new Set(categoriesDuplicate)];
});
const communityInfo = createMemo<ICommunity | undefined>(() => { const communityInfo = createMemo<ICommunity | undefined>(() => {
const activeCommunityId = state.community.active; const activeCommunityId = state.community.active;
return state.community.communities[activeCommunityId ?? 0]; return state.community.communities[activeCommunityId ?? 0];
@ -43,35 +141,229 @@ const ChannelView: Component = () => {
setActiveChannel(id); setActiveChannel(id);
}; };
const mapChannel = (channelId: string) => { const onCategoryRightClick = (category: string | null, e: MouseEvent) => {
const channel = state.channel.channels[channelId]; categoryMenu.open(e, category);
if (!channel) { };
return undefined;
const onChannelRightClick = (id: string, e: MouseEvent) => {
channelMenu.open(e, id);
};
const onRightClick = (e: MouseEvent) => {
listMenu.open(e, undefined);
};
const onCreateChannel = () => {
setCreateChannelOpen(true);
};
const onRemoveChannel = (channelId: string | undefined) => {
if (!channelId) {
return;
} }
removeChannel(channelId);
};
const onSwapCategory = (
direction: "prev" | "next",
category: string | null | undefined,
) => {
const chs = channels();
const community = getActiveCommunity();
if (!community || !category) {
return;
}
const categoryChannelIndex = findCategoryChannelIndex(category);
const categoryChannel = chs[categoryChannelIndex];
const swapCategoryChannelIndex = findNeighbouringCategoryChannelIndex(
categoryChannel,
direction,
);
const channelMod = chs[categoryChannelIndex];
const channelSwap = chs[swapCategoryChannelIndex];
let newChannels: IChannel[] = [...chs];
newChannels[categoryChannelIndex] = channelSwap;
newChannels[swapCategoryChannelIndex] = channelMod;
finalizeChannelOrder(community.id, newChannels);
};
const onSwapChannel = (
direction: "prev" | "next",
channelId: string | undefined,
) => {
const chs = channels();
const community = getActiveCommunity();
if (!channelId || !community) {
return;
}
const modIndex = chs.findIndex((channel) => channel.id === channelId);
if (modIndex === -1) {
return;
}
const channelMod = chs[modIndex];
const swapIndex = findNeighbouringChannelIndexInCategory(
channelMod,
direction,
);
const channelSwap = chs[swapIndex];
let newChannels: IChannel[] = [...chs];
newChannels[modIndex] = channelSwap;
newChannels[swapIndex] = channelMod;
finalizeChannelOrder(community.id, newChannels);
};
const findNeighbouringCategoryChannelIndex = (
channel: IChannel,
direction: "prev" | "next",
): number => {
const cats = categories();
const categoryChannelIndex = findCategoryChannelIndex(
channel.category ?? "",
);
const categoryIndex = cats.findIndex((cat) => cat === channel.category);
if (direction === "prev" && categoryIndex === 0) {
return categoryChannelIndex;
}
if (direction === "next" && categoryIndex === cats.length - 1) {
return categoryChannelIndex;
}
const neighbouringCategory =
cats[direction === "prev" ? categoryIndex - 1 : categoryIndex + 1];
return findCategoryChannelIndex(neighbouringCategory);
};
const findCategoryChannelIndex = (category: string): number => {
const channelsInCategory = channels().filter(
(ch) => ch.category === category,
);
return findChannelIndexById(channelsInCategory[0].id);
};
const findNeighbouringChannelIndexInCategory = (
channel: IChannel,
direction: "prev" | "next",
): number => {
const channelsInCategory = channels().filter(
(ch) => ch.category === channel.category,
);
const modIndex = channelsInCategory.findIndex(
(ch) => ch.id === channel.id,
);
if (direction === "prev" && modIndex === 0) {
return findChannelIndexById(channel.id);
}
if (
direction === "next" &&
modIndex === channelsInCategory.length - 1
) {
return findChannelIndexById(channel.id);
}
const neighbouringChannel =
channelsInCategory[
direction === "prev" ? modIndex - 1 : modIndex + 1
];
return findChannelIndexById(neighbouringChannel.id);
};
const findChannelIndexById = (id: string): number => {
return channels().findIndex((channel) => channel.id === id);
};
const finalizeChannelOrder = (communityId: string, chs: IChannel[]) => {
for (let i = 0; i < chs.length; i++) {
chs[i] = { ...chs[i], order: i };
}
updateCommunityChannelOrder(
communityId,
chs.map((channel) => channel.id),
);
};
const mapChannel = (channel: IChannel): JSXElement => {
return ( return (
<Channel <Channel
id={channel.id} id={channel.id}
name={channel.name ?? ""} name={channel.name ?? ""}
active={channel.id === state.channel.active} active={channel.id === state.channel.active}
onChannelClick={onChannelClick} onChannelClick={onChannelClick}
onChannelRightClick={onChannelRightClick}
/> />
); );
}; };
const mapCategory = (category: string | null): JSXElement => {
const mappedChannels = channels()
.filter((channel) => channel.category === category)
.map(mapChannel);
if (mappedChannels.length === 0) {
return;
}
return (
<div
class="collapse collapse-arrow bg-stone-900 rounded-xl"
onContextMenu={(e) => onCategoryRightClick(category, e)}
>
<input type="checkbox" checked={true} />
<div class="collapse-title font-semibold opacity-50">
{category || "Uncategorized"}
</div>
<div class="collapse-content">{mappedChannels}</div>
</div>
);
};
return ( return (
<div class="bg-stone-900 w-80 shadow-panel z-20 h-full relative"> <div class="bg-stone-900 w-80 shadow-panel z-20 h-full relative">
<ContextMenu
visible={listMenu.getVisible()}
position={listMenu.getPos()}
items={listMenuItems}
/>
<ContextMenu
visible={categoryMenu.getVisible()}
position={categoryMenu.getPos()}
items={categoryMenuItems()}
/>
<ContextMenu
visible={channelMenu.getVisible()}
position={channelMenu.getPos()}
items={channelMenuItems}
/>
<CommunityBar <CommunityBar
id={communityInfo()?.id} id={communityInfo()?.id}
name={communityInfo()?.name} name={communityInfo()?.name}
description={communityInfo()?.description} description={communityInfo()?.description}
avatar={ avatar={getCommunityAvatarUrl(communityInfo()?.avatar)}
"https://img.daisyui.com/images/profile/demo/yellingcat@192.webp"
}
onSettingsClick={onCommunitySettingsClick} onSettingsClick={onCommunitySettingsClick}
/> />
<ul class="h-full list flex flex-col p-2 gap-1 pt-18 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-500 scrollbar-track-gray-800"> <ul
{channelIds().map(mapChannel)} class="h-full list flex flex-col p-2 gap-1 pt-18 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-500 scrollbar-track-gray-800"
onContextMenu={onRightClick}
>
{categories().map(mapCategory)}
{mapCategory(null)}
</ul> </ul>
</div> </div>
); );

View file

@ -1,12 +1,22 @@
import { createMemo, onMount, type Component } from "solid-js"; import {
createMemo,
createSignal,
JSXElement,
onMount,
type Component,
} from "solid-js";
import { ChannelBar } from "../../components/ChannelBar"; import { ChannelBar } from "../../components/ChannelBar";
import { MessageBar } from "../../components/MessageBar"; import { MessageBar } from "../../components/MessageBar";
import { Message } from "../../components/Message"; import { Message } from "../../components/Message";
import { state } from "../../store/state"; import { state } from "../../store/state";
import { IChannel, setText } from "../../store/channel"; import { getActiveChannel, IChannel, setText } from "../../store/channel";
import { fetchChannelMessages } from "../../services/channel"; import { fetchChannelMessages } from "../../services/channel";
import { fetchUser, getUserAvatarUrl } from "../../services/user"; import { fetchUser, getUserAvatarUrl } from "../../services/user";
import { createMessage } from "../../services/message"; import {
createMessage,
removeMessage,
updateMessage,
} from "../../services/message";
import { IMessage } from "../../store/message"; import { IMessage } from "../../store/message";
import { import {
createAttachment, createAttachment,
@ -14,8 +24,68 @@ import {
uploadChunks, uploadChunks,
} from "../../services/file"; } from "../../services/file";
import { getActiveCommunity } from "../../store/community"; import { getActiveCommunity } from "../../store/community";
import {
ContextMenu,
IContextMenuItem,
useContextMenu,
} from "../../components/ContextMenu";
import {
EditIcon,
PinIcon,
ReactIcon,
RemoveIcon,
TrashIcon,
} from "../../icons";
import { getUserBestRoleId } from "../../services/role";
import { IRole } from "../../store/role";
const ChatView: Component = () => { const ChatView: Component = () => {
const menu = useContextMenu<string>("chat-message-menu");
const [getEditingMessages, setEditingMessages] = createSignal<Set<string>>(
new Set(),
);
const editingMenuItems = createMemo<IContextMenuItem[]>(() => {
if (getEditingMessages().has(menu.getData() ?? "")) {
return [
{
label: "Cancel editing",
icon: RemoveIcon,
onClick: () => onEditMessageChange(menu.getData()),
},
];
}
return [
{
label: "Edit message",
icon: EditIcon,
onClick: () => onEditMessageChange(menu.getData()),
},
];
});
const menuItems = createMemo<IContextMenuItem[]>(() => [
...editingMenuItems(),
{
label: "React",
icon: ReactIcon,
onClick: () => onReactMessage(menu.getData()),
},
{
label: "Pin message",
icon: PinIcon,
onClick: () => onPinMessage(menu.getData()),
},
{
label: "Remove message",
icon: TrashIcon,
color: "var(--color-error)",
onClick: () => onRemoveMessage(menu.getData()),
},
]);
let scrollRef: HTMLUListElement | undefined; let scrollRef: HTMLUListElement | undefined;
let lastScrollTop = 0; let lastScrollTop = 0;
let autoScroll = true; let autoScroll = true;
@ -105,7 +175,7 @@ const ChatView: Component = () => {
setText(channel.id, text); setText(channel.id, text);
}; };
const onMessageSend = async (files: File[]) => { const onSendMessage = async (files: File[]) => {
autoScroll = true; autoScroll = true;
const channel = channelInfo(); const channel = channelInfo();
@ -153,6 +223,65 @@ const ChatView: Component = () => {
); );
}; };
const onRemoveMessage = (messageId: string | undefined) => {
const channel = getActiveChannel();
if (!channel || !messageId) {
return;
}
removeMessage(messageId, channel.id);
};
const onEditMessageChange = (messageId: string | undefined) => {
if (!messageId) {
return;
}
const editingMessages = getEditingMessages();
if (editingMessages.has(messageId)) {
editingMessages.delete(messageId);
} else {
editingMessages.add(messageId);
}
setEditingMessages(new Set([...editingMessages]));
};
const onEditMessageFinish = (messageId: string, text: string) => {
const community = getActiveCommunity();
if (!community || !messageId) {
return;
}
const updatedText = text.trim();
if (updatedText.length < 1) {
return;
}
updateMessage(messageId, updatedText, community.id);
onEditMessageChange(messageId);
};
const onReactMessage = (messageId: string | undefined) => {
const channel = getActiveChannel();
if (!channel || !messageId) {
return;
}
};
const onPinMessage = (messageId: string | undefined) => {
const channel = getActiveChannel();
if (!channel || !messageId) {
return;
}
};
const onMessageRightClick = (id: string, e: MouseEvent) => {
menu.open(e, id);
};
const isPlainMessage = ( const isPlainMessage = (
message: IMessage, message: IMessage,
previousMessage: IMessage | undefined, previousMessage: IMessage | undefined,
@ -168,8 +297,50 @@ const ChatView: Component = () => {
return message.creationDate < previousMessage.creationDate + 600000; return message.creationDate < previousMessage.creationDate + 600000;
}; };
const mapMessage = (
message: IMessage,
index: number,
allMessages: IMessage[],
): JSXElement => {
const user = state.user.users[message.ownerId];
const community = getActiveCommunity();
if (!user || !community) {
return;
}
const bestRoleId = getUserBestRoleId(user.id, community.id);
let bestRole: IRole | undefined;
if (bestRoleId) {
bestRole = state.role.roles[bestRoleId];
}
return (
<Message
messageId={message.id ?? ""}
message={message.text}
userId={message.ownerId}
username={user.nickname ?? user.username ?? ""}
color={bestRole?.color}
avatar={getUserAvatarUrl(user.avatar)}
attachments={message.attachments}
time={message.creationDate}
plain={isPlainMessage(message, allMessages[index - 1])}
decryptionStatus={message.decryptionStatus ?? false}
editing={getEditingMessages().has(message.id)}
onProfileClick={onProfileClick}
onMessageRightClick={onMessageRightClick}
onMessageEdit={onEditMessageFinish}
/>
);
};
return ( return (
<div class="bg-stone-800 flex-1 z-0 relative"> <div class="bg-stone-800 flex-1 z-0 relative">
<ContextMenu
visible={menu.getVisible()}
position={menu.getPos()}
items={menuItems()}
/>
<div class="h-full"> <div class="h-full">
<ChannelBar <ChannelBar
id={channelInfo()?.id} id={channelInfo()?.id}
@ -181,32 +352,13 @@ const ChatView: Component = () => {
onScrollEnd={handleScroll} onScrollEnd={handleScroll}
class="h-full list flex flex-col p-2 pt-18 pb-24 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-500 scrollbar-track-gray-800" class="h-full list flex flex-col p-2 pt-18 pb-24 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-500 scrollbar-track-gray-800"
> >
{messages().map((message, messageIndex, allMessages) => ( {messages().map(mapMessage)}
<Message
messageId={message.id ?? ""}
message={message.text}
userId={message.ownerId}
username={
state.user.users[message.ownerId].username ?? ""
}
avatar={getUserAvatarUrl(
state.user.users[message.ownerId].avatar,
)}
attachments={message.attachments}
plain={isPlainMessage(
message,
allMessages[messageIndex - 1],
)}
decryptionStatus={message.decryptionStatus ?? false}
onProfileClick={onProfileClick}
/>
))}
</ul> </ul>
{channelInfo() ? ( {channelInfo() ? (
<MessageBar <MessageBar
text={channelInfo()?.text ?? ""} text={channelInfo()?.text ?? ""}
onChangeText={onChangeMessageText} onChangeText={onChangeMessageText}
onSend={onMessageSend} onSend={onSendMessage}
/> />
) : undefined} ) : undefined}
</div> </div>

View file

@ -1,6 +1,15 @@
import { createSignal, type Component, type JSXElement } from "solid-js"; import {
createEffect,
createSignal,
type Component,
type JSXElement,
} from "solid-js";
import { ICommunitySettingsModalViewProps } from "./types"; import { ICommunitySettingsModalViewProps } from "./types";
import CommunitySettingsProfilePage from "./pages/SettingsProfilePage/CommunitySettingsProfilePage"; import CommunitySettingsProfilePage from "./pages/CommunitySettingsProfilePage/CommunitySettingsProfilePage";
import CommunitySettingsChannelsPage from "./pages/CommunitySettingsChannelsPage/CommunitySettingsChannelsPage";
import CommunitySettingsRolesPage from "./pages/CommunitySettingsRolesPage/CommunitySettingsRolesPage";
import CommunitySettingsMembersPage from "./pages/CommunitySettingsMembersPage/CommunitySettingsMembersPage";
import CommunitySettingsInvitesPage from "./pages/CommunitySettingsInvitesPage/CommunitySettingsInvitesPage";
import { Dynamic } from "solid-js/web"; import { Dynamic } from "solid-js/web";
import { SettingsItem } from "../../components/SettingsItem"; import { SettingsItem } from "../../components/SettingsItem";
import { getActiveCommunity } from "../../store/community"; import { getActiveCommunity } from "../../store/community";
@ -8,12 +17,27 @@ import { getActiveCommunity } from "../../store/community";
const CommunitySettingsModalView: Component< const CommunitySettingsModalView: Component<
ICommunitySettingsModalViewProps ICommunitySettingsModalViewProps
> = (props: ICommunitySettingsModalViewProps) => { > = (props: ICommunitySettingsModalViewProps) => {
const [getSelectedCommunityId, setSelectedCommunityId] = createSignal<
string | undefined
>(undefined);
const [getSelectedPage, setSelectedPage] = createSignal<string | undefined>( const [getSelectedPage, setSelectedPage] = createSignal<string | undefined>(
undefined, undefined,
); );
createEffect(() => {
const comunity = getActiveCommunity();
if (getSelectedCommunityId() !== comunity?.id) {
setSelectedCommunityId(comunity?.id);
setSelectedPage();
}
});
const pages = new Map<string, Component>([ const pages = new Map<string, Component>([
["Community Profile", CommunitySettingsProfilePage], ["Community Profile", CommunitySettingsProfilePage],
["Channels", CommunitySettingsChannelsPage],
["Roles", CommunitySettingsRolesPage],
["Members", CommunitySettingsMembersPage],
["Invites", CommunitySettingsInvitesPage],
]); ]);
const getCurrentPage = (): JSXElement => { const getCurrentPage = (): JSXElement => {

View file

@ -0,0 +1,270 @@
import {
Component,
createMemo,
createSignal,
JSXElement,
onMount,
} from "solid-js";
import { Input } from "../../../../components/Input";
import { state } from "../../../../store/state";
import { IChannel } from "../../../../store/channel";
import { SettingsItem } from "../../../../components/SettingsItem";
import { getActiveCommunity } from "../../../../store/community";
import { fetchCommunityChannels } from "../../../../services/community";
import { removeChannel, updateChannel } from "../../../../services/channel";
import { CheckIcon, HashIcon, PlusIcon, TrashIcon } from "../../../../icons";
import { setCreateChannelOpen } from "../../../../store/app";
import { ISelectOption, Select } from "../../../../components/Select";
const UNCATEGORIZED_VALUE = "Uncategorized";
const CommunitySettingsChannelsPage: Component = () => {
const [getSelectedChannelId, setSelectedChannelId] = createSignal<
string | undefined
>(undefined);
const [getManualCategory, setManualCategory] = createSignal<boolean>(false);
const [getSettingsChannelName, setSettingsChannelName] =
createSignal<string>("");
const [getSettingsChannelDescription, setSettingsChannelDescription] =
createSignal<string>("");
const [getSettingsChannelCategory, setSettingsChannelCategory] =
createSignal<string>("");
onMount(() => {
if (state.community.active) {
fetchCommunityChannels(state.community.active);
}
});
const getSelectedChannel = (): IChannel | undefined => {
const selectedChannelId = getSelectedChannelId();
if (!selectedChannelId) {
return;
}
return state.channel.channels[selectedChannelId];
};
const onSelectChannel = (id: string) => {
const channel = state.channel.channels[id];
if (!channel) {
return undefined;
}
setSelectedChannelId(id);
setManualCategory(false);
setSettingsChannelName(channel.name ?? "");
setSettingsChannelDescription(channel.description ?? "");
setSettingsChannelCategory(channel.category ?? "");
};
const channelIds = createMemo(() => {
const community = getActiveCommunity();
if (!community) {
return [];
}
return community.channels ?? [];
});
const channels = createMemo(() => {
return channelIds()
.map((channelId) => {
const channel = state.channel.channels[channelId];
if (!channel) {
return {
id: channelId,
};
}
return channel;
})
.filter((channel) => channel.id !== "")
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
});
const categoryOptions = createMemo<ISelectOption[]>(() => {
const options = channelIds()
.map((channelId) => {
const category =
state.channel.channels[channelId]?.category ?? "";
return { value: category, label: category };
})
.filter((option) => option.value !== "");
const uncategorized = {
value: UNCATEGORIZED_VALUE,
label: UNCATEGORIZED_VALUE,
};
const finalOptions = [uncategorized, ...options];
return [
...new Map(
finalOptions.map((option) => [option.value, option]),
).values(),
];
});
const onAddChannel = () => {
setCreateChannelOpen(true);
};
const onSaveChannel = () => {
const channel = getSelectedChannel();
if (!channel) {
return;
}
const name = getSettingsChannelName().trim();
const description = getSettingsChannelDescription().trim();
const category = getSettingsChannelCategory().trim();
updateChannel({
id: channel.id,
name: name.length > 0 ? name : channel.name,
description: description,
category: category === UNCATEGORIZED_VALUE ? null : category,
});
};
const onDeleteChannel = () => {
const channel = getSelectedChannel();
if (!channel) {
return;
}
removeChannel(channel.id);
setSelectedChannelId();
};
const mapChannel = (channel: IChannel): JSXElement => {
return (
<SettingsItem
id={channel.id}
text={`# ${channel.name ?? ""}`}
active={channel.id === getSelectedChannelId()}
onClick={onSelectChannel}
/>
);
};
const categoryHtml = (): JSXElement => {
if (getManualCategory()) {
return (
<Input
type="text"
placeholder="Enter new channel category"
value={getSettingsChannelCategory()}
submitText="Pick existing"
onChange={setSettingsChannelCategory}
onSubmit={() => setManualCategory(false)}
/>
);
} else {
return (
<Select
value={getSettingsChannelCategory()}
placeholder="Pick a category"
options={categoryOptions()}
submitText="Create new"
onChange={setSettingsChannelCategory}
onSubmit={() => setManualCategory(true)}
/>
);
}
};
const optionsHtml = (): JSXElement => {
const channel = getSelectedChannel();
if (!channel) {
return undefined;
}
return (
<div class="flex-1 flex flex-col gap-0 w-full">
<h3 class="text-lg font-bold text-center mb-6">
# {channel.name} Settings
</h3>
<h4 class="text-sm font-semibold mb-1 mx-7 text-left">
Channel name
</h4>
<Input
type="text"
placeholder="Enter channel name"
icon={HashIcon}
outline={(channel.name ?? "") !== getSettingsChannelName()}
value={getSettingsChannelName()}
onChange={setSettingsChannelName}
/>
<div class="py-3"></div>
<h4 class="text-sm font-semibold mb-1 mx-7 text-left">
Channel description
</h4>
<Input
type="text"
placeholder="Enter channel description"
outline={
(channel.description ?? "") !==
getSettingsChannelDescription()
}
value={getSettingsChannelDescription()}
onChange={setSettingsChannelDescription}
/>
<div class="py-3"></div>
<h4 class="text-sm font-semibold mb-1 mx-7 text-left">
Channel Category
</h4>
{categoryHtml()}
<div class="divider"></div>
<button
class="bg-stone-950 btn rounded-xl h-14"
onClick={onSaveChannel}
>
<div class="w-5">
<CheckIcon />
</div>
Save Channel
</button>
<div class="divider"></div>
<button
class="bg-stone-950 text-error btn btn-error rounded-xl h-14"
onClick={onDeleteChannel}
>
<div class="w-5">
<TrashIcon />
</div>
Delete Channel
</button>
</div>
);
};
return (
<div class="flex-1 flex flex-row gap-4">
<div class="flex flex-col gap-2">
<h3 class="text-lg font-bold text-center mb-4">Channels</h3>
{channels().map(mapChannel)}
{channels().length > 0 ? (
<div class="divider"></div>
) : undefined}
<button
class="bg-stone-950 btn rounded-xl h-13"
onClick={onAddChannel}
>
<div class="w-6">
<PlusIcon />
</div>
Add new Channel
</button>
</div>
<div class="divider divider-horizontal"></div>
<div class="flex-1">{optionsHtml()}</div>
</div>
);
};
export default CommunitySettingsChannelsPage;

View file

@ -0,0 +1 @@
export * from "./CommunitySettingsChannelsPage";

View file

@ -0,0 +1,144 @@
import {
createMemo,
createSignal,
JSXElement,
onMount,
type Component,
} from "solid-js";
import { state } from "../../../../store/state";
import { RichSettingsItem } from "../../../../components/RichSettingsItem";
import { InviteIcon, PlusIcon, TrashIcon } from "../../../../icons";
import { fetchCommunityInvites } from "../../../../services/community";
import { getActiveCommunity } from "../../../../store/community";
import { removeInvite } from "../../../../services/invite";
import { setCreateInviteOpen } from "../../../../store/app";
import { IInvite } from "../../../../store/invite";
const CommunitySettingsInvitesPage: Component = () => {
const [getSelectedInviteId, setSelectedInviteId] = createSignal<
string | undefined
>(undefined);
onMount(() => {
if (state.community.active) {
fetchCommunityInvites(state.community.active);
}
});
const inviteIds = createMemo(() => {
const community = getActiveCommunity();
if (!community) {
return [];
}
return community.invites ?? [];
});
const invites = createMemo(() => {
return inviteIds()
.map((inviteId) => {
const invite = state.invite.invites[inviteId];
if (!invite) {
return {
id: "",
};
}
return invite;
})
.filter((invite) => invite.id !== "");
});
const onRevokeInvite = (inviteId: string) => {
removeInvite(inviteId);
if (state.community.active) {
fetchCommunityInvites(state.community.active);
}
};
const onCreateInvite = () => {
setCreateInviteOpen(true);
};
const mapInvite = (invite: IInvite): JSXElement => {
return (
<RichSettingsItem
id={invite.id}
title={invite.id}
info={invite.valid ? undefined : "Invalid"}
icon={InviteIcon}
iconPadding={2}
active={invite.id === getSelectedInviteId()}
onClick={setSelectedInviteId}
>
<div class="flex flex-col gap-2 font-bold">
<p>
Invite ID: <span class="font-normal">{invite.id}</span>
</p>
{invite.hasExpiration ? (
<p>
Expires:{" "}
<span class="font-normal">
{invite.expirationDate
? new Date(
invite.expirationDate,
).toLocaleString()
: "unknown"}
</span>
</p>
) : undefined}
<p>
Remaining Invites:{" "}
<span class="font-normal">
{invite.unlimitedInvites ? (
"unlimited"
) : (
<>
{invite.remainingInvites}/
{invite.totalInvites}
</>
)}
</span>
</p>
<p>
Created:{" "}
<span class="font-normal">
{invite.creationDate
? new Date(invite.creationDate).toLocaleString()
: "unknown"}
</span>
</p>
<button
class="btn btn-error rounded-lg self-end mt-1 pr-2"
onClick={() => onRevokeInvite(invite.id)}
>
Revoke Invite
<div class="w-6">
<TrashIcon />
</div>
</button>
</div>
</RichSettingsItem>
);
};
return (
<div class="flex-1 flex flex-col gap-2 w-full">
<h3 class="text-lg font-bold text-center mb-4">Invites</h3>
{invites().map(mapInvite)}
{invites().length > 0 ? <div class="divider"></div> : undefined}
<button
class="bg-stone-950 btn rounded-xl h-14"
onClick={onCreateInvite}
>
<div class="w-5">
<PlusIcon />
</div>
Create Invite
</button>
</div>
);
};
export default CommunitySettingsInvitesPage;

View file

@ -0,0 +1 @@
export * from "./CommunitySettingsInvitesPage";

View file

@ -0,0 +1,235 @@
import {
Component,
createMemo,
createSignal,
JSXElement,
onMount,
} from "solid-js";
import { state } from "../../../../store/state";
import { SettingsItem } from "../../../../components/SettingsItem";
import { IUser } from "../../../../store/user";
import { getActiveCommunity } from "../../../../store/community";
import {
fetchCommunityMembers,
fetchCommunityRoles,
removeCommunityMember,
} from "../../../../services/community";
import { TrashIcon } from "../../../../icons";
import { fetchUserCommunityRoles } from "../../../../services/user";
import { ISelectOption, Select } from "../../../../components/Select";
import { IRole } from "../../../../store/role";
import { assignRole, unassignRole } from "../../../../services/role";
import { BadgeItem } from "../../../../components/BadgeItem";
const CommunitySettingsMembersPage: Component = () => {
const [getSelectedMemberId, setSelectedMemberId] = createSignal<
string | undefined
>(undefined);
const [getSelectedRoleId, setSelectedRoleId] = createSignal<
string | undefined
>(undefined);
onMount(() => {
if (state.community.active) {
fetchCommunityMembers(state.community.active);
fetchCommunityRoles(state.community.active);
}
});
const getSelectedMember = (): IUser | undefined => {
const selectedMemberId = getSelectedMemberId();
if (!selectedMemberId) {
return;
}
return state.user.users[selectedMemberId];
};
const getSelectedRole = (): IRole | undefined => {
const selectedRoleId = getSelectedRoleId();
if (!selectedRoleId) {
return;
}
return state.role.roles[selectedRoleId];
};
const onSelectMember = (id: string) => {
const community = getActiveCommunity();
if (!community) {
return;
}
const member = state.user.users[id];
if (!member) {
return;
}
fetchUserCommunityRoles(member.id, community.id);
setSelectedMemberId(id);
};
const memberIds = createMemo(() => {
const community = getActiveCommunity();
if (!community) {
return [];
}
return community.members ?? [];
});
const members = createMemo(() => {
return memberIds()
.map((memberId) => {
const member = state.user.users[memberId];
if (!member) {
return {
id: memberId,
};
}
return member;
})
.filter((member) => member.id !== "");
});
const onRemoveMember = () => {
const community = getActiveCommunity();
if (!community) {
return;
}
const member = getSelectedMember();
if (!member) {
return;
}
removeCommunityMember(community.id, member.id);
setSelectedMemberId();
};
const onUnassignRole = (id: string) => {
const role = state.role.roles[id];
if (!role) {
return undefined;
}
const member = getSelectedMember();
if (!member) {
return undefined;
}
unassignRole(role.id, member.id);
};
const onAssignRole = () => {
const role = getSelectedRole();
if (!role) {
return undefined;
}
const member = getSelectedMember();
if (!member) {
return undefined;
}
assignRole(role.id, member.id);
};
const mapMember = (member: IUser): JSXElement => {
return (
<SettingsItem
id={member.id}
text={member.nickname ?? ""}
active={member.id === getSelectedMemberId()}
onClick={onSelectMember}
/>
);
};
const mapRole = (roleId: string): JSXElement => {
const role = state.role.roles[roleId];
if (!role) {
return;
}
const member = getSelectedMember();
if (!member) {
return;
}
return (
<BadgeItem
id={role.id}
text={role.name ?? ""}
removable={true}
onRemove={onUnassignRole}
/>
);
};
const optionsHtml = (): JSXElement => {
const community = getActiveCommunity();
if (!community) {
return;
}
const member = getSelectedMember();
if (!member) {
return;
}
const roleIds = (member.roles ?? {})[community.id] ?? [];
const roleOptions: ISelectOption[] =
community.roles?.map((role) => ({
value: role,
label: state.role.roles[role]?.name ?? role,
})) ?? [];
return (
<div class="flex-1 flex flex-col gap-0 w-full">
<h3 class="text-lg font-bold text-center mb-6">
{member.nickname ?? member.username} Settings
</h3>
<div class="flex flex-row flex-wrap gap-2">
{roleIds.map(mapRole)}
</div>
<div class="my-1"></div>
<Select
value={getSelectedRoleId() ?? ""}
placeholder="Pick a role to assign"
options={roleOptions}
submitText="Assign"
onChange={setSelectedRoleId}
onSubmit={onAssignRole}
/>
<div class="divider"></div>
<button
class="bg-stone-950 text-error btn btn-error rounded-xl h-14"
onClick={onRemoveMember}
>
<div class="w-5">
<TrashIcon />
</div>
Kick Member
</button>
</div>
);
};
return (
<div class="flex-1 flex flex-row gap-4">
<div class="flex flex-col gap-2">
<h3 class="text-lg font-bold text-center mb-4">Members</h3>
{members().map(mapMember)}
</div>
<div class="divider divider-horizontal"></div>
<div class="flex-1">{optionsHtml()}</div>
</div>
);
};
export default CommunitySettingsMembersPage;

View file

@ -0,0 +1 @@
export * from "./CommunitySettingsMembersPage";

View file

@ -16,6 +16,7 @@ import {
updateCommunity, updateCommunity,
} from "../../../../services/community"; } from "../../../../services/community";
import { setCommunitySettingsOpen } from "../../../../store/app"; import { setCommunitySettingsOpen } from "../../../../store/app";
import { CheckIcon, TrashIcon } from "../../../../icons";
const CommunitySettingsProfilePage: Component = () => { const CommunitySettingsProfilePage: Component = () => {
const [getSettingsName, setSettingsName] = createSignal<string>(""); const [getSettingsName, setSettingsName] = createSignal<string>("");
@ -95,7 +96,7 @@ const CommunitySettingsProfilePage: Component = () => {
return ( return (
<div class="flex-1 flex flex-col gap-0 w-full"> <div class="flex-1 flex flex-col gap-0 w-full">
<h3 class="text-lg font-bold text-center mb-5"> <h3 class="text-lg font-bold text-center mb-6">
{getActiveCommunity()?.name} Profile {getActiveCommunity()?.name} Profile
</h3> </h3>
<h4 class="text-sm font-semibold mb-2 text-center"> <h4 class="text-sm font-semibold mb-2 text-center">
@ -121,7 +122,7 @@ const CommunitySettingsProfilePage: Component = () => {
value={getSettingsName()} value={getSettingsName()}
onChange={setSettingsName} onChange={setSettingsName}
/> />
<div class="py-3"></div> <div class="divider py-3"></div>
<h4 class="text-sm font-semibold mb-1 mx-7 text-left"> <h4 class="text-sm font-semibold mb-1 mx-7 text-left">
Description Description
</h4> </h4>
@ -141,6 +142,9 @@ const CommunitySettingsProfilePage: Component = () => {
class="bg-stone-950 btn rounded-xl h-14" class="bg-stone-950 btn rounded-xl h-14"
onClick={onSaveProfile} onClick={onSaveProfile}
> >
<div class="w-5">
<CheckIcon />
</div>
Save Profile Save Profile
</button> </button>
<div class="divider"></div> <div class="divider"></div>
@ -148,6 +152,9 @@ const CommunitySettingsProfilePage: Component = () => {
class="bg-stone-950 text-error btn btn-error rounded-xl h-14" class="bg-stone-950 text-error btn btn-error rounded-xl h-14"
onClick={onDeleteCommunity} onClick={onDeleteCommunity}
> >
<div class="w-5">
<TrashIcon />
</div>
Delete Community Delete Community
</button> </button>
</div> </div>

View file

@ -0,0 +1,309 @@
import {
Component,
createEffect,
createMemo,
createSignal,
JSXElement,
onMount,
} from "solid-js";
import { state } from "../../../../store/state";
import { SettingsItem } from "../../../../components/SettingsItem";
import { IRole } from "../../../../store/role";
import { getActiveCommunity } from "../../../../store/community";
import { fetchCommunityRoles } from "../../../../services/community";
import { CheckIcon, PlusIcon, TrashIcon } from "../../../../icons";
import { setCreateRoleOpen } from "../../../../store/app";
import {
fetchPermissions,
fetchRole,
removeRole,
updateRole,
} from "../../../../services/role";
import { IPermission } from "./types";
import { Input } from "../../../../components/Input";
const CommunitySettingsRolesPage: Component = () => {
const [getSelectedRoleId, setSelectedRoleId] = createSignal<
string | undefined
>(undefined);
const [getSettingsRoleName, setSettingsRoleName] = createSignal<
string | undefined
>();
const [getSettingsRoleDescription, setSettingsRoleDescription] =
createSignal<string | undefined>();
const [getSettingsRoleColor, setSettingsRoleColor] = createSignal<
string | undefined
>();
const [getSettingsRoleShowInMembers, setSettingsRoleShowInMembers] =
createSignal<boolean | undefined>();
const [getSettingsPermissions, setSettingsPermissions] = createSignal<
IPermission[]
>([]);
onMount(() => {
fetchPermissions();
if (state.community.active) {
fetchCommunityRoles(state.community.active);
}
});
createEffect(() => {
const role = getSelectedRole();
if (!role) {
return;
}
setSettingsRoleName(role.name);
setSettingsRoleDescription(role.description);
setSettingsRoleColor(role.color);
setSettingsRoleShowInMembers(role.showInMembers);
setSettingsPermissions(
state.role.permissions.map((permission) => ({
name: permission,
value: role.permissions?.includes(permission) ?? false,
})),
);
});
const getSelectedRole = (): IRole | undefined => {
const selectedRoleId = getSelectedRoleId();
if (!selectedRoleId) {
return;
}
return state.role.roles[selectedRoleId];
};
const onSelectRole = (id: string) => {
const role = state.role.roles[id];
if (!role) {
return undefined;
}
setSelectedRoleId(id);
fetchRole(role.id);
};
const roleIds = createMemo(() => {
const community = getActiveCommunity();
if (!community) {
return [];
}
return community.roles ?? [];
});
const roles = createMemo(() => {
return roleIds()
.map((roleId) => {
const role = state.role.roles[roleId];
if (!role) {
return {
id: "",
};
}
return role;
})
.filter((role) => role.id !== "")
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
});
const onAddRole = () => {
setCreateRoleOpen(true);
};
const onSaveRole = () => {
const role = getSelectedRole();
if (!role) {
return;
}
updateRole({
id: role.id,
name: getSettingsRoleName(),
description: getSettingsRoleDescription(),
color: getSettingsRoleColor(),
showInMembers: getSettingsRoleShowInMembers(),
permissions: getSettingsPermissions()
.filter((permission) => permission.value)
.map((permission) => permission.name),
});
};
const onDeleteRole = () => {
const role = getSelectedRole();
if (!role) {
return;
}
removeRole(role.id);
setSelectedRoleId();
};
const onCheckPermission = (index: number) => {
const permissions = getSettingsPermissions();
permissions[index].value = !permissions[index].value;
setSettingsPermissions([...permissions]);
};
const onCheckShowInMembers = () => {
const show = getSettingsRoleShowInMembers();
setSettingsRoleShowInMembers(!show);
};
const mapRole = (role: IRole): JSXElement => {
return (
<SettingsItem
id={role.id}
text={role.name ?? ""}
active={role.id === getSelectedRoleId()}
onClick={onSelectRole}
/>
);
};
const mapCheckItem = (
value: boolean,
label: string,
index: number,
count: number,
onCheck: (index: number) => void,
): JSXElement => {
const top = index === 0;
const bottom = index == count - 1;
return (
<li
class={`flex flex-row h-16 px-3 border-2 transition-all bg-stone-900 hover:bg-stone-800 border-stone-800 hover:border-stone-700 rounded-sm ${top ? "rounded-t-xl" : ""} ${bottom ? "rounded-b-xl" : ""}`}
>
<label class="label gap-3">
<input
type="checkbox"
class="checkbox w-8 h-8 p-2 bg-stone-800 border-2 rounded-full shadow-none"
checked={value}
onChange={() => onCheck(index)}
/>
<span>{label}</span>
</label>
</li>
);
};
const optionsHtml = (): JSXElement => {
const role = getSelectedRole();
if (!role) {
return undefined;
}
return (
<div class="flex-1 flex flex-col gap-0 w-full">
<h3 class="text-lg font-bold text-center mb-6">
{role.name} Settings
</h3>
<div class="divider py-3">Name</div>
<Input
type="text"
placeholder="Enter name"
outline={getSelectedRole()?.name !== getSettingsRoleName()}
value={getSettingsRoleName() ?? ""}
onChange={setSettingsRoleName}
/>
<div class="divider py-3">Description</div>
<Input
type="text"
placeholder="Enter description"
outline={
getSelectedRole()?.description !==
getSettingsRoleDescription()
}
value={getSettingsRoleDescription() ?? ""}
onChange={setSettingsRoleDescription}
/>
<div class="divider py-3">Color</div>
<Input
type="text"
placeholder="Enter color"
outline={
getSelectedRole()?.color !== getSettingsRoleColor()
}
value={getSettingsRoleColor() ?? ""}
onChange={setSettingsRoleColor}
/>
<div class="divider py-3">Permissions</div>
<ul class="flex flex-col gap-0.5 w-full">
{getSettingsPermissions().map(
(permission, index, allPermissions) =>
mapCheckItem(
permission.value,
permission.name,
index,
allPermissions.length,
onCheckPermission,
),
)}
</ul>
<div class="divider py-3">Other settings</div>
<ul class="flex flex-col gap-0.5 w-full">
{mapCheckItem(
getSettingsRoleShowInMembers() ?? false,
"Show in member list",
0,
1,
onCheckShowInMembers,
)}
</ul>
<div class="divider"></div>
<button
class="bg-stone-950 btn rounded-xl h-14"
onClick={onSaveRole}
>
<div class="w-5">
<CheckIcon />
</div>
Save Role
</button>
<div class="divider"></div>
<button
class="bg-stone-950 text-error btn btn-error rounded-xl h-14"
onClick={onDeleteRole}
>
<div class="w-5">
<TrashIcon />
</div>
Delete Role
</button>
</div>
);
};
return (
<div class="flex-1 flex flex-row gap-4">
<div class="flex flex-col gap-2">
<h3 class="text-lg font-bold text-center mb-4">Roles</h3>
{roles().map(mapRole)}
{roles().length > 0 ? <div class="divider"></div> : undefined}
<button
class="bg-stone-950 btn rounded-xl h-13"
onClick={onAddRole}
>
<div class="w-6">
<PlusIcon />
</div>
Add new Role
</button>
</div>
<div class="divider divider-horizontal"></div>
<div class="flex-1">{optionsHtml()}</div>
</div>
);
};
export default CommunitySettingsRolesPage;

View file

@ -0,0 +1,2 @@
export * from "./CommunitySettingsRolesPage";
export * from "./types";

View file

@ -0,0 +1,6 @@
interface IPermission {
name: string;
value: boolean;
}
export { type IPermission };

View file

@ -2,7 +2,7 @@ import { createMemo, type Component } from "solid-js";
import { Community } from "../../components/Community"; import { Community } from "../../components/Community";
import { state } from "../../store/state"; import { state } from "../../store/state";
import { SidebarItem } from "../../components/SidebarItem"; import { SidebarItem } from "../../components/SidebarItem";
import { HomeIcon, PlusIcon, SettingsIcon } from "../../icons"; import { HomeIcon, LeaveIcon, PlusIcon, SettingsIcon } from "../../icons";
import { import {
setAddCommunityOpen, setAddCommunityOpen,
setHomeOpen, setHomeOpen,
@ -15,10 +15,28 @@ import {
import { import {
fetchCommunity, fetchCommunity,
getCommunityAvatarUrl, getCommunityAvatarUrl,
removeCommunityMember,
} from "../../services/community"; } from "../../services/community";
import { resetActiveChannel } from "../../store/channel"; import { resetActiveChannel } from "../../store/channel";
import {
ContextMenu,
IContextMenuItem,
useContextMenu,
} from "../../components/ContextMenu";
import { getLoggedInUser } from "../../store/user";
const CommunityView: Component = () => { const CommunityView: Component = () => {
const menu = useContextMenu<string>("community-community-menu");
const menuItems: IContextMenuItem[] = [
{
label: "Leave community",
icon: LeaveIcon,
color: "var(--color-error)",
onClick: () => onLeaveCommunity(menu.getData()),
},
];
const communityIds = createMemo(() => { const communityIds = createMemo(() => {
const loggedUserId = state.user.loggedUserId; const loggedUserId = state.user.loggedUserId;
if (!loggedUserId) { if (!loggedUserId) {
@ -48,6 +66,10 @@ const CommunityView: Component = () => {
resetActiveChannel(); resetActiveChannel();
}; };
const onCommunityRightClick = (id: string, e: MouseEvent) => {
menu.open(e, id);
};
const onNewClick = () => { const onNewClick = () => {
setAddCommunityOpen(true); setAddCommunityOpen(true);
}; };
@ -56,6 +78,17 @@ const CommunityView: Component = () => {
setSettingsOpen(true); setSettingsOpen(true);
}; };
const onLeaveCommunity = (communityId: string | undefined) => {
if (!communityId) {
return;
}
const user = getLoggedInUser();
if (user) {
removeCommunityMember(communityId, user.id);
}
};
const mapCommunity = (communityId: string) => { const mapCommunity = (communityId: string) => {
const community = state.community.communities[communityId]; const community = state.community.communities[communityId];
if (!community) { if (!community) {
@ -69,12 +102,18 @@ const CommunityView: Component = () => {
avatar={getCommunityAvatarUrl(community.avatar)} avatar={getCommunityAvatarUrl(community.avatar)}
active={community.id === state.community.active} active={community.id === state.community.active}
onCommunityClick={onCommunityClick} onCommunityClick={onCommunityClick}
onCommunityRightClick={onCommunityRightClick}
/> />
); );
}; };
return ( return (
<div class="flex flex-col bg-stone-950 w-16 h-full shadow-panel z-30 p-2 gap-2"> <div class="flex flex-col bg-stone-950 w-16 h-full shadow-panel z-30 p-2 gap-2">
<ContextMenu
visible={menu.getVisible()}
position={menu.getPos()}
items={menuItems}
/>
<SidebarItem <SidebarItem
icon={HomeIcon} icon={HomeIcon}
active={state.app.homeOpen} active={state.app.homeOpen}

View file

@ -0,0 +1,58 @@
import { createSignal, type Component } from "solid-js";
import { ICreateChannelModalViewProps } from "./types";
import { Input } from "../../components/Input";
import { createChannel } from "../../services/channel";
import { getActiveCommunity } from "../../store/community";
const CreateChannelModalView: Component<ICreateChannelModalViewProps> = (
props: ICreateChannelModalViewProps,
) => {
const [getChannelName, setChannelName] = createSignal("");
const onCreateChannel = () => {
const channelName = getChannelName();
if (!channelName || channelName.trim().length < 1) {
return;
}
const community = getActiveCommunity();
if (!community?.id) {
return;
}
createChannel(channelName, community.id);
setChannelName("");
props.onClose?.();
};
return (
<div>
<dialog
ref={props.dialogRef}
class="modal outline-none bg-[#00000050]"
>
<div class="modal-box bg-stone-950 rounded-3xl">
<h3 class="text-lg font-bold text-center mb-6">
Create a new Channel
</h3>
<Input
placeholder="Enter name for the new channel"
value={getChannelName()}
onChange={setChannelName}
submitText="Create"
onSubmit={onCreateChannel}
/>
</div>
<form
onClick={props.onClose}
method="dialog"
class="modal-backdrop"
></form>
</dialog>
</div>
);
};
export default CreateChannelModalView;

View file

@ -0,0 +1,2 @@
export * from "./CreateChannelModalView";
export * from "./types";

View file

@ -0,0 +1,6 @@
interface ICreateChannelModalViewProps {
dialogRef?: (element: HTMLDialogElement) => void;
onClose?: () => void;
}
export { type ICreateChannelModalViewProps };

View file

@ -0,0 +1,93 @@
import { createSignal, type Component } from "solid-js";
import { ICreateInviteModalViewProps } from "./types";
import { Input } from "../../components/Input";
import { getActiveCommunity } from "../../store/community";
import { createCommunityInvite } from "../../services/community";
import { PlusIcon } from "../../icons";
const CreateInviteModalView: Component<ICreateInviteModalViewProps> = (
props: ICreateInviteModalViewProps,
) => {
const [getInviteCount, setInviteCount] = createSignal("");
const [getInviteExpiration, setInviteExpiration] = createSignal("");
const onCreateInvite = () => {
const inviteCount = getInviteCount();
const inviteExpiration = getInviteExpiration();
const community = getActiveCommunity();
if (!community?.id) {
return;
}
const inviteCountNum = Number(inviteCount);
const inviteExpirationNum = new Date(inviteExpiration).getTime();
createCommunityInvite({
communityId: community.id,
totalInvites:
isNaN(inviteCountNum) || inviteCountNum < 1
? undefined
: inviteCountNum,
expirationDate: isNaN(inviteExpirationNum)
? undefined
: inviteExpirationNum,
});
setInviteCount("");
setInviteExpiration("");
props.onClose?.();
};
return (
<div>
<dialog
ref={props.dialogRef}
class="modal outline-none bg-[#00000050]"
>
<div class="modal-box bg-stone-950 rounded-3xl flex flex-col">
<h3 class="text-lg font-bold text-center mb-6">
Create a new Invite
</h3>
<h4 class="text-sm font-semibold mb-1 mx-7 text-left">
Invites (optional)
</h4>
<Input
type="number"
placeholder="Enter how many invites to create"
value={getInviteCount()}
onChange={setInviteCount}
/>
<div class="divider my-4"></div>
<h4 class="text-sm font-semibold mb-1 mx-7 text-left">
Expiration (optional)
</h4>
<Input
type="date"
placeholder="Enter expiration date for the invite"
value={getInviteExpiration()}
onChange={setInviteExpiration}
/>
<div class="divider my-4"></div>
<button
class="bg-stone-950 btn rounded-xl h-14"
onClick={onCreateInvite}
>
<div class="w-5">
<PlusIcon />
</div>
Create Invite
</button>
</div>
<form
onClick={props.onClose}
method="dialog"
class="modal-backdrop"
></form>
</dialog>
</div>
);
};
export default CreateInviteModalView;

Some files were not shown because too many files have changed in this diff Show more