diff --git a/src/ui/App.css b/src/ui/App.css index 1683f96d4..427363ef0 100644 --- a/src/ui/App.css +++ b/src/ui/App.css @@ -93,6 +93,8 @@ --th-lg: 250px; --th-md: 200px; --th-sm: 150px; + --th-xs: 118px; + --th-xxs: 64px; /* border radius */ --br-lg: 16px; diff --git a/src/ui/atoms/pagination/PaginationDot.css b/src/ui/atoms/pagination/PaginationDot.css index 18a937072..d0aaf00c3 100644 --- a/src/ui/atoms/pagination/PaginationDot.css +++ b/src/ui/atoms/pagination/PaginationDot.css @@ -1,16 +1,10 @@ .PaginationDot { - display: inline-flex; - justify-content: center; - align-items: center; - gap: var(--sp-xxs); -} - -.PaginationDot__item { - width: 8px; - height: 8px; + width: 10px; + height: 10px; border-radius: 50%; background-color: var(--bg-surface-border); + cursor: pointer; } -.PaginationDot__item--active { +.PaginationDot--active { background-color: var(--bg-primary); } diff --git a/src/ui/atoms/pagination/PaginationDot.stories.tsx b/src/ui/atoms/pagination/PaginationDot.stories.tsx index 05359cc41..7fe510535 100644 --- a/src/ui/atoms/pagination/PaginationDot.stories.tsx +++ b/src/ui/atoms/pagination/PaginationDot.stories.tsx @@ -4,8 +4,10 @@ export const title = "PaginationDot"; export default function PaginationDotStories() { return ( -
- +
+ {[0, 1, 2].map((v) => ( + + ))}
); } diff --git a/src/ui/atoms/pagination/PaginationDot.tsx b/src/ui/atoms/pagination/PaginationDot.tsx index 7aa71d6f5..1218ac98f 100644 --- a/src/ui/atoms/pagination/PaginationDot.tsx +++ b/src/ui/atoms/pagination/PaginationDot.tsx @@ -1,22 +1,20 @@ import classNames from "classnames"; +import { ButtonHTMLAttributes, forwardRef } from "react"; import "./PaginationDot.css"; -interface PaginationDotProps { +type PaginationDotProps = ButtonHTMLAttributes & { className?: string; - max: number; - value: number; -} + active?: boolean; +}; -export function PaginationDot({ className, max, value }: PaginationDotProps) { - return ( -
- {Array.from({ length: max }).map((item, index) => ( - - ))} -
- ); -} +export const PaginationDot = forwardRef( + ({ type, className, active, ...props }: PaginationDotProps, ref) => ( +
); } diff --git a/src/ui/atoms/thumbnail/Thumbnail.tsx b/src/ui/atoms/thumbnail/Thumbnail.tsx index b25b00f04..681c16c92 100644 --- a/src/ui/atoms/thumbnail/Thumbnail.tsx +++ b/src/ui/atoms/thumbnail/Thumbnail.tsx @@ -5,7 +5,7 @@ import "./Thumbnail.css"; interface ThumbnailProps { className?: string; bgColor?: string; - size?: "lg" | "md" | "sm"; + size?: "lg" | "md" | "sm" | "xs" | "xxs"; outlined?: boolean; wide?: boolean; children?: ReactNode; diff --git a/src/ui/hooks/useAutoUpload.ts b/src/ui/hooks/useAutoUpload.ts new file mode 100644 index 000000000..99be4030c --- /dev/null +++ b/src/ui/hooks/useAutoUpload.ts @@ -0,0 +1,25 @@ +import { IBlobHandle, Session } from "@thirdroom/hydrogen-view-sdk"; +import { useEffect, useState } from "react"; + +import { useThrottle } from "./useThrottle"; +import { useAttachmentUpload } from "./useAttachmentUpload"; + +export const useAutoUpload = (session: Session, blob?: IBlobHandle) => { + const [progress, setProgress] = useState(0); + const throttledSetProgress = useThrottle(setProgress, 16); + const { mxc, error, upload, cancel } = useAttachmentUpload(session.hsApi, throttledSetProgress); + + useEffect(() => { + if (blob) upload(blob); + else { + cancel(); + setProgress(0); + } + }, [blob, upload, cancel]); + + return { + progress, + mxc, + error, + }; +}; diff --git a/src/ui/utils/common.ts b/src/ui/utils/common.ts index 856244817..bbbb7a8c4 100644 --- a/src/ui/utils/common.ts +++ b/src/ui/utils/common.ts @@ -59,6 +59,75 @@ export function loadImageUrl(url: string): Promise { }); } +export function loadImageElement(url: string): Promise { + return new Promise((resolve, reject) => { + const img = document.createElement("img"); + img.onload = () => resolve(img); + img.onerror = (err) => reject(err); + img.src = url; + }); +} + +export function loadVideoElement(url: string): Promise { + return new Promise((resolve, reject) => { + const video = document.createElement("video"); + video.preload = "metadata"; + video.playsInline = true; + video.muted = true; + + video.onloadeddata = () => { + resolve(video); + video.pause(); + }; + video.onerror = (e) => { + reject(e); + }; + + video.src = url; + video.load(); + video.play(); + }); +} + +export function getThumbnailDimensions(width: number, height: number): [number, number] { + const MAX_WIDTH = 800; + const MAX_HEIGHT = 600; + let targetWidth = width; + let targetHeight = height; + if (targetHeight > MAX_HEIGHT) { + targetWidth = Math.floor(targetWidth * (MAX_HEIGHT / targetHeight)); + targetHeight = MAX_HEIGHT; + } + if (targetWidth > MAX_WIDTH) { + targetHeight = Math.floor(targetHeight * (MAX_WIDTH / targetWidth)); + targetWidth = MAX_WIDTH; + } + return [targetWidth, targetHeight]; +} + +export function getThumbnail( + img: HTMLImageElement | SVGImageElement | HTMLVideoElement, + width: number, + height: number, + thumbnailMimeType?: string +): Promise { + return new Promise((resolve) => { + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext("2d"); + if (!context) { + resolve(undefined); + return; + } + context.drawImage(img, 0, 0, width, height); + + canvas.toBlob((thumbnail) => { + resolve(thumbnail ?? undefined); + }, thumbnailMimeType ?? "image/jpeg"); + }); +} + export function linkifyText(body: string) { const msgParts = []; diff --git a/src/ui/views/components/AutoFileUpload.tsx b/src/ui/views/components/AutoFileUpload.tsx index 24c13361b..bee4b75cf 100644 --- a/src/ui/views/components/AutoFileUpload.tsx +++ b/src/ui/views/components/AutoFileUpload.tsx @@ -1,11 +1,10 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { IBlobHandle } from "@thirdroom/hydrogen-view-sdk"; import { FileUploadCard, FileUploadErrorCard } from "./file-upload-card/FileUploadCard"; -import { useAttachmentUpload } from "../../hooks/useAttachmentUpload"; import { useHydrogen } from "../../hooks/useHydrogen"; import { useFilePicker } from "../../hooks/useFilePicker"; -import { useThrottle } from "../../hooks/useThrottle"; +import { useAutoUpload } from "../../hooks/useAutoUpload"; export interface AutoUploadInfo { mxc?: string; @@ -23,16 +22,7 @@ export function AutoFileUpload({ renderButton, mimeType, onUploadInfo }: AutoFil const { session, platform } = useHydrogen(true); const { fileData, pickFile, dropFile } = useFilePicker(platform, mimeType); - const [progress, setProgress] = useState(0); - const throttledSetProgress = useThrottle(setProgress, 16); - const { mxc, error, upload, cancel } = useAttachmentUpload(session.hsApi, throttledSetProgress); - useEffect(() => { - if (fileData.blob) upload(fileData.blob); - else { - cancel(); - setProgress(0); - } - }, [fileData.blob, upload, cancel]); + const { progress, mxc, error } = useAutoUpload(session, fileData.blob); useEffect(() => { onUploadInfo({ diff --git a/src/ui/views/components/attribution-card/AttributionCard.css b/src/ui/views/components/attribution-card/AttributionCard.css new file mode 100644 index 000000000..70e526c1a --- /dev/null +++ b/src/ui/views/components/attribution-card/AttributionCard.css @@ -0,0 +1,6 @@ +.AttributionCard { + padding: var(--sp-xs); + background-color: var(--bg-surface); + border: 1px solid var(--bg-surface-border); + border-radius: var(--br-lg); +} diff --git a/src/ui/views/components/attribution-card/AttributionCard.tsx b/src/ui/views/components/attribution-card/AttributionCard.tsx new file mode 100644 index 000000000..372274c9e --- /dev/null +++ b/src/ui/views/components/attribution-card/AttributionCard.tsx @@ -0,0 +1,35 @@ +import { AllHTMLAttributes, forwardRef } from "react"; +import classNames from "classnames"; + +import { Input } from "../../../atoms/input/Input"; +import { Label } from "../../../atoms/text/Label"; +import { SettingTile } from "../setting-tile/SettingTile"; +import "./AttributionCard.css"; + +export const AttributionCard = forwardRef>( + ({ className, ...props }, ref) => ( +
+
+ Title}> + + +
+
+ Author Name}> + + + Author URL}> + + +
+
+ License}> + + + Source URL}> + + +
+
+ ) +); diff --git a/src/ui/views/session/editor/AssetUploadModal.tsx b/src/ui/views/session/editor/AssetUploadModal.tsx new file mode 100644 index 000000000..2890eec9b --- /dev/null +++ b/src/ui/views/session/editor/AssetUploadModal.tsx @@ -0,0 +1,98 @@ +import { IBlobHandle } from "@thirdroom/hydrogen-view-sdk"; + +import { Button } from "../../../atoms/button/Button"; +import { Content } from "../../../atoms/content/Content"; +import { Footer } from "../../../atoms/footer/Footer"; +import { Header } from "../../../atoms/header/Header"; +import { HeaderTitle } from "../../../atoms/header/HeaderTitle"; +import { Input } from "../../../atoms/input/Input"; +import { Modal } from "../../../atoms/modal/Modal"; +import { ModalAside } from "../../../atoms/modal/ModalAside"; +import { ModalContent } from "../../../atoms/modal/ModalContent"; +import { Scroll } from "../../../atoms/scroll/Scroll"; +import { Label } from "../../../atoms/text/Label"; +import { Text } from "../../../atoms/text/Text"; +import { useAutoUpload } from "../../../hooks/useAutoUpload"; +import { useHydrogen } from "../../../hooks/useHydrogen"; +import { AttributionCard } from "../../components/attribution-card/AttributionCard"; +import { FileUploadCard, FileUploadErrorCard } from "../../components/file-upload-card/FileUploadCard"; +import { ScenePreview } from "../../components/scene-preview/ScenePreview"; +import { SettingTile } from "../../components/setting-tile/SettingTile"; +import { Asset, AssetType } from "./assets"; + +interface AssetUploadModalProps { + blob: IBlobHandle; + requestClose: () => void; + onSubmit: (asset: Asset) => void; +} + +export function AssetUploadModal({ blob, requestClose, onSubmit }: AssetUploadModalProps) { + const { session } = useHydrogen(true); + const { progress, error } = useAutoUpload(session, blob); + + return ( + + + Asset Upload} />} + children={ + +
+ {error ? ( + + ) : ( + + )} +
+ Asset Name}> + + + Asset Description}> + + +
+
+ + +
+
+
+ } + bottom={ +