(feat): big call and chatting stuff #23
19 changed files with 461 additions and 439 deletions
(wip): add onboarding
(fix): login page reloading
commit
bf8f449f03
|
|
@ -63,6 +63,7 @@
|
||||||
"@tensamin/ui": "*",
|
"@tensamin/ui": "*",
|
||||||
"@tensamin/user": "workspace:*",
|
"@tensamin/user": "workspace:*",
|
||||||
"@tensamin/notifications": "workspace:*",
|
"@tensamin/notifications": "workspace:*",
|
||||||
|
"@tensamin/onboarding": "workspace:*",
|
||||||
"aria-hidden": "^1.2.4",
|
"aria-hidden": "^1.2.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import * as React from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
import QrCodeScanner from "@tensamin/tauri/qrCodeScanner";
|
import QrCodeScanner from "@tensamin/tauri/qrCodeScanner";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
||||||
const fetchedUser = z.object({
|
const fetchedUser = z.object({
|
||||||
id: z.uuidv4(),
|
id: z.uuidv4(),
|
||||||
|
|
@ -80,6 +81,7 @@ export default function Form() {
|
||||||
const uploadRef = React.useRef<HTMLInputElement | null>(null);
|
const uploadRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
const [isDragging, setIsDragging] = React.useState(false);
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
const { save } = useStorage();
|
const { save } = useStorage();
|
||||||
|
const navigate = useNavigate();
|
||||||
const loginPendingRef = React.useRef(false);
|
const loginPendingRef = React.useRef(false);
|
||||||
|
|
||||||
const persistLogin = React.useCallback(
|
const persistLogin = React.useCallback(
|
||||||
|
|
@ -91,18 +93,13 @@ export default function Form() {
|
||||||
await save("mtp_keyring", privateKey, { secure: true });
|
await save("mtp_keyring", privateKey, { secure: true });
|
||||||
await save("session_id", Date.now());
|
await save("session_id", Date.now());
|
||||||
await save("user_id", userId);
|
await save("user_id", userId);
|
||||||
window.history.replaceState(
|
await navigate({ to: "/", replace: true });
|
||||||
null,
|
|
||||||
"",
|
|
||||||
window.location.protocol === "file:" ? "#/" : "/",
|
|
||||||
);
|
|
||||||
window.location.reload();
|
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
loginPendingRef.current = false;
|
loginPendingRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[save],
|
[navigate, save],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Process dropped files
|
// Process dropped files
|
||||||
|
|
|
||||||
|
|
@ -1,271 +0,0 @@
|
||||||
import { useState, useCallback, useEffect } from "react";
|
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
CreateScreen,
|
|
||||||
ErrorScreen,
|
|
||||||
Label,
|
|
||||||
Link,
|
|
||||||
Spinner,
|
|
||||||
} from "@tensamin/ui";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { legalDocsSchema } from "@tensamin/shared/features/legal/schema";
|
|
||||||
import { log } from "@tensamin/shared/log";
|
|
||||||
|
|
||||||
// Prevents the user from using Tensamin without accepting the privacy policy and terms of service.
|
|
||||||
export default function Screen(props: { children: React.ReactNode }) {
|
|
||||||
const { load, save } = useStorage();
|
|
||||||
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [errorDescription, setErrorDescription] = useState("");
|
|
||||||
|
|
||||||
const [remoteDocs, setRemoteDocs] = useState<
|
|
||||||
z.infer<typeof legalDocsSchema> | undefined
|
|
||||||
>(undefined);
|
|
||||||
const [localDocs, setLocalDocs] = useState<
|
|
||||||
z.infer<typeof legalDocsSchema> | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [showLoading, setShowLoading] = useState(false);
|
|
||||||
|
|
||||||
const [acceptedPP, acceptPP] = useState(false);
|
|
||||||
const [acceptedTOS, acceptTOS] = useState(false);
|
|
||||||
const [hasContinued, setHasContinued] = useState(false);
|
|
||||||
|
|
||||||
const [userId, setUserId] = useState<number | undefined>(undefined);
|
|
||||||
|
|
||||||
// Saves
|
|
||||||
const handleContinueLegal = useCallback((): void => {
|
|
||||||
const currentDocs = remoteDocs;
|
|
||||||
if (!currentDocs) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
save("accepted_privacy_policy", true);
|
|
||||||
save("accepted_terms_of_service", true);
|
|
||||||
save("legal_docs", currentDocs);
|
|
||||||
setHasContinued(true);
|
|
||||||
}, [remoteDocs, save]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
let loadingTimer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const id = await load("user_id");
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUserId(id);
|
|
||||||
|
|
||||||
if (id === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadingTimer = setTimeout(() => {
|
|
||||||
if (active) setShowLoading(true);
|
|
||||||
}, 200);
|
|
||||||
|
|
||||||
const response = await fetch("https://legal.tensamin.net/api/current");
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Legal documents request failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
const current: unknown = await response.json();
|
|
||||||
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeCurrent = legalDocsSchema.safeParse(current);
|
|
||||||
if (!safeCurrent.success) {
|
|
||||||
setError("Failed to load legal documents");
|
|
||||||
setErrorDescription(
|
|
||||||
"The legal documents data received from the server is invalid. Please try again later.",
|
|
||||||
);
|
|
||||||
log(
|
|
||||||
0,
|
|
||||||
"Legal",
|
|
||||||
"red",
|
|
||||||
"Invalid legal documents data",
|
|
||||||
safeCurrent.error,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setRemoteDocs(safeCurrent.data);
|
|
||||||
|
|
||||||
const currentLocalDocs = await load("legal_docs");
|
|
||||||
setLocalDocs(currentLocalDocs);
|
|
||||||
|
|
||||||
const [loadedAcceptedPP, loadedAcceptedTOS] = await Promise.all([
|
|
||||||
load("accepted_privacy_policy"),
|
|
||||||
load("accepted_terms_of_service"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
acceptPP(
|
|
||||||
loadedAcceptedPP &&
|
|
||||||
!!currentLocalDocs &&
|
|
||||||
currentLocalDocs.pp.hash === safeCurrent.data.pp.hash,
|
|
||||||
);
|
|
||||||
acceptTOS(
|
|
||||||
loadedAcceptedTOS &&
|
|
||||||
!!currentLocalDocs &&
|
|
||||||
currentLocalDocs.tos.hash === safeCurrent.data.tos.hash,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
if (!active) return;
|
|
||||||
|
|
||||||
setError("Failed to load legal documents");
|
|
||||||
setErrorDescription(
|
|
||||||
"An error occurred while fetching the legal documents from the server. Please try again later.",
|
|
||||||
);
|
|
||||||
log(0, "Legal", "red", "Failed to fetch legal documents", err);
|
|
||||||
} finally {
|
|
||||||
clearTimeout(loadingTimer);
|
|
||||||
if (active) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
clearTimeout(loadingTimer);
|
|
||||||
};
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
if (error !== "" && errorDescription !== "") {
|
|
||||||
return <ErrorScreen error={error} description={errorDescription} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading || userId === undefined) {
|
|
||||||
if (!showLoading) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CreateScreen>
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<Spinner className="size-8" />
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Fetching legal documents...
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
label="status.methanium.net"
|
|
||||||
link="https://status.methanium.net"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CreateScreen>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const docsMatch =
|
|
||||||
localDocs !== undefined &&
|
|
||||||
remoteDocs !== undefined &&
|
|
||||||
localDocs.pp.hash === remoteDocs.pp.hash &&
|
|
||||||
localDocs.tos.hash === remoteDocs.tos.hash;
|
|
||||||
|
|
||||||
if (
|
|
||||||
(acceptedPP && acceptedTOS && (docsMatch || hasContinued)) ||
|
|
||||||
userId === 0
|
|
||||||
) {
|
|
||||||
return <>{props.children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CreateScreen>
|
|
||||||
<div className="h-full flex flex-col gap-15 p-10 py-20 md:p-40 w-full lg:w-2/3">
|
|
||||||
<h1 className="text-3xl md:text-4xl font-bold">
|
|
||||||
Privacy Policy & ToS
|
|
||||||
<p className="text-muted-foreground text-[20px] font-normal pt-3">
|
|
||||||
{remoteDocs?.pp.version} / {remoteDocs?.tos.version}
|
|
||||||
</p>
|
|
||||||
</h1>
|
|
||||||
<div className="w-full h-full flex flex-col items-center justify-center gap-5">
|
|
||||||
<div className="justify-start items-start flex flex-col gap-2">
|
|
||||||
<BigCheckbox
|
|
||||||
id="acceptPP"
|
|
||||||
checked={acceptedPP}
|
|
||||||
onChange={acceptPP}
|
|
||||||
label="I agree to the Privacy Policy"
|
|
||||||
/>
|
|
||||||
<BigCheckbox
|
|
||||||
id="acceptTOS"
|
|
||||||
checked={acceptedTOS}
|
|
||||||
onChange={acceptTOS}
|
|
||||||
label="I agree to the Terms of Service"
|
|
||||||
/>
|
|
||||||
<div className="w-full border-t-2" />
|
|
||||||
<Link
|
|
||||||
label="Privacy Policy"
|
|
||||||
link={`https://legal.tensamin.net/pp/${remoteDocs?.pp.version}`}
|
|
||||||
/>
|
|
||||||
<Link
|
|
||||||
label="Terms of Service"
|
|
||||||
link={`https://legal.tensamin.net/tos/${remoteDocs?.tos.version}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ContinueButton
|
|
||||||
disabled={!acceptedPP || !acceptedTOS}
|
|
||||||
onClick={handleContinueLegal}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CreateScreen>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Components
|
|
||||||
function ContinueButton({
|
|
||||||
onClick,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
onClick: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="w-full flex justify-end">
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
className="text-md w-full md:w-auto"
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BigCheckbox({
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (checked: boolean) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id={id}
|
|
||||||
checked={checked}
|
|
||||||
onCheckedChange={onChange}
|
|
||||||
className="size-5.5 rounded-md flex items-center justify-center"
|
|
||||||
/>
|
|
||||||
<Label htmlFor={id} className="text-lg">
|
|
||||||
{label}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -15,6 +15,7 @@ import NotFound from "@/routes/404";
|
||||||
|
|
||||||
import AppLayout from "@/routes/app/layout";
|
import AppLayout from "@/routes/app/layout";
|
||||||
import { createSettingsRoute } from "@tensamin/settings";
|
import { createSettingsRoute } from "@tensamin/settings";
|
||||||
|
import OnboardingGate from "@tensamin/onboarding";
|
||||||
|
|
||||||
import Home from "@/routes/app/home";
|
import Home from "@/routes/app/home";
|
||||||
import ChatScreen from "@tensamin/chat/screen";
|
import ChatScreen from "@tensamin/chat/screen";
|
||||||
|
|
@ -43,7 +44,6 @@ import Crypto from "@tensamin/crypto/context";
|
||||||
import DesktopMediaProvider from "@tensamin/shared/desktopMedia";
|
import DesktopMediaProvider from "@tensamin/shared/desktopMedia";
|
||||||
import { log } from "@tensamin/shared/log";
|
import { log } from "@tensamin/shared/log";
|
||||||
|
|
||||||
import LegalWrapper from "@/features/legal/screen";
|
|
||||||
import CacheSync from "@tensamin/cache/sync";
|
import CacheSync from "@tensamin/cache/sync";
|
||||||
|
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
|
|
@ -244,13 +244,7 @@ function RootShell() {
|
||||||
<Storage>
|
<Storage>
|
||||||
<ThemeStorageBridge />
|
<ThemeStorageBridge />
|
||||||
<LoginWrapper>
|
<LoginWrapper>
|
||||||
<LegalWrapper>
|
<Outlet />
|
||||||
<Crypto>
|
|
||||||
<DesktopMediaProvider>
|
|
||||||
<Outlet />
|
|
||||||
</DesktopMediaProvider>
|
|
||||||
</Crypto>
|
|
||||||
</LegalWrapper>
|
|
||||||
</LoginWrapper>
|
</LoginWrapper>
|
||||||
</Storage>
|
</Storage>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
@ -262,24 +256,30 @@ function RootShell() {
|
||||||
|
|
||||||
function AppShell() {
|
function AppShell() {
|
||||||
return (
|
return (
|
||||||
<MTPProvider>
|
<OnboardingGate>
|
||||||
<CacheSync />
|
<Crypto>
|
||||||
<Session>
|
<DesktopMediaProvider>
|
||||||
<UserProvider>
|
<MTPProvider>
|
||||||
<CallInit />
|
<CacheSync />
|
||||||
<CallPopout />
|
<Session>
|
||||||
<TAuthWrapper>
|
<UserProvider>
|
||||||
<AppLayout>
|
<CallInit />
|
||||||
<ChatContext>
|
<CallPopout />
|
||||||
<NotificationsProvider>
|
<TAuthWrapper>
|
||||||
<Outlet />
|
<AppLayout>
|
||||||
</NotificationsProvider>
|
<ChatContext>
|
||||||
</ChatContext>
|
<NotificationsProvider>
|
||||||
</AppLayout>
|
<Outlet />
|
||||||
</TAuthWrapper>
|
</NotificationsProvider>
|
||||||
</UserProvider>
|
</ChatContext>
|
||||||
</Session>
|
</AppLayout>
|
||||||
</MTPProvider>
|
</TAuthWrapper>
|
||||||
|
</UserProvider>
|
||||||
|
</Session>
|
||||||
|
</MTPProvider>
|
||||||
|
</DesktopMediaProvider>
|
||||||
|
</Crypto>
|
||||||
|
</OnboardingGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,7 @@ export default defineConfig({
|
||||||
"@tensamin/markdown",
|
"@tensamin/markdown",
|
||||||
"@tensamin/mtp",
|
"@tensamin/mtp",
|
||||||
"@tensamin/notifications",
|
"@tensamin/notifications",
|
||||||
|
"@tensamin/onboarding",
|
||||||
"@tensamin/shared",
|
"@tensamin/shared",
|
||||||
"@tensamin/shared/data",
|
"@tensamin/shared/data",
|
||||||
"@tensamin/shared/log",
|
"@tensamin/shared/log",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Button } from "@tensamin/ui";
|
import { Button, withTooltip } from "@tensamin/ui";
|
||||||
import { toggleDeaf, useCall } from "../../store";
|
import { toggleDeaf, useCall } from "../../store";
|
||||||
import { HeadphoneOff, Headphones } from "lucide-react";
|
import { HeadphoneOff, Headphones } from "lucide-react";
|
||||||
import { type CallButtonProps, iconScale, withTooltip } from "./tooltipButton";
|
import { type CallButtonProps, iconScale } from "./tooltipButton";
|
||||||
|
|
||||||
export default function DeafButton({
|
export default function DeafButton({
|
||||||
className,
|
className,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { LeaveIcon } from "@livekit/components-react";
|
import { LeaveIcon } from "@livekit/components-react";
|
||||||
import { Button } from "@tensamin/ui";
|
import { Button, withTooltip } from "@tensamin/ui";
|
||||||
import { disconnect, useCall } from "../../store";
|
import { disconnect, useCall } from "../../store";
|
||||||
import { type CallButtonProps, iconScale, withTooltip } from "./tooltipButton";
|
import { type CallButtonProps, iconScale } from "./tooltipButton";
|
||||||
|
|
||||||
export default function LeaveButton({
|
export default function LeaveButton({
|
||||||
className,
|
className,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Button } from "@tensamin/ui";
|
import { Button, withTooltip } from "@tensamin/ui";
|
||||||
import { toggleMute, useCall } from "../../store";
|
import { toggleMute, useCall } from "../../store";
|
||||||
import { Mic, MicOff } from "lucide-react";
|
import { Mic, MicOff } from "lucide-react";
|
||||||
import { type CallButtonProps, iconScale, withTooltip } from "./tooltipButton";
|
import { type CallButtonProps, iconScale } from "./tooltipButton";
|
||||||
|
|
||||||
export default function MuteButton({
|
export default function MuteButton({
|
||||||
className,
|
className,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,3 @@
|
||||||
import { type ReactElement } from "react";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui";
|
|
||||||
|
|
||||||
export type CallButtonProps = {
|
export type CallButtonProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
iconSize?: number;
|
iconSize?: number;
|
||||||
|
|
@ -11,22 +8,3 @@ export type CallButtonProps = {
|
||||||
export function iconScale(iconSize?: number) {
|
export function iconScale(iconSize?: number) {
|
||||||
return { scale: (iconSize ? iconSize + 100 : 100) + "%" };
|
return { scale: (iconSize ? iconSize + 100 : 100) + "%" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withTooltip(
|
|
||||||
button: ReactElement,
|
|
||||||
tooltip?: string,
|
|
||||||
portalContainer?: HTMLElement,
|
|
||||||
) {
|
|
||||||
if (!tooltip) {
|
|
||||||
return button;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger render={button} />
|
|
||||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
|
||||||
{tooltip}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
23
packages/onboarding/package.json
Normal file
23
packages/onboarding/package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "@tensamin/onboarding",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.tsx"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"format": "pnpm exec prettier --write .",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"build": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tensamin/shared": "workspace:*",
|
||||||
|
"@tensamin/storage": "workspace:*",
|
||||||
|
"@tensamin/ui": "*",
|
||||||
|
"lucide-react": "^1.14.0",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
178
packages/onboarding/src/index.tsx
Normal file
178
packages/onboarding/src/index.tsx
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
CreateScreen,
|
||||||
|
ErrorScreen,
|
||||||
|
Link,
|
||||||
|
OnboardingFlow,
|
||||||
|
Spinner,
|
||||||
|
type OnboardingStep,
|
||||||
|
} from "@tensamin/ui";
|
||||||
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
|
import { legalDocsSchema } from "@tensamin/shared/features/legal/schema";
|
||||||
|
import { log } from "@tensamin/shared/log";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
|
import LegalPage from "./pages/legal";
|
||||||
|
import { onboardingSteps } from "./steps";
|
||||||
|
|
||||||
|
export {
|
||||||
|
useOnboardingStep,
|
||||||
|
type OnboardingStep,
|
||||||
|
type OnboardingStepControls,
|
||||||
|
} from "@tensamin/ui";
|
||||||
|
|
||||||
|
type LegalDocs = z.infer<typeof legalDocsSchema>;
|
||||||
|
|
||||||
|
interface GateState {
|
||||||
|
docs: LegalDocs;
|
||||||
|
acceptedPP: boolean;
|
||||||
|
acceptedTOS: boolean;
|
||||||
|
includeLegal: boolean;
|
||||||
|
includeOnboarding: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OnboardingGate({ children }: { children: ReactNode }) {
|
||||||
|
const { load, save } = useStorage();
|
||||||
|
const [state, setState] = useState<GateState>();
|
||||||
|
const [complete, setComplete] = useState(false);
|
||||||
|
const [showLoading, setShowLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [errorDescription, setErrorDescription] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
const loadingTimer = setTimeout(() => {
|
||||||
|
if (active) setShowLoading(true);
|
||||||
|
}, 200);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch("https://legal.tensamin.net/api/current");
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Legal documents request failed: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current: unknown = await response.json();
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
const parsed = legalDocsSchema.safeParse(current);
|
||||||
|
if (!parsed.success) {
|
||||||
|
setError("Failed to load legal documents");
|
||||||
|
setErrorDescription(
|
||||||
|
"The legal documents data received from the server is invalid. Please try again later.",
|
||||||
|
);
|
||||||
|
log(0, "Legal", "red", "Invalid legal documents data", parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [localDocs, acceptedPP, acceptedTOS, onboardingDone] =
|
||||||
|
await Promise.all([
|
||||||
|
load("legal_docs"),
|
||||||
|
load("accepted_privacy_policy"),
|
||||||
|
load("accepted_terms_of_service"),
|
||||||
|
load("onboarding_done"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!active) return;
|
||||||
|
|
||||||
|
const currentAcceptedPP =
|
||||||
|
acceptedPP && localDocs.pp.hash === parsed.data.pp.hash;
|
||||||
|
const currentAcceptedTOS =
|
||||||
|
acceptedTOS && localDocs.tos.hash === parsed.data.tos.hash;
|
||||||
|
const existingUser = acceptedPP && acceptedTOS;
|
||||||
|
const includeOnboarding = !onboardingDone && !existingUser;
|
||||||
|
|
||||||
|
if (!onboardingDone && existingUser) {
|
||||||
|
await save("onboarding_done", true);
|
||||||
|
if (!active) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({
|
||||||
|
docs: parsed.data,
|
||||||
|
acceptedPP: currentAcceptedPP,
|
||||||
|
acceptedTOS: currentAcceptedTOS,
|
||||||
|
includeLegal: !currentAcceptedPP || !currentAcceptedTOS,
|
||||||
|
includeOnboarding,
|
||||||
|
});
|
||||||
|
} catch (caught) {
|
||||||
|
if (!active) return;
|
||||||
|
setError("Failed to load legal documents");
|
||||||
|
setErrorDescription(
|
||||||
|
"An error occurred while fetching the legal documents from the server. Please try again later.",
|
||||||
|
);
|
||||||
|
log(0, "Legal", "red", "Failed to fetch legal documents", caught);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(loadingTimer);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
clearTimeout(loadingTimer);
|
||||||
|
};
|
||||||
|
}, [load, save]);
|
||||||
|
|
||||||
|
const acceptLegal = useCallback(async () => {
|
||||||
|
if (!state) return;
|
||||||
|
await Promise.all([
|
||||||
|
save("accepted_privacy_policy", true),
|
||||||
|
save("accepted_terms_of_service", true),
|
||||||
|
save("legal_docs", state.docs),
|
||||||
|
]);
|
||||||
|
setState((current) =>
|
||||||
|
current ? { ...current, acceptedPP: true, acceptedTOS: true } : current,
|
||||||
|
);
|
||||||
|
}, [save, state]);
|
||||||
|
|
||||||
|
const finish = useCallback(async () => {
|
||||||
|
if (state?.includeOnboarding) {
|
||||||
|
await save("onboarding_done", true);
|
||||||
|
}
|
||||||
|
setComplete(true);
|
||||||
|
}, [save, state?.includeOnboarding]);
|
||||||
|
|
||||||
|
if (error && errorDescription) {
|
||||||
|
return <ErrorScreen error={error} description={errorDescription} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state) {
|
||||||
|
if (!showLoading) return null;
|
||||||
|
return (
|
||||||
|
<CreateScreen>
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<Spinner className="size-8" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Fetching legal documents...
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
label="status.methanium.net"
|
||||||
|
link="https://status.methanium.net"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CreateScreen>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps: OnboardingStep[] = [];
|
||||||
|
if (state.includeLegal) {
|
||||||
|
steps.push({
|
||||||
|
id: "legal",
|
||||||
|
defaultCanContinue: false,
|
||||||
|
content: (
|
||||||
|
<LegalPage
|
||||||
|
docs={state.docs}
|
||||||
|
initiallyAcceptedPP={state.acceptedPP}
|
||||||
|
initiallyAcceptedTOS={state.acceptedTOS}
|
||||||
|
onAccept={acceptLegal}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (state.includeOnboarding) {
|
||||||
|
steps.push(...onboardingSteps);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (complete || steps.length === 0) return <>{children}</>;
|
||||||
|
|
||||||
|
return <OnboardingFlow steps={steps} onFinish={finish} />;
|
||||||
|
}
|
||||||
98
packages/onboarding/src/pages/legal.tsx
Normal file
98
packages/onboarding/src/pages/legal.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { Checkbox, Label, Link } from "@tensamin/ui";
|
||||||
|
import { legalDocsSchema } from "@tensamin/shared/features/legal/schema";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
|
import { useOnboardingStep } from "@tensamin/ui";
|
||||||
|
|
||||||
|
type LegalDocs = z.infer<typeof legalDocsSchema>;
|
||||||
|
|
||||||
|
export default function LegalPage({
|
||||||
|
docs,
|
||||||
|
initiallyAcceptedPP,
|
||||||
|
initiallyAcceptedTOS,
|
||||||
|
onAccept,
|
||||||
|
}: {
|
||||||
|
docs: LegalDocs;
|
||||||
|
initiallyAcceptedPP: boolean;
|
||||||
|
initiallyAcceptedTOS: boolean;
|
||||||
|
onAccept: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [acceptedPP, setAcceptedPP] = useState(initiallyAcceptedPP);
|
||||||
|
const [acceptedTOS, setAcceptedTOS] = useState(initiallyAcceptedTOS);
|
||||||
|
|
||||||
|
const handleContinue = useCallback(async () => {
|
||||||
|
if (!acceptedPP || !acceptedTOS) return false;
|
||||||
|
await onAccept();
|
||||||
|
}, [acceptedPP, acceptedTOS, onAccept]);
|
||||||
|
|
||||||
|
useOnboardingStep({
|
||||||
|
canContinue: acceptedPP && acceptedTOS,
|
||||||
|
onContinue: handleContinue,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex h-full w-full max-w-5xl flex-col gap-10 p-10 py-20 md:p-24">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold md:text-4xl" tabIndex={-1}>
|
||||||
|
Privacy Policy & ToS
|
||||||
|
</h1>
|
||||||
|
<p className="pt-3 text-xl text-muted-foreground">
|
||||||
|
{docs.pp.version} / {docs.tos.version}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 items-center justify-center">
|
||||||
|
<div className="flex flex-col items-start gap-2">
|
||||||
|
<BigCheckbox
|
||||||
|
id="acceptPP"
|
||||||
|
checked={acceptedPP}
|
||||||
|
onChange={setAcceptedPP}
|
||||||
|
label="I agree to the Privacy Policy"
|
||||||
|
/>
|
||||||
|
<BigCheckbox
|
||||||
|
id="acceptTOS"
|
||||||
|
checked={acceptedTOS}
|
||||||
|
onChange={setAcceptedTOS}
|
||||||
|
label="I agree to the Terms of Service"
|
||||||
|
/>
|
||||||
|
<div className="w-full border-t-2" />
|
||||||
|
<Link
|
||||||
|
label="Privacy Policy"
|
||||||
|
link={`https://legal.tensamin.net/pp/${docs.pp.version}`}
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
label="Terms of Service"
|
||||||
|
link={`https://legal.tensamin.net/tos/${docs.tos.version}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BigCheckbox({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={id}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={onChange}
|
||||||
|
className="flex size-5.5 items-center justify-center rounded-md"
|
||||||
|
/>
|
||||||
|
<Label htmlFor={id} className="text-lg">
|
||||||
|
{label}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
packages/onboarding/src/pages/onboarding.tsx
Normal file
9
packages/onboarding/src/pages/onboarding.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export default function OnboardingPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center p-8">
|
||||||
|
<h1 className="text-3xl font-bold" tabIndex={-1}>
|
||||||
|
Onboarding
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
packages/onboarding/src/steps.tsx
Normal file
10
packages/onboarding/src/steps.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import type { OnboardingStep } from "@tensamin/ui";
|
||||||
|
import OnboardingPage from "./pages/onboarding";
|
||||||
|
|
||||||
|
// Add future onboarding pages here. Completed users skip this entire manifest.
|
||||||
|
export const onboardingSteps: OnboardingStep[] = [
|
||||||
|
{
|
||||||
|
id: "onboarding",
|
||||||
|
content: <OnboardingPage />,
|
||||||
|
},
|
||||||
|
];
|
||||||
12
packages/onboarding/tsconfig.json
Normal file
12
packages/onboarding/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,4 @@
|
||||||
import {
|
import { EditableList, LabeledSwitch } from "@tensamin/ui";
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
ContextMenu,
|
|
||||||
ContextMenuContent,
|
|
||||||
ContextMenuItem,
|
|
||||||
ContextMenuTrigger,
|
|
||||||
Input,
|
|
||||||
Label,
|
|
||||||
Switch as UISwitch,
|
|
||||||
} from "@tensamin/ui";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { storageDefaults, type Storage } from "@tensamin/shared/data";
|
import { storageDefaults, type Storage } from "@tensamin/shared/data";
|
||||||
|
|
@ -24,9 +14,12 @@ type ListStorageKey = {
|
||||||
}[keyof Storage];
|
}[keyof Storage];
|
||||||
|
|
||||||
type ListStorageItem<K extends ListStorageKey> =
|
type ListStorageItem<K extends ListStorageKey> =
|
||||||
Storage[K] extends Array<infer Item> ? Item : never;
|
Storage[K] extends Array<infer Item> ? Item & (string | number) : never;
|
||||||
|
|
||||||
export function Switch({ label, id }: {
|
export function Switch({
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
label: React.ReactNode;
|
label: React.ReactNode;
|
||||||
id: keyof typeof settingsStorageDefaults & BooleanStorageKey;
|
id: keyof typeof settingsStorageDefaults & BooleanStorageKey;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -38,91 +31,55 @@ export function Switch({ label, id }: {
|
||||||
}, [id, load]);
|
}, [id, load]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1">
|
<LabeledSwitch
|
||||||
<UISwitch id={id} checked={value} onCheckedChange={(nextValue) => {
|
id={id}
|
||||||
|
label={label}
|
||||||
|
checked={value}
|
||||||
|
onCheckedChange={(nextValue) => {
|
||||||
setValue(nextValue);
|
setValue(nextValue);
|
||||||
save(id, nextValue);
|
save(id, nextValue);
|
||||||
}} />
|
}}
|
||||||
<Label htmlFor={id}>{label}</Label>
|
/>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function List<K extends ListStorageKey>({ label, id }: {
|
export function List<K extends ListStorageKey>({
|
||||||
|
label,
|
||||||
|
id,
|
||||||
|
}: {
|
||||||
label: React.ReactNode;
|
label: React.ReactNode;
|
||||||
id: K;
|
id: K;
|
||||||
}) {
|
}) {
|
||||||
const { save, load } = useStorage();
|
const { save, load } = useStorage();
|
||||||
const [items, setItems] = useState<Storage[K]>(storageDefaults[id]);
|
const [items, setItems] = useState<Storage[K]>(storageDefaults[id]);
|
||||||
const [inputValue, setInputValue] = useState("");
|
|
||||||
const [selectedItems, setSelectedItems] = useState<Set<number>>(new Set());
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load(id).then((value) => setItems(value));
|
load(id).then((value) => setItems(value));
|
||||||
}, [id, load]);
|
}, [id, load]);
|
||||||
|
|
||||||
const persistItems = (nextItems: Storage[K]) => {
|
const persistItems = (nextItems: ListStorageItem<K>[]) => {
|
||||||
setItems(nextItems);
|
setItems(nextItems as Storage[K]);
|
||||||
save(id, nextItems);
|
save(id, nextItems as Storage[K]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toStorageItem = (value: string): ListStorageItem<K> => {
|
const toStorageItem = (value: string): ListStorageItem<K> => {
|
||||||
const referenceItem = items[0] ?? storageDefaults[id][0];
|
const referenceItem = items[0] ?? storageDefaults[id][0];
|
||||||
return (typeof referenceItem === "number" ? Number(value) : value) as ListStorageItem<K>;
|
return (
|
||||||
};
|
typeof referenceItem === "number" ? Number(value) : value
|
||||||
|
) as ListStorageItem<K>;
|
||||||
const addItem = () => {
|
|
||||||
const trimmedValue = inputValue.trim();
|
|
||||||
if (!trimmedValue) return;
|
|
||||||
const nextItem = toStorageItem(trimmedValue);
|
|
||||||
if (typeof nextItem === "number" && Number.isNaN(nextItem)) return;
|
|
||||||
persistItems([...items, nextItem] as Storage[K]);
|
|
||||||
setInputValue("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteItems = (indexes: Set<number>) => {
|
|
||||||
const nextItems = items.filter((_, index) => !indexes.has(index)) as Storage[K];
|
|
||||||
setItems(nextItems);
|
|
||||||
setSelectedItems(new Set());
|
|
||||||
save(id, nextItems);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 pt-4">
|
<EditableList<ListStorageItem<K>>
|
||||||
<Label>{label}</Label>
|
label={label}
|
||||||
<div className="flex flex-col gap-0 overflow-hidden p-1 border-2 rounded-xl">
|
items={items as ListStorageItem<K>[]}
|
||||||
<div className="flex gap-1">
|
parseItem={(value) => {
|
||||||
<Input value={inputValue} onChange={(event) => setInputValue(event.target.value)} onKeyDown={(event) => {
|
const item = toStorageItem(value);
|
||||||
if (event.key === "Enter") addItem();
|
return typeof item === "number" && Number.isNaN(item)
|
||||||
}} />
|
? undefined
|
||||||
<Button onClick={addItem}>Add item</Button>
|
: item;
|
||||||
</div>
|
}}
|
||||||
<div className="flex flex-col gap-0">
|
onItemsChange={persistItems}
|
||||||
{items.map((item, index) => {
|
/>
|
||||||
const labelId = `${String(id)}-${index}`;
|
|
||||||
const selected = selectedItems.has(index);
|
|
||||||
const deletingSelectedItems = selectedItems.size > 1;
|
|
||||||
return (
|
|
||||||
<ContextMenu key={`${String(item)}-${index}`}>
|
|
||||||
<ContextMenuTrigger render={<div className="grid grid-cols-[auto_auto_1fr] items-center gap-2 border-b px-1 py-2 last:border-b-0">
|
|
||||||
<Checkbox id={labelId} checked={selected} onCheckedChange={(checked) => setSelectedItems((previous) => {
|
|
||||||
const nextSelected = new Set(previous);
|
|
||||||
if (checked) nextSelected.add(index);
|
|
||||||
else nextSelected.delete(index);
|
|
||||||
return nextSelected;
|
|
||||||
})} />
|
|
||||||
<Label htmlFor={labelId}>{String(item)}</Label><div />
|
|
||||||
</div>} />
|
|
||||||
<ContextMenuContent>
|
|
||||||
<ContextMenuItem variant="destructive" onClick={() => deleteItems(deletingSelectedItems ? selectedItems : new Set([index]))}>
|
|
||||||
{deletingSelectedItems ? "Delete Selected" : "Delete"}
|
|
||||||
</ContextMenuItem>
|
|
||||||
</ContextMenuContent>
|
|
||||||
</ContextMenu>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -421,6 +421,7 @@ export interface Storage extends SettingsStorageDefaults {
|
||||||
session_id: number;
|
session_id: number;
|
||||||
user_id: number;
|
user_id: number;
|
||||||
mtp_keyring: string;
|
mtp_keyring: string;
|
||||||
|
onboarding_done: boolean;
|
||||||
ppandtos_done: boolean;
|
ppandtos_done: boolean;
|
||||||
accepted_terms_of_service: boolean;
|
accepted_terms_of_service: boolean;
|
||||||
accepted_privacy_policy: boolean;
|
accepted_privacy_policy: boolean;
|
||||||
|
|
@ -458,6 +459,7 @@ export const storageDefaults: Storage = {
|
||||||
session_id: 0,
|
session_id: 0,
|
||||||
user_id: 0,
|
user_id: 0,
|
||||||
mtp_keyring: "",
|
mtp_keyring: "",
|
||||||
|
onboarding_done: false,
|
||||||
ppandtos_done: false,
|
ppandtos_done: false,
|
||||||
accepted_terms_of_service: false,
|
accepted_terms_of_service: false,
|
||||||
accepted_privacy_policy: false,
|
accepted_privacy_policy: false,
|
||||||
|
|
|
||||||
85
pnpm-lock.yaml
generated
85
pnpm-lock.yaml
generated
|
|
@ -5,7 +5,7 @@ settings:
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
'@tensamin/ui': https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz
|
'@tensamin/ui': https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz
|
||||||
mtp: https://git.methanium.net/methanium/mtp/releases/download/0.2.0-3919eb46fd/mtp-0.2.0.tgz
|
mtp: https://git.methanium.net/methanium/mtp/releases/download/0.2.0-3919eb46fd/mtp-0.2.0.tgz
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
|
|
@ -13,8 +13,8 @@ importers:
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tensamin/ui':
|
'@tensamin/ui':
|
||||||
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz
|
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz
|
||||||
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
||||||
mtp:
|
mtp:
|
||||||
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.2.0-3919eb46fd/mtp-0.2.0.tgz
|
specifier: https://git.methanium.net/methanium/mtp/releases/download/0.2.0-3919eb46fd/mtp-0.2.0.tgz
|
||||||
version: https://git.methanium.net/methanium/mtp/releases/download/0.2.0-3919eb46fd/mtp-0.2.0.tgz
|
version: https://git.methanium.net/methanium/mtp/releases/download/0.2.0-3919eb46fd/mtp-0.2.0.tgz
|
||||||
|
|
@ -107,8 +107,8 @@ importers:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
'@tensamin/ui':
|
'@tensamin/ui':
|
||||||
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz
|
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz
|
||||||
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.0
|
specifier: ^19.2.0
|
||||||
version: 19.2.7
|
version: 19.2.7
|
||||||
|
|
@ -257,6 +257,9 @@ importers:
|
||||||
'@tensamin/notifications':
|
'@tensamin/notifications':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/notifications
|
version: link:../../packages/notifications
|
||||||
|
'@tensamin/onboarding':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/onboarding
|
||||||
'@tensamin/settings':
|
'@tensamin/settings':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/settings
|
version: link:../../packages/settings
|
||||||
|
|
@ -273,8 +276,8 @@ importers:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/tauth
|
version: link:../../packages/tauth
|
||||||
'@tensamin/ui':
|
'@tensamin/ui':
|
||||||
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz
|
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz
|
||||||
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
||||||
'@tensamin/user':
|
'@tensamin/user':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/user
|
version: link:../../packages/user
|
||||||
|
|
@ -547,8 +550,8 @@ importers:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../storage
|
version: link:../storage
|
||||||
'@tensamin/ui':
|
'@tensamin/ui':
|
||||||
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz
|
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz
|
||||||
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
||||||
'@tensamin/user':
|
'@tensamin/user':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../user
|
version: link:../user
|
||||||
|
|
@ -610,8 +613,8 @@ importers:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../storage
|
version: link:../storage
|
||||||
'@tensamin/ui':
|
'@tensamin/ui':
|
||||||
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz
|
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz
|
||||||
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
||||||
'@tensamin/user':
|
'@tensamin/user':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../user
|
version: link:../user
|
||||||
|
|
@ -661,8 +664,8 @@ importers:
|
||||||
specifier: ^6.41.1
|
specifier: ^6.41.1
|
||||||
version: 6.43.4
|
version: 6.43.4
|
||||||
'@tensamin/ui':
|
'@tensamin/ui':
|
||||||
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz
|
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz
|
||||||
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
||||||
'@twemoji/api':
|
'@twemoji/api':
|
||||||
specifier: ^17.0.3
|
specifier: ^17.0.3
|
||||||
version: 17.0.3
|
version: 17.0.3
|
||||||
|
|
@ -691,8 +694,8 @@ importers:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../storage
|
version: link:../storage
|
||||||
'@tensamin/ui':
|
'@tensamin/ui':
|
||||||
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz
|
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz
|
||||||
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.14.0
|
specifier: ^1.14.0
|
||||||
version: 1.23.0(react@19.2.7)
|
version: 1.23.0(react@19.2.7)
|
||||||
|
|
@ -740,8 +743,8 @@ importers:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../storage
|
version: link:../storage
|
||||||
'@tensamin/ui':
|
'@tensamin/ui':
|
||||||
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz
|
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz
|
||||||
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
||||||
'@tensamin/user':
|
'@tensamin/user':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../user
|
version: link:../user
|
||||||
|
|
@ -758,6 +761,30 @@ importers:
|
||||||
specifier: ^4.3.6
|
specifier: ^4.3.6
|
||||||
version: 4.4.3
|
version: 4.4.3
|
||||||
|
|
||||||
|
packages/onboarding:
|
||||||
|
dependencies:
|
||||||
|
'@tensamin/shared':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../shared
|
||||||
|
'@tensamin/storage':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../storage
|
||||||
|
'@tensamin/ui':
|
||||||
|
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz
|
||||||
|
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
||||||
|
lucide-react:
|
||||||
|
specifier: ^1.14.0
|
||||||
|
version: 1.23.0(react@19.2.7)
|
||||||
|
react:
|
||||||
|
specifier: ^19.2.0
|
||||||
|
version: 19.2.7
|
||||||
|
react-dom:
|
||||||
|
specifier: ^19.2.0
|
||||||
|
version: 19.2.7(react@19.2.7)
|
||||||
|
zod:
|
||||||
|
specifier: ^4.3.6
|
||||||
|
version: 4.4.3
|
||||||
|
|
||||||
packages/settings:
|
packages/settings:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tanstack/react-router':
|
'@tanstack/react-router':
|
||||||
|
|
@ -779,8 +806,8 @@ importers:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../storage
|
version: link:../storage
|
||||||
'@tensamin/ui':
|
'@tensamin/ui':
|
||||||
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz
|
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz
|
||||||
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
||||||
'@tensamin/user':
|
'@tensamin/user':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../user
|
version: link:../user
|
||||||
|
|
@ -801,8 +828,8 @@ importers:
|
||||||
packages/shared:
|
packages/shared:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tensamin/ui':
|
'@tensamin/ui':
|
||||||
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz
|
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz
|
||||||
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.14.0
|
specifier: ^1.14.0
|
||||||
version: 1.23.0(react@19.2.7)
|
version: 1.23.0(react@19.2.7)
|
||||||
|
|
@ -831,8 +858,8 @@ importers:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../shared
|
version: link:../shared
|
||||||
'@tensamin/ui':
|
'@tensamin/ui':
|
||||||
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz
|
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz
|
||||||
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
||||||
react:
|
react:
|
||||||
specifier: ^19.2.0
|
specifier: ^19.2.0
|
||||||
version: 19.2.7
|
version: 19.2.7
|
||||||
|
|
@ -864,8 +891,8 @@ importers:
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../apps/tauri
|
version: link:../../apps/tauri
|
||||||
'@tensamin/ui':
|
'@tensamin/ui':
|
||||||
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz
|
specifier: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz
|
||||||
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
version: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)
|
||||||
'@tensamin/user':
|
'@tensamin/user':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../user
|
version: link:../user
|
||||||
|
|
@ -2125,9 +2152,9 @@ packages:
|
||||||
'@tauri-apps/plugin-notification@2.3.3':
|
'@tauri-apps/plugin-notification@2.3.3':
|
||||||
resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==}
|
resolution: {integrity: sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==}
|
||||||
|
|
||||||
'@tensamin/ui@https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz':
|
'@tensamin/ui@https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz':
|
||||||
resolution: {integrity: sha512-xyzw3/XAYKczV1cYQ2QuzhFzFYbU9U2U/gi/TMU/U7MC0vp1xHCVssTcB1kot8yk9YEIEcDBK2X49Ftzj6bJeQ==, tarball: https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz}
|
resolution: {integrity: sha512-H68VGR6Dy/3cO+VhhUdUdMkPWnUqP0lDmu3v3NPdfuB++SUmSm/zVVXB0I5Y5bnNXTD3FOVLIdP+AplaXuEGDg==, tarball: https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz}
|
||||||
version: 0.0.40
|
version: 0.0.41
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: ^19.2.7
|
react: ^19.2.7
|
||||||
react-dom: ^19.2.7
|
react-dom: ^19.2.7
|
||||||
|
|
@ -6206,7 +6233,7 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.11.1
|
'@tauri-apps/api': 2.11.1
|
||||||
|
|
||||||
'@tensamin/ui@https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)':
|
'@tensamin/ui@https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz(@date-fns/tz@1.5.0)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-is@19.2.7)(react@19.2.7)(redux@5.0.1)(typescript@6.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@base-ui/react': 1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
'@base-ui/react': 1.6.0(@date-fns/tz@1.5.0)(@types/react@19.2.17)(date-fns@4.4.0)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||||
'@fontsource-variable/inter': 5.2.8
|
'@fontsource-variable/inter': 5.2.8
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,5 @@ allowBuilds:
|
||||||
electron-winstaller: true
|
electron-winstaller: true
|
||||||
esbuild: true
|
esbuild: true
|
||||||
overrides:
|
overrides:
|
||||||
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/releases/download/0.0.40/tensamin-ui.tgz"
|
"@tensamin/ui": "https://git.methanium.net/tensamin/ui/releases/download/0.0.41/tensamin-ui.tgz"
|
||||||
mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.2.0-3919eb46fd/mtp-0.2.0.tgz"
|
mtp: "https://git.methanium.net/methanium/mtp/releases/download/0.2.0-3919eb46fd/mtp-0.2.0.tgz"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue