forked from Kieran/zap.stream
feat: display media URLs on click
This commit is contained in:
parent
7a6e43d638
commit
a11eeef698
20
src/element/collapsible.css
Normal file
20
src/element/collapsible.css
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
.collapsible-media {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-media a {
|
||||||
|
color: var(--text-link);
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsible-media img,
|
||||||
|
.collapsible-media video {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-preview {
|
||||||
|
color: var(--text-link);
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
32
src/element/collapsible.tsx
Normal file
32
src/element/collapsible.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import "./collapsible.css";
|
||||||
|
import * as Dialog from "@radix-ui/react-dialog";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { ExternalLink } from "element/external-link";
|
||||||
|
|
||||||
|
interface MediaURLProps {
|
||||||
|
url: URL;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MediaURL({ url, children }: MediaURLProps) {
|
||||||
|
const preview = <span className="url-preview">{url.toString()}</span>;
|
||||||
|
return (
|
||||||
|
<Dialog.Root>
|
||||||
|
<Dialog.Trigger asChild>{preview}</Dialog.Trigger>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="dialog-overlay" />
|
||||||
|
<Dialog.Content className="dialog-content">
|
||||||
|
<div className="collapsible-media">
|
||||||
|
<ExternalLink href={url.toString()}>{url.toString()}</ExternalLink>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<button className="btn delete-button" aria-label="Close">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</Dialog.Close>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
);
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { NostrLink } from "element/nostr-link";
|
import { NostrLink } from "element/nostr-link";
|
||||||
|
import { MediaURL } from "element/collapsible";
|
||||||
|
|
||||||
const FileExtensionRegex = /\.([\w]+)$/i;
|
const FileExtensionRegex = /\.([\w]+)$/i;
|
||||||
|
|
||||||
@ -23,17 +24,23 @@ export function HyperText({ link, children }: HyperTextProps) {
|
|||||||
case "bmp":
|
case "bmp":
|
||||||
case "webp": {
|
case "webp": {
|
||||||
return (
|
return (
|
||||||
<img
|
<MediaURL url={url}>
|
||||||
src={url.toString()}
|
<img
|
||||||
alt={url.toString()}
|
src={url.toString()}
|
||||||
style={{ objectFit: "contain" }}
|
alt={url.toString()}
|
||||||
/>
|
style={{ objectFit: "contain" }}
|
||||||
|
/>
|
||||||
|
</MediaURL>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case "wav":
|
case "wav":
|
||||||
case "mp3":
|
case "mp3":
|
||||||
case "ogg": {
|
case "ogg": {
|
||||||
return <audio key={url.toString()} src={url.toString()} controls />;
|
return (
|
||||||
|
<MediaURL url={url}>
|
||||||
|
<audio key={url.toString()} src={url.toString()} controls />;
|
||||||
|
</MediaURL>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
case "mp4":
|
case "mp4":
|
||||||
case "mov":
|
case "mov":
|
||||||
@ -41,7 +48,11 @@ export function HyperText({ link, children }: HyperTextProps) {
|
|||||||
case "avi":
|
case "avi":
|
||||||
case "m4v":
|
case "m4v":
|
||||||
case "webm": {
|
case "webm": {
|
||||||
return <video key={url.toString()} src={url.toString()} controls />;
|
return (
|
||||||
|
<MediaURL url={url}>
|
||||||
|
<video key={url.toString()} src={url.toString()} controls />
|
||||||
|
</MediaURL>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return <a href={url.toString()}>{children || url.toString()}</a>;
|
return <a href={url.toString()}>{children || url.toString()}</a>;
|
||||||
|
@ -25,3 +25,9 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.markdown video {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
@ -30,6 +30,9 @@ export function Markdown({ content, tags = [] }: MarkdownProps) {
|
|||||||
td: ({ children }: ComponentProps) => {
|
td: ({ children }: ComponentProps) => {
|
||||||
return children && <td>{transformText(children, tags)}</td>;
|
return children && <td>{transformText(children, tags)}</td>;
|
||||||
},
|
},
|
||||||
|
th: ({ children }: ComponentProps) => {
|
||||||
|
return children && <th>{transformText(children, tags)}</th>;
|
||||||
|
},
|
||||||
p: ({ children }: ComponentProps) => {
|
p: ({ children }: ComponentProps) => {
|
||||||
return children && <p>{transformText(children, tags)}</p>;
|
return children && <p>{transformText(children, tags)}</p>;
|
||||||
},
|
},
|
||||||
|
@ -4,7 +4,9 @@ import { Link } from "react-router-dom";
|
|||||||
import { useUserProfile } from "@snort/system-react";
|
import { useUserProfile } from "@snort/system-react";
|
||||||
import { UserMetadata } from "@snort/system";
|
import { UserMetadata } from "@snort/system";
|
||||||
import { hexToBech32 } from "@snort/shared";
|
import { hexToBech32 } from "@snort/shared";
|
||||||
|
|
||||||
import { Icon } from "element/icon";
|
import { Icon } from "element/icon";
|
||||||
|
import usePlaceholder from "hooks/placeholders";
|
||||||
import { System } from "index";
|
import { System } from "index";
|
||||||
import { useInView } from "react-intersection-observer";
|
import { useInView } from "react-intersection-observer";
|
||||||
|
|
||||||
@ -47,6 +49,7 @@ export function Profile({
|
|||||||
useUserProfile(System, inView && !profile ? pubkey : undefined) || profile;
|
useUserProfile(System, inView && !profile ? pubkey : undefined) || profile;
|
||||||
const showAvatar = options?.showAvatar ?? true;
|
const showAvatar = options?.showAvatar ?? true;
|
||||||
const showName = options?.showName ?? true;
|
const showName = options?.showName ?? true;
|
||||||
|
const placeholder = usePlaceholder(pubkey);
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
@ -57,7 +60,7 @@ export function Profile({
|
|||||||
<img
|
<img
|
||||||
alt={pLoaded?.name || pubkey}
|
alt={pLoaded?.name || pubkey}
|
||||||
className={avatarClassname ? avatarClassname : ""}
|
className={avatarClassname ? avatarClassname : ""}
|
||||||
src={pLoaded?.picture ?? ""}
|
src={pLoaded?.picture ?? placeholder}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{icon}
|
{icon}
|
||||||
|
@ -4,3 +4,10 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text video {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
@ -9,6 +9,7 @@ import { Mention } from "element/mention";
|
|||||||
import { Emoji } from "element/emoji";
|
import { Emoji } from "element/emoji";
|
||||||
import { HyperText } from "element/hypertext";
|
import { HyperText } from "element/hypertext";
|
||||||
import { splitByUrl } from "utils";
|
import { splitByUrl } from "utils";
|
||||||
|
import type { Tags } from "types";
|
||||||
|
|
||||||
export type Fragment = string | ReactNode;
|
export type Fragment = string | ReactNode;
|
||||||
|
|
||||||
@ -188,7 +189,12 @@ export function transformText(ps: Fragment[], tags: Array<string[]>) {
|
|||||||
return fragments;
|
return fragments;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Text({ content, tags }: { content: string; tags: string[][] }) {
|
interface TextProps {
|
||||||
|
content: string;
|
||||||
|
tags: Tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Text({ content, tags }: TextProps) {
|
||||||
// todo: RTL langugage support
|
// todo: RTL langugage support
|
||||||
const element = useMemo(() => {
|
const element = useMemo(() => {
|
||||||
return <span className="text">{transformText([content], tags)}</span>;
|
return <span className="text">{transformText([content], tags)}</span>;
|
||||||
|
9
src/hooks/placeholders.ts
Normal file
9
src/hooks/placeholders.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
export default function usePlaceholder(pubkey: string) {
|
||||||
|
const url = useMemo(
|
||||||
|
() => `https://robohash.v0l.io/${pubkey}.png?set=2`,
|
||||||
|
[pubkey]
|
||||||
|
);
|
||||||
|
return url;
|
||||||
|
}
|
@ -254,6 +254,13 @@ div.paper {
|
|||||||
margin: 6px;
|
margin: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dialog-trigger {
|
||||||
|
font-size: 15px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
.ctx-menu {
|
.ctx-menu {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
@ -57,11 +57,18 @@
|
|||||||
.profile-page .profile-actions {
|
.profile-page .profile-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
align-items: flex-start;
|
||||||
|
gap: 4px;
|
||||||
top: 12px;
|
top: 12px;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (min-width: 480px) {
|
||||||
|
.profile-page .profile-actions {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.profile-page .profile-information {
|
.profile-page .profile-information {
|
||||||
margin: 12px;
|
margin: 12px;
|
||||||
margin-left: 16px;
|
margin-left: 16px;
|
||||||
@ -87,22 +94,11 @@
|
|||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-page .icon-button {
|
.profile-page .zap-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-page .icon-button span {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 480px) {
|
|
||||||
.profile-page .icon-button span {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-page .zap-button-icon {
|
.profile-page .zap-button-icon {
|
||||||
color: #171717;
|
color: #171717;
|
||||||
}
|
}
|
||||||
@ -234,3 +230,19 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-page .live-button span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.profile-page .zap-button span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 480px) {
|
||||||
|
.profile-page .zap-button span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.profile-page .live-button span {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -18,6 +18,7 @@ import { FollowButton } from "element/follow-button";
|
|||||||
import { MuteButton } from "element/mute-button";
|
import { MuteButton } from "element/mute-button";
|
||||||
import { useProfile } from "hooks/profile";
|
import { useProfile } from "hooks/profile";
|
||||||
import useTopZappers from "hooks/top-zappers";
|
import useTopZappers from "hooks/top-zappers";
|
||||||
|
import usePlaceholder from "hooks/placeholders";
|
||||||
import { Text } from "element/text";
|
import { Text } from "element/text";
|
||||||
import { StreamState, System } from "index";
|
import { StreamState, System } from "index";
|
||||||
import { findTag } from "utils";
|
import { findTag } from "utils";
|
||||||
@ -52,6 +53,7 @@ export function ProfilePage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const link = parseNostrLink(params.npub!);
|
const link = parseNostrLink(params.npub!);
|
||||||
|
const placeholder = usePlaceholder(link.id);
|
||||||
const profile = useUserProfile(System, link.id);
|
const profile = useUserProfile(System, link.id);
|
||||||
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
const zapTarget = profile?.lud16 ?? profile?.lud06;
|
||||||
const { streams, zaps } = useProfile(link, true);
|
const { streams, zaps } = useProfile(link, true);
|
||||||
@ -91,16 +93,22 @@ export function ProfilePage() {
|
|||||||
src={profile?.banner || defaultBanner}
|
src={profile?.banner || defaultBanner}
|
||||||
/>
|
/>
|
||||||
<div className="profile-content">
|
<div className="profile-content">
|
||||||
{profile?.picture && (
|
{profile?.picture ? (
|
||||||
<img
|
<img
|
||||||
className="avatar"
|
className="avatar"
|
||||||
alt={profile.name || link.id}
|
alt={profile.name || link.id}
|
||||||
src={profile.picture}
|
src={profile.picture}
|
||||||
/>
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
className="avatar"
|
||||||
|
alt={profile?.name || link.id}
|
||||||
|
src={placeholder}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="status-indicator">
|
<div className="status-indicator">
|
||||||
{isLive ? (
|
{isLive ? (
|
||||||
<div className="icon-button pill live" onClick={goToLive}>
|
<div className="live-button pill live" onClick={goToLive}>
|
||||||
<Icon name="signal" />
|
<Icon name="signal" />
|
||||||
<span>live</span>
|
<span>live</span>
|
||||||
</div>
|
</div>
|
||||||
@ -122,9 +130,9 @@ export function ProfilePage() {
|
|||||||
lnurl={zapTarget}
|
lnurl={zapTarget}
|
||||||
button={
|
button={
|
||||||
<button className="btn">
|
<button className="btn">
|
||||||
<div className="icon-button">
|
<div className="zap-button">
|
||||||
<span>Zap</span>
|
|
||||||
<Icon name="zap-filled" className="zap-button-icon" />
|
<Icon name="zap-filled" className="zap-button-icon" />
|
||||||
|
<span>Zap</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user