Make referral links editable: add input field for customization, update share button and QR code to reflect changes dynamically.

This commit is contained in:
MartinBraquet
2026-07-29 16:49:02 +02:00
parent 9e036835bb
commit 0b696ff0b4
5 changed files with 68 additions and 22 deletions

View File

@@ -575,6 +575,7 @@
"languages.en": "Englisch",
"languages.fr": "Französisch",
"media.next": "Weiter",
"media.play_video": "Video abspielen",
"media.previous": "Zurück",
"messages.action.add_reaction": "Reaktion hinzufügen",
"messages.action.delete": "Löschen",
@@ -1360,6 +1361,7 @@
"profiles.loading_slow": "Noch am Starten — gleich geht's los…",
"profiles.title": "Personen",
"profiles.try_keyword_search": "Schlüsselwortsuche versuchen",
"referrals.link_label": "Dein Empfehlungslink",
"referrals.title": "Lade jemanden ein, Compass beizutreten!",
"register.agreement.and": " und ",
"register.agreement.prefix": "Mit der Registrierung akzeptiere ich die ",

View File

@@ -575,6 +575,7 @@
"languages.en": "Anglais",
"languages.fr": "Français",
"media.next": "Suivant",
"media.play_video": "Lire la vidéo",
"media.previous": "Précédent",
"messages.action.add_reaction": "Ajouter une réaction",
"messages.action.delete": "Supprimer",
@@ -1359,6 +1360,7 @@
"profiles.loading_slow": "Démarrage en cours — encore quelques secondes…",
"profiles.title": "Personnes",
"profiles.try_keyword_search": "Essayer une recherche par mot-clé",
"referrals.link_label": "Votre lien de parrainage",
"referrals.title": "Invitez quelqu'un à rejoindre Compass !",
"register.agreement.and": " et ",
"register.agreement.prefix": "En vous inscrivant, j'accepte les ",

View File

@@ -2,9 +2,12 @@ import clsx from 'clsx'
import Video from 'common/util/tiptap-video'
import {useState} from 'react'
import {MediaModal} from 'web/components/media-modal'
import {firstFrameSrc, VideoThumbnail} from 'web/components/widgets/video-thumbnail'
export const BasicVideo = Video.extend({
renderReact: (attrs: any) => <video src={attrs.src} controls preload="metadata" />,
// Controls are their own play affordance here; the fragment is what keeps the frame behind them
// from being blank.
renderReact: (attrs: any) => <video src={firstFrameSrc(attrs.src)} controls preload="metadata" />,
})
export const DisplayVideo = Video.extend({
@@ -21,16 +24,10 @@ function ExpandingVideo(props: {src: string; size?: 'md'}) {
return (
<>
<video
{...rest}
muted
playsInline
preload="metadata"
<VideoThumbnail
src={rest.src}
onClick={() => setExpanded(true)}
className={clsx(
'cursor-pointer object-contain',
size === 'md' ? 'max-h-[400px]' : 'h-[128px]',
)}
className={clsx(size === 'md' ? 'max-h-[400px]' : 'h-[128px]')}
/>
<MediaModal url={rest.src} open={expanded} setOpen={setExpanded} />
</>

View File

@@ -31,6 +31,7 @@ import {DisplayMention} from '../editor/user-mention/mention-extension'
import {BasicVideo, DisplayVideo} from '../editor/video'
import {Linkify} from './linkify'
import {linkClass} from './site-link'
import {VideoThumbnail} from './video-thumbnail'
const DisplayLink = Link.extend({
renderHTML({HTMLAttributes}) {
@@ -496,20 +497,12 @@ function recurse(
)
case 'video':
return (
<button
<VideoThumbnail
key={key}
type="button"
src={node.attrs?.src ?? ''}
onClick={() => onMediaClick?.(node.attrs?.src ?? '')}
className="cursor-pointer"
>
<video
src={node.attrs?.src}
muted
playsInline
preload="metadata"
className={size === 'sm' ? 'max-h-32' : size === 'md' ? 'max-h-64' : undefined}
/>
</button>
className={size === 'sm' ? 'max-h-32' : size === 'md' ? 'max-h-64' : undefined}
/>
)
case 'table':
return (

View File

@@ -0,0 +1,52 @@
import {PlayIcon} from '@heroicons/react/24/solid'
import clsx from 'clsx'
import {useT} from 'web/lib/locale'
/**
* A video shown as a still cover frame with a play affordance.
*
* Nothing plays in place here, so the element paints only what the browser has decoded:
* `preload="metadata"` on its own leaves Chrome and the Android WebView with an empty grey box.
* The `#t=` media fragment asks for the frame at that timestamp, and the seek on loadedmetadata
* covers the browsers that load the metadata but ignore the fragment.
*/
export function VideoThumbnail(props: {src: string; className?: string; onClick?: () => void}) {
const {src, className, onClick} = props
const t = useT()
return (
<button
type="button"
onClick={onClick}
aria-label={t('media.play_video', 'Play video')}
className="relative inline-block cursor-pointer align-middle leading-none"
>
<video
src={firstFrameSrc(src)}
muted
playsInline
preload="metadata"
onLoadedMetadata={(e) => {
const el = e.currentTarget
// Only nudge a video parked at the very start: a fragment the browser did honour has
// already moved it, and re-seeking would throw that frame away.
if (el.currentTime === 0) el.currentTime = FIRST_FRAME_TIME
}}
className={clsx('object-contain', className)}
/>
<span className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-black/50 ring-1 ring-white/50">
<PlayIcon className="ml-0.5 h-5 w-5 text-white" />
</span>
</span>
</button>
)
}
/** Not 0: a request for exactly the start is what a browser showing a blank poster already did. */
const FIRST_FRAME_TIME = 0.001
/** Media fragment asking the browser to show the opening frame rather than an empty box. */
export function firstFrameSrc(src: string) {
return src.includes('#') ? src : `${src}#t=${FIRST_FRAME_TIME}`
}