Compare commits

..
Author SHA1 Message Date
bflatastic 32248cb540 scroll caret into view 2026-04-04 05:16:12 +07:00
bflatastic 3f93fa835e height toggles to work 2026-04-04 05:07:30 +07:00
bflatastic 04a5637dc0 mentions text area 2026-04-04 04:51:36 +07:00
Brandon Thomas b4e1e3ea57 marketing tracking (#1297) 2026-04-03 15:06:38 -04:00
bflatastic 71fd694368 maybe_landing_url signup params 2026-04-03 23:51:11 +07:00
bflatastic f2c386dcb9 Merge branch 'main' of https://github.com/storytold/storyteller-rust 2026-04-03 23:39:36 +07:00
bflatastic ffb8cbe433 landing videos updates 2026-04-03 23:39:09 +07:00
Brandon Thomas 5f062c4512 new optional signup params 2026-04-03 12:37:33 -04:00
Brandon Thomas 2442cda22f bump ArtCraft 0.21 -> 0.22 (#1296) 2026-04-03 11:36:17 -04:00
Brandon Thomas 0a534921ce fix mac build 2026-04-03 11:34:46 -04:00
Brandon Thomas 1fecc4405c Bump ArtCraft 0.20 -> 0.21 (#1295) 2026-04-03 11:05:47 -04:00
bflatastic cbca7e3cb4 confirm before deleting character alert dialog 2026-04-03 15:28:40 +07:00
BFlat d107b78498 characters (#1280)
- Create, list, delete characters for use as reference with seedance 2.0
- Characters are mentionable in the prompt box by using the @ sign
2026-04-03 15:19:41 +07:00
26 changed files with 918 additions and 346 deletions
@@ -1,12 +1,12 @@
{
"db_name": "MySQL",
"query": "\nINSERT INTO users\nSET\n token = ?,\n \n is_temporary = ?,\n \n username = ?,\n display_name = ?,\n\n username_is_generated = ?,\n username_is_not_customized = ?,\n\n email_address = ?,\n email_gravatar_hash = ?,\n\n email_confirmed = FALSE,\n email_confirmed_by_google = ?,\n email_is_synthetic = ?,\n \n was_eagerly_provisioned = ?,\n\n profile_markdown = ?,\n profile_rendered_html = ?,\n user_role_slug = ?,\n\n password_hash = ?,\n is_without_password = ?,\n\n maybe_feature_flags = ?,\n\n ip_address_creation = ?,\n ip_address_last_login = ?,\n ip_address_last_update = ?,\n\n maybe_source = ?,\n\n maybe_signup_method = ?,\n\n maybe_referral_url = ?\n ",
"query": "\nINSERT INTO users\nSET\n token = ?,\n \n is_temporary = ?,\n \n username = ?,\n display_name = ?,\n\n username_is_generated = ?,\n username_is_not_customized = ?,\n\n email_address = ?,\n email_gravatar_hash = ?,\n\n email_confirmed = FALSE,\n email_confirmed_by_google = ?,\n email_is_synthetic = ?,\n \n was_eagerly_provisioned = ?,\n\n profile_markdown = ?,\n profile_rendered_html = ?,\n user_role_slug = ?,\n\n password_hash = ?,\n is_without_password = ?,\n\n maybe_feature_flags = ?,\n\n ip_address_creation = ?,\n ip_address_last_login = ?,\n ip_address_last_update = ?,\n\n maybe_source = ?,\n\n maybe_signup_method = ?,\n\n maybe_referral_url = ?,\n maybe_landing_url = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 23
"Right": 24
},
"nullable": []
},
"hash": "08996721482fe897c2721298c04cf0eba5fcf1d04d57397b8afab3dd59feaa8c"
"hash": "3aacc0d270a9073ee5a38595cc32ab7793e0e8b9ce3ad81f554e7198fd0fd509"
}
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
DROP COLUMN maybe_landing_url;
@@ -0,0 +1,6 @@
-- noinspection SqlDialectInspectionForFile
-- noinspection SqlNoDataSourceInspectionForFile
-- noinspection SqlResolveForFile
ALTER TABLE users
ADD COLUMN maybe_landing_url VARCHAR(255) DEFAULT NULL;
@@ -222,8 +222,13 @@ CREATE TABLE users (
was_eagerly_provisioned BOOLEAN NOT NULL DEFAULT false,
-- The referral URL the user arrived from when signing up.
-- This helps us know what marketing is effective.
maybe_referral_url VARCHAR(255) DEFAULT NULL,
-- The actual URL the user first arrived at when signing up, including query params.
-- This helps us know what marketing is effective.
maybe_landing_url VARCHAR(255) DEFAULT NULL,
-- ========== MODERATION DETAILS ==========
-- Different than deleted.
@@ -12,10 +12,14 @@ pub struct StripeArtcraftCreateSubscriptionCheckoutWithUserSignupRequest {
pub cadence: Option<PlanBillingCadence>,
/// Optional: The referral URL the user arrived from when signing up.
/// Optional: The referral URL the user arrived from when first hitting the site, prior to navigation and signing up.
/// The browser can send `document.referrer` to the backend so we know how people are finding us.
/// If the browser doesn't send this parameter, we'll try the `referer` header.
pub maybe_referral_url: Option<String>,
/// Optional: The URL where the user landed when they first arrived, prior to navigation and signing up.
/// The browser can send `window.location.href` to the backend so we know how people are finding us.
pub maybe_landing_url: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema, Debug, Copy, Clone)]
@@ -63,6 +63,7 @@ async fn seed_user(
ip_address: "127.0.0.1",
maybe_source: None,
maybe_referral_url: None,
maybe_landing_url: None,
maybe_user_token: maybe_user_token.as_ref(),
}).await.map_err(|err| anyhow!("err: {:?}", err))?;
+1 -1
View File
@@ -1,4 +1,4 @@
/// The version of the ArtCraft desktop app reported in the UI and via Analytics.
/// This should match the CI builds.
pub const ARTCRAFT_VERSION: &str = "0.20.0";
pub const ARTCRAFT_VERSION: &str = "0.22.0";
+6 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.20.0",
"version": "0.22.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
@@ -14,6 +14,10 @@
"windows": [
],
"security": {
"assetProtocol": {
"enable": true,
"scope": ["**"]
},
"csp": {
"default-src": [
"'self'",
@@ -32,6 +36,7 @@
"'self'",
"'unsafe-eval'",
"'unsafe-inline'",
"asset:",
"blob:",
"data:",
"http://ipc.localhost",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0-rc",
"productName": "ArtCraft",
"version": "0.20.0",
"version": "0.22.0",
"identifier": "ai.artcraft.app",
"build": {
"frontendDist": "../../../frontend/apps/artcraft/dist",
@@ -25,6 +25,10 @@ pub struct CreateAccountFromEmailPasswordArgs<'a> {
/// If the browser doesn't send this parameter, we'll try the `referer` header.
pub maybe_referral_url: Option<String>,
/// The URL where the user landed when they first arrived, prior to navigation and signing up.
/// We can grab this from `window.location.href`.
pub maybe_landing_url: Option<String>,
/// In production code, send this as `None`.
/// Only provide an external user token for db integration tests and db seeding tools.
/// This allows for knowing the user token a priori.
@@ -68,6 +72,7 @@ pub async fn create_account_from_email_and_password(
is_without_password: false,
maybe_referral_url: args.maybe_referral_url,
maybe_landing_url: args.maybe_landing_url,
// NB: This is just for testing.
maybe_user_token: args.maybe_user_token,
@@ -59,6 +59,7 @@ pub async fn create_account_from_google_sso(
// TODO: Necessary? We're not doing SSO for now.
maybe_referral_url: None,
maybe_landing_url: None,
// NB: This is just for testing.
maybe_user_token: None,
@@ -27,6 +27,7 @@ pub struct CreateAccountFromStripeCheckoutArgs<'a> {
pub ip_address: &'a str,
pub maybe_source: Option<UserSignupSource>,
pub maybe_referral_url: Option<String>,
pub maybe_landing_url: Option<String>,
/// Comma separated string of feature flags.
pub maybe_feature_flags: Option<&'a str>,
@@ -70,6 +71,7 @@ pub async fn create_account_from_stripe_checkout(
is_without_password: true,
maybe_referral_url: args.maybe_referral_url,
maybe_landing_url: args.maybe_landing_url,
// NB: This is just for testing.
maybe_user_token: None,
@@ -65,6 +65,10 @@ pub struct GenericCreateAccountArgs<'a> {
/// If the browser doesn't send this parameter, we'll try the `referer` header.
pub maybe_referral_url: Option<String>,
/// The URL where the user landed when they first arrived, prior to navigation and signing up.
/// The browser can send `window.location.href` to the backend so we know how people are finding us.
pub maybe_landing_url: Option<String>,
/// In production code, send this as `None`.
/// Only provide an external user token for db integration tests and db seeding tools.
/// This allows for knowing the user token a priori.
@@ -86,6 +90,9 @@ pub async fn create_account_generic(
const INITIAL_PROFILE_RENDERED_HTML : &str = "";
const INITIAL_USER_ROLE: &str = "user";
let maybe_referral_url = sanitize_optional_url(args.maybe_referral_url);
let maybe_landing_url = sanitize_optional_url(args.maybe_landing_url);
let user_token = match args.maybe_user_token {
None => UserToken::generate(),
Some(user_token) => user_token.clone(),
@@ -131,7 +138,8 @@ SET
maybe_signup_method = ?,
maybe_referral_url = ?
maybe_referral_url = ?,
maybe_landing_url = ?
"#,
user_token.as_str(),
@@ -167,7 +175,8 @@ SET
args.maybe_source.map(|s| s.to_str()),
args.maybe_signup_method.map(|m| m.to_str()),
args.maybe_referral_url,
maybe_referral_url,
maybe_landing_url,
);
@@ -207,3 +216,11 @@ SET
user_id: record_id,
})
}
/// Trim, reject empty strings, and truncate to 255 characters.
fn sanitize_optional_url(value: Option<String>) -> Option<String> {
value
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(|s| s.chars().take(255).collect())
}
@@ -88,9 +88,9 @@ pub async fn stripe_artcraft_create_subscription_checkout_with_user_signup_handl
http_request.headers().get("referer")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
})
.filter(|s| !s.is_empty())
.map(|s| s.chars().take(255).collect::<String>());
});
let maybe_landing_url = request.maybe_landing_url.clone();
let maybe_user_metadata = internal_user_lookup
.lookup_user_from_http_request_and_mysql_connection(&http_request, &mut mysql_connection)
@@ -109,6 +109,7 @@ pub async fn stripe_artcraft_create_subscription_checkout_with_user_signup_handl
&mut mysql_connection,
&stripe_config,
maybe_referral_url,
maybe_landing_url,
).await?
},
Some(user_metadata) => {
@@ -29,6 +29,7 @@ pub (super) async fn user_creation_case(
mysql_connection: &mut PoolConnection<MySql>,
stripe_config: &ArtcraftStripeConfigWithClient,
maybe_referral_url: Option<String>,
maybe_landing_url: Option<String>,
) -> Result<CreationPayload, CommonWebError> {
let mut transaction = mysql_connection.begin()
@@ -84,6 +85,7 @@ pub (super) async fn user_creation_case(
ip_address: &ip_address,
maybe_source,
maybe_referral_url: maybe_referral_url.clone(),
maybe_landing_url: maybe_landing_url.clone(),
},
Transactor::for_transaction(&mut transaction),
).await;
@@ -42,10 +42,14 @@ pub struct CreateAccountRequest {
/// If not provided, we try to infer it from the Origin header instead.
pub signup_source: Option<UserSignupSource>,
/// Optional: The referral URL the user arrived from when signing up.
/// Optional: The referral URL the user arrived from when first hitting the site, prior to navigation and signing up.
/// The browser can send `document.referrer` to the backend so we know how people are finding us.
/// If the browser doesn't send this parameter, we'll try the `referer` header.
pub maybe_referral_url: Option<String>,
/// Optional: The URL where the user landed when they first arrived, prior to navigation and signing up.
/// The browser can send `window.location.href` to the backend so we know how people are finding us.
pub maybe_landing_url: Option<String>,
}
#[derive(ToSchema, Serialize)]
@@ -205,9 +209,9 @@ pub async fn create_account_handler(
http_request.headers().get("referer")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
})
.filter(|s| !s.is_empty())
.map(|s| s.chars().take(255).collect::<String>());
});
let maybe_landing_url = request.maybe_landing_url.clone();
let create_account_result = create_account_from_email_and_password(
&mysql_pool,
@@ -220,6 +224,7 @@ pub async fn create_account_handler(
ip_address: &ip_address,
maybe_source,
maybe_referral_url,
maybe_landing_url,
maybe_user_token: None, // NB: This parameter is for internal testing only
}
).await;
@@ -33,6 +33,7 @@ export function GoogleLoginButton({
const result = await api.GoogleSSO({
credential: credentialResponse.credential,
maybeReferralUrl: (window as any).cached_referrer,
maybeLandingUrl: (window as any).cached_landing_url,
});
if (result.success) {
window.dispatchEvent(new Event("auth-change"));
@@ -58,6 +58,7 @@ export const SignupForm = ({
passwordConfirmation: password,
signupSource,
maybeReferralUrl: (window as any).cached_referrer,
maybeLandingUrl: (window as any).cached_landing_url,
});
setIsLoading(false);
@@ -193,6 +193,7 @@ const PricingTable = ({
plan: apiPlanSlug,
cadence: cadence,
maybeReferralUrl: (window as any).cached_referrer,
maybeLandingUrl: (window as any).cached_landing_url,
});
if (!response.success || !response.data) {
@@ -26,6 +26,9 @@ if (import.meta.env.DEV) {
if (!(window as any).cached_referrer) {
(window as any).cached_referrer = document.referrer || undefined;
}
if (!(window as any).cached_landing_url) {
(window as any).cached_landing_url = window.location.href || undefined;
}
// Fire-and-forget: log the referral once per browser session
if (!sessionStorage.getItem("referral_logged")) {
@@ -3,8 +3,6 @@ import { Button } from "@storyteller/ui-button";
import { isMobile } from "react-device-detect";
import { faGithub } from "@fortawesome/free-brands-svg-icons";
import {
faVolumeMute,
faVolumeHigh,
faFilm,
faPaintBrush,
faCamera,
@@ -23,39 +21,30 @@ import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import ModelBadgeGrid from "../../components/model-badge-grid";
import Seo from "../../components/seo";
import { OwnershipComparison } from "../../components/ownership-comparison/ownership-comparison";
import { DownloadModal } from "../../components/download-modal";
import { UsersApi } from "@storyteller/api";
import { LandingActionButtons } from "../../components/landing-action-buttons";
gsap.registerPlugin(ScrollTrigger);
//const HERO_VIDEO_URL = "https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/artcraft_commercial.mp4";
const HERO_VIDEO_URL =
"https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/artcraft_website_v2.mp4";
const Landing = () => {
const videos = [
{
src: "https://www.youtube.com/embed/HDdsKJl92H4?si=0Hm4AweSRHq3qRt6",
},
{
src: "https://www.youtube.com/embed/oqoCWdOwr2U?si=ILMPk8hGHo9hP8RU",
},
{
src: "https://www.youtube.com/embed/H4NFXGMuwpY?si=wPuQl5cJOu1v8MJu",
},
{
src: "https://www.youtube.com/embed/7x7IZkHiGD8?si=tL8nK4CULigpfHQR",
},
];
const [isMuted, setIsMuted] = useState(true);
const [isHovering, setIsHovering] = useState(false);
const [activeFeature, setActiveFeature] = useState(0);
const [activeVideo, setActiveVideo] = useState<number | null>(null);
const [showDownloadModal, setShowDownloadModal] = useState(false);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const checkSession = async () => {
@@ -230,16 +219,6 @@ const Landing = () => {
}
}, [activeFeature]);
useEffect(() => {
if (videoRef.current) {
videoRef.current.muted = isMuted;
videoRef.current.volume = 0.1;
}
}, [isMuted]);
const toggleMute = () => {
setIsMuted(!isMuted);
};
return (
<div
@@ -315,39 +294,17 @@ const Landing = () => {
data-animate
>
<div
className="relative rounded-[20px] sm:rounded-[32px] overflow-hidden bg-white/10 backdrop-blur-md md:backdrop-blur-xl shadow-2xl p-2 sm:p-4 aspect-[16/9] w-full mx-auto transition-transform duration-300 transform-gpu"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
className="relative rounded-[20px] sm:rounded-[32px] overflow-hidden bg-white/10 backdrop-blur-md md:backdrop-blur-xl shadow-2xl p-2 sm:p-4 w-full mx-auto transition-transform duration-300 transform-gpu"
>
<video
ref={videoRef}
autoPlay
loop
playsInline
muted
preload="none"
poster="/images/hero-poster.jpg"
className="w-full h-full object-cover rounded-2xl bg-white"
src={HERO_VIDEO_URL}
>
<source src={HERO_VIDEO_URL} type="video/mp4" />
Your browser does not support the video tag.
</video>
<button
onClick={toggleMute}
className={`absolute top-4 right-4 sm:top-6 sm:right-6 z-20 p-2 bg-black bg-opacity-40 rounded-full w-10 h-10 md:w-12 md:h-12 text-white transition-opacity duration-200 hover:bg-opacity-80 text-md md:text-xl
${isMuted
? "opacity-100"
: isHovering
? "opacity-100"
: "opacity-0"
}`}
aria-label={isMuted ? "Unmute video" : "Mute video"}
>
<FontAwesomeIcon
icon={isMuted ? faVolumeMute : faVolumeHigh}
<div className="relative w-full rounded-2xl overflow-hidden" style={{ paddingTop: "56.25%" }}>
<iframe
src="https://player.vimeo.com/video/1179924350?h=8b9b3f0f35&autoplay=1&muted=1&loop=1&background=0&byline=0&portrait=0&title=0"
className="absolute inset-0 w-full h-full rounded-2xl"
allow="autoplay; fullscreen; picture-in-picture"
allowFullScreen
title="Seedance in ArtCraft"
/>
</button>
</div>
</div>
</div>
</div>
@@ -397,10 +354,11 @@ const Landing = () => {
<button
key={index}
onClick={() => setActiveFeature(index)}
className={`snap-center shrink-0 px-5 py-2.5 rounded-full text-sm font-bold border transition-all duration-300 whitespace-nowrap ${activeFeature === index
className={`snap-center shrink-0 px-5 py-2.5 rounded-full text-sm font-bold border transition-all duration-300 whitespace-nowrap ${
activeFeature === index
? "bg-primary border-primary text-white shadow-lg shadow-primary/20"
: "bg-white/5 border-white/10 text-white/60 hover:bg-white/10"
}`}
}`}
>
{feature.title}
</button>
@@ -459,26 +417,29 @@ const Landing = () => {
>
<div className="max-w-lg">
<div
className={`w-12 h-12 mb-6 rounded-2xl flex items-center justify-center text-2xl transition-all duration-500 ${activeFeature === index
className={`w-12 h-12 mb-6 rounded-2xl flex items-center justify-center text-2xl transition-all duration-500 ${
activeFeature === index
? "bg-primary text-white shadow-lg shadow-primary/30 scale-110"
: "bg-white/10 text-white/50"
}`}
}`}
>
<FontAwesomeIcon icon={feature.icon} />
</div>
<h3
className={`text-3xl xl:text-4xl font-bold mb-6 transition-all duration-500 ${activeFeature === index
className={`text-3xl xl:text-4xl font-bold mb-6 transition-all duration-500 ${
activeFeature === index
? "text-white"
: "text-white/40"
}`}
}`}
>
{feature.title}
</h3>
<p
className={`text-xl leading-relaxed transition-all duration-500 ${activeFeature === index
className={`text-xl leading-relaxed transition-all duration-500 ${
activeFeature === index
? "text-white/80"
: "text-white/30"
}`}
}`}
>
{feature.description}
</p>
+4 -1
View File
@@ -35,15 +35,17 @@ export class BillingApi extends ApiManager {
plan,
cadence,
maybeReferralUrl,
maybeLandingUrl,
}: {
plan: string;
cadence: "yearly" | "monthly";
maybeReferralUrl?: string;
maybeLandingUrl?: string;
}): Promise<ApiResponse<{ stripeCheckoutRedirectUrl: string }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/stripe_artcraft/user_signup_subscription_checkout`;
return await this.post<
{ plan: string; cadence: string; maybe_referral_url?: string },
{ plan: string; cadence: string; maybe_referral_url?: string; maybe_landing_url?: string },
{
success: boolean;
stripe_checkout_redirect_url?: string;
@@ -55,6 +57,7 @@ export class BillingApi extends ApiManager {
plan,
cadence,
...(maybeReferralUrl && { maybe_referral_url: maybeReferralUrl }),
...(maybeLandingUrl && { maybe_landing_url: maybeLandingUrl }),
},
})
.then((response) => ({
+12 -1
View File
@@ -9,6 +9,7 @@ interface SignupRequest {
password_confirmation: string;
signup_source?: string;
maybe_referral_url?: string;
maybe_landing_url?: string;
}
export class UsersApi extends ApiManager {
@@ -202,6 +203,7 @@ export class UsersApi extends ApiManager {
passwordConfirmation,
signupSource,
maybeReferralUrl,
maybeLandingUrl,
}: {
username: string;
email: string;
@@ -209,6 +211,7 @@ export class UsersApi extends ApiManager {
passwordConfirmation: string;
signupSource?: string;
maybeReferralUrl?: string;
maybeLandingUrl?: string;
}): Promise<ApiResponse<{ signedSession?: string }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/create_account`;
const body: SignupRequest = {
@@ -223,6 +226,9 @@ export class UsersApi extends ApiManager {
if (maybeReferralUrl) {
body.maybe_referral_url = maybeReferralUrl;
}
if (maybeLandingUrl) {
body.maybe_landing_url = maybeLandingUrl;
}
try {
const response = await this.authFetch<
@@ -259,17 +265,22 @@ export class UsersApi extends ApiManager {
public async GoogleSSO({
credential,
maybeReferralUrl,
maybeLandingUrl,
}: {
credential: string;
maybeReferralUrl?: string;
maybeLandingUrl?: string;
}): Promise<ApiResponse<{ usernameNotYetCustomized?: boolean }>> {
const endpoint = `${this.getApiSchemeAndHost()}/v1/accounts/google_sso`;
const body: { google_credential: string; maybe_referral_url?: string } = {
const body: { google_credential: string; maybe_referral_url?: string; maybe_landing_url?: string } = {
google_credential: credential,
};
if (maybeReferralUrl) {
body.maybe_referral_url = maybeReferralUrl;
}
if (maybeLandingUrl) {
body.maybe_landing_url = maybeLandingUrl;
}
try {
const response = await this.authFetch<
@@ -26,6 +26,11 @@ import { useCharactersStore } from "./promptStore";
import { Input } from "@storyteller/ui-input";
import { Button } from "@storyteller/ui-button";
import { Label } from "@storyteller/ui-label";
import {
showActionReminder,
isActionReminderOpen,
actionReminderProps,
} from "@storyteller/ui-action-reminder-modal";
interface CharactersModalProps {
isOpen: boolean;
@@ -147,7 +152,7 @@ const CharacterListView = ({
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(false);
const [cursor, setCursor] = useState<number | undefined>(undefined);
const [deletingToken, setDeletingToken] = useState<string | null>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const loadingMoreRef = useRef(false);
const storeSetCharacters = useCharactersStore((s) => s.setCharacters);
@@ -246,28 +251,49 @@ const CharacterListView = ({
return () => observer.disconnect();
}, [hasMore, cursor, fetchCharacters]);
const handleDelete = async (character: Character) => {
setDeletingToken(character.token);
try {
const api = new CharactersApi();
const res = await api.DeleteCharacter({
characterToken: character.token,
});
const handleDelete = (character: Character) => {
showActionReminder({
reminderType: "default",
title: "Delete character?",
message: (
<p className="text-sm text-white/70">
This will permanently delete <strong>{character.name}</strong>. This
action cannot be undone.
</p>
),
primaryActionText: "Delete",
primaryActionIcon: faTrashAlt,
secondaryActionText: "Cancel",
primaryActionBtnClassName: "bg-red text-white hover:bg-red/90",
onPrimaryAction: async () => {
if (actionReminderProps.value) {
actionReminderProps.value = {
...actionReminderProps.value,
isLoading: true,
};
}
try {
const api = new CharactersApi();
const res = await api.DeleteCharacter({
characterToken: character.token,
});
if (res.success) {
setCharacters((prev) =>
prev.filter((c) => c.token !== character.token),
);
storeRemoveCharacter(character.token);
toast.success(`Character "${character.name}" deleted`);
} else {
toast.error(res.errorMessage || "Failed to delete character");
}
} catch {
toast.error("Failed to delete character");
} finally {
setDeletingToken(null);
}
if (res.success) {
setCharacters((prev) =>
prev.filter((c) => c.token !== character.token),
);
storeRemoveCharacter(character.token);
toast.success(`Character "${character.name}" deleted`);
} else {
toast.error(res.errorMessage || "Failed to delete character");
}
} catch {
toast.error("Failed to delete character");
} finally {
isActionReminderOpen.value = false;
}
},
});
};
return (
@@ -355,7 +381,6 @@ const CharacterListView = ({
{characters.map((character) => {
const isUserCreated = character.is_user_created !== false;
const isDeleting = deletingToken === character.token;
return (
<div
@@ -407,20 +432,12 @@ const CharacterListView = ({
e.stopPropagation();
handleDelete(character);
}}
disabled={isDeleting}
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-red-500"
>
{isDeleting ? (
<FontAwesomeIcon
icon={faSpinnerThird}
className="text-[10px] animate-spin"
/>
) : (
<FontAwesomeIcon
icon={faTrashAlt}
className="text-[10px]"
/>
)}
<FontAwesomeIcon
icon={faTrashAlt}
className="text-[10px]"
/>
</button>
</div>
)}
@@ -0,0 +1,628 @@
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { twMerge } from "tailwind-merge";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faUser } from "@fortawesome/pro-solid-svg-icons";
import {
faVideo,
faMusic,
} from "@fortawesome/pro-regular-svg-icons";
export interface MentionItem {
label: string;
type: "image" | "video" | "audio" | "character";
preview?: string;
}
interface MentionTextareaProps {
value: string;
onChange: (value: string) => void;
mentionItems: MentionItem[];
placeholder?: string;
className?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
onFocus?: () => void;
onBlur?: () => void;
disabled?: boolean;
colorMap: Record<string, string>;
}
interface MentionState {
isOpen: boolean;
triggerIndex: number;
query: string;
activeIndex: number;
}
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function escapeHTML(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// ---------------------------------------------------------------------------
// Cursor helpers for contentEditable
// ---------------------------------------------------------------------------
function getCaretOffset(el: HTMLElement): number {
const sel = window.getSelection();
if (!sel?.rangeCount || !sel.anchorNode || !el.contains(sel.anchorNode)) {
return 0;
}
const anchorNode = sel.anchorNode;
const anchorOffset = sel.anchorOffset;
let offset = 0;
if (anchorNode === el) {
for (let i = 0; i < anchorOffset; i++) {
const child = el.childNodes[i];
if (child.nodeType === Node.TEXT_NODE) {
offset += child.textContent?.length ?? 0;
} else if (child.nodeName === "BR") {
offset += 1;
} else {
offset += child.textContent?.length ?? 0;
}
}
return offset;
}
function countBefore(parent: Node): boolean {
for (const child of Array.from(parent.childNodes)) {
if (child === anchorNode) {
if (child.nodeType === Node.TEXT_NODE) {
offset += anchorOffset;
}
return true;
}
if (child.contains(anchorNode)) {
return countBefore(child);
}
if (child.nodeType === Node.TEXT_NODE) {
offset += child.textContent?.length ?? 0;
} else if (child.nodeName === "BR") {
offset += 1;
} else {
offset += child.textContent?.length ?? 0;
}
}
return false;
}
countBefore(el);
return offset;
}
function setCaretOffset(el: HTMLElement, offset: number) {
let remaining = offset;
function findPosition(parent: Node): boolean {
for (let i = 0; i < parent.childNodes.length; i++) {
const child = parent.childNodes[i];
if (child.nodeType === Node.TEXT_NODE) {
const len = child.textContent?.length ?? 0;
if (remaining <= len) {
const sel = window.getSelection();
const range = document.createRange();
range.setStart(child, remaining);
range.collapse(true);
sel?.removeAllRanges();
sel?.addRange(range);
return true;
}
remaining -= len;
} else if (child.nodeName === "BR") {
if (remaining === 0) {
const sel = window.getSelection();
const range = document.createRange();
range.setStart(parent, i);
range.collapse(true);
sel?.removeAllRanges();
sel?.addRange(range);
return true;
}
remaining -= 1;
} else if (child.nodeType === Node.ELEMENT_NODE) {
if (findPosition(child)) {
return true;
}
}
}
return false;
}
if (!findPosition(el)) {
const sel = window.getSelection();
if (sel) {
sel.selectAllChildren(el);
sel.collapseToEnd();
}
}
}
/**
* Scroll the contentEditable so the caret is visible.
* Uses a zero-width space span to get a measurable rect on empty lines.
*/
function scrollCaretIntoView(el: HTMLElement) {
const sel = window.getSelection();
if (!sel?.rangeCount || !el.contains(sel.anchorNode)) return;
const range = sel.getRangeAt(0).cloneRange();
range.collapse(false);
const span = document.createElement("span");
span.textContent = "\u200B";
range.insertNode(span);
const spanRect = span.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
if (spanRect.bottom > elRect.bottom) {
el.scrollTop += spanRect.bottom - elRect.bottom;
} else if (spanRect.top < elRect.top) {
el.scrollTop -= elRect.top - spanRect.top;
}
// Remove temp node and restore caret after it
const parent = span.parentNode!;
const next = span.nextSibling;
parent.removeChild(span);
const restored = document.createRange();
if (next) {
restored.setStartBefore(next);
} else {
restored.setStartAfter(parent.lastChild ?? parent);
}
restored.collapse(true);
sel.removeAllRanges();
sel.addRange(restored);
}
// ---------------------------------------------------------------------------
// Mention Dropdown
// ---------------------------------------------------------------------------
function MentionDropdown({
items,
activeIndex,
onSelect,
onHover,
position,
}: {
items: MentionItem[];
activeIndex: number;
onSelect: (item: MentionItem) => void;
onHover: (index: number) => void;
position: { left: number; bottom: number };
}) {
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const list = listRef.current;
if (!list) return;
const activeItem = list.children[1]?.children[activeIndex] as HTMLElement;
activeItem?.scrollIntoView({ block: "nearest" });
}, [activeIndex]);
return (
<div
ref={listRef}
className="absolute z-50 mb-1 w-64 max-h-72 overflow-y-auto rounded-lg border border-white/10 bg-ui-controls shadow-lg backdrop-blur-xl"
style={{ left: position.left, bottom: position.bottom }}
>
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-base-fg/50">
References
</div>
<div>
{items.map((item, i) => (
<button
key={item.label}
type="button"
className={twMerge(
"flex w-full items-center gap-2.5 px-3 py-2 text-sm text-base-fg transition-colors cursor-pointer",
i === activeIndex ? "bg-white/10" : "hover:bg-white/5",
)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(item);
}}
onMouseEnter={() => onHover(i)}
>
<div className="h-8 w-8 flex-shrink-0 overflow-hidden rounded-md border border-white/20 flex items-center justify-center bg-black/20">
{item.type === "character" && item.preview ? (
<img
src={item.preview}
alt={item.label}
className="h-full w-full object-cover"
/>
) : item.type === "character" ? (
<FontAwesomeIcon
icon={faUser}
className="h-3.5 w-3.5 text-base-fg/60"
/>
) : item.type === "image" && item.preview ? (
<img
src={item.preview}
alt={item.label}
className="h-full w-full object-cover"
/>
) : item.type === "video" && item.preview ? (
<video
src={item.preview}
muted
preload="metadata"
className="h-full w-full object-cover"
/>
) : (
<FontAwesomeIcon
icon={item.type === "video" ? faVideo : faMusic}
className="h-3.5 w-3.5 text-base-fg/60"
/>
)}
</div>
<span className="font-medium">{item.label}</span>
</button>
))}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// MentionTextarea Component
// ---------------------------------------------------------------------------
export const MentionTextarea = forwardRef<HTMLDivElement, MentionTextareaProps>(function MentionTextarea({
value,
onChange,
mentionItems,
placeholder,
className,
onKeyDown: externalOnKeyDown,
onFocus,
onBlur,
disabled,
colorMap,
}, ref) {
const editorRef = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => editorRef.current!, []);
const isInternalUpdate = useRef(false);
const isComposing = useRef(false);
const pendingCaret = useRef<number | null>(null);
const [mentionState, setMentionState] = useState<MentionState>({
isOpen: false,
triggerIndex: -1,
query: "",
activeIndex: 0,
});
// Pixel position of the @ trigger relative to the wrapper div
const [dropdownPos, setDropdownPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 });
const filteredItems = useMemo(() => {
if (!mentionState.isOpen) return [];
return mentionItems.filter((item) =>
mentionState.query
? item.label.toLowerCase().includes(mentionState.query.toLowerCase())
: true,
);
}, [mentionItems, mentionState.isOpen, mentionState.query]);
// Build a regex that matches any known mention label (supports spaces in names)
// Sort longest-first so "@Pumpkin Head" matches before "@Pumpkin"
const mentionRegex = useMemo(() => {
const labels = mentionItems.map((item) => item.label);
if (labels.length === 0) return null;
const sorted = [...labels].sort((a, b) => b.length - a.length);
const pattern = sorted.map((l) => escapeRegex(l)).join("|");
return new RegExp(`(${pattern})`, "g");
}, [mentionItems]);
// Build innerHTML with colored @mentions inline
const buildHTML = useCallback(
(text: string): string => {
if (!text) return "";
if (!mentionRegex) {
let html = escapeHTML(text);
html = html.replace(/\n/g, "<br>");
if (html.endsWith("<br>")) html += "<br>";
return html;
}
let html = "";
let lastIndex = 0;
const regex = new RegExp(mentionRegex);
let match: RegExpExecArray | null;
// biome-ignore lint/suspicious/noAssignInExpressions: --
while ((match = regex.exec(text)) !== null) {
const fullMatch = match[0];
const color = colorMap[fullMatch];
if (match.index > lastIndex) {
html += escapeHTML(text.slice(lastIndex, match.index));
}
if (color) {
html += `<span style="color:${color}">${escapeHTML(fullMatch)}</span>`;
} else {
html += escapeHTML(fullMatch);
}
lastIndex = match.index + fullMatch.length;
}
if (lastIndex < text.length) {
html += escapeHTML(text.slice(lastIndex));
}
html = html.replace(/\n/g, "<br>");
if (html.endsWith("<br>")) {
html += "<br>";
}
return html;
},
[colorMap, mentionRegex],
);
// Sync DOM when value changes from parent (not from user input)
useEffect(() => {
if (isInternalUpdate.current) {
isInternalUpdate.current = false;
return;
}
const el = editorRef.current;
if (!el) return;
const caret = pendingCaret.current ?? getCaretOffset(el);
el.innerHTML = buildHTML(value);
if (pendingCaret.current !== null) {
setCaretOffset(el, pendingCaret.current);
pendingCaret.current = null;
} else if (document.activeElement === el) {
setCaretOffset(el, caret);
}
}, [value, buildHTML]);
// Get pixel coordinates of a text offset relative to the wrapper
const getOffsetRect = useCallback((charOffset: number) => {
const el = editorRef.current;
if (!el) return null;
// Temporarily place caret at charOffset to measure position
const saved = getCaretOffset(el);
setCaretOffset(el, charOffset);
const sel = window.getSelection();
if (!sel?.rangeCount) {
setCaretOffset(el, saved);
return null;
}
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
const wrapperRect = el.parentElement!.getBoundingClientRect();
setCaretOffset(el, saved);
return {
left: rect.left - wrapperRect.left,
bottom: wrapperRect.bottom - rect.top,
};
}, []);
// Detect @mention trigger from cursor position
// Supports multi-word names by scanning back to the nearest @
const detectMention = useCallback((text: string, cursorPos: number) => {
// Find the last @ before cursor
const textBefore = text.slice(0, cursorPos);
const lastAt = textBefore.lastIndexOf("@");
if (lastAt !== -1 && (lastAt === 0 || /\s/.test(text[lastAt - 1]))) {
const query = text.slice(lastAt, cursorPos); // includes @
// Only open if there's no newline in the query
if (!query.includes("\n")) {
const pos = getOffsetRect(lastAt);
if (pos) setDropdownPos(pos);
setMentionState({
isOpen: true,
triggerIndex: lastAt,
query,
activeIndex: 0,
});
return;
}
}
setMentionState((prev) =>
prev.isOpen ? { ...prev, isOpen: false } : prev,
);
}, [getOffsetRect]);
// Extract text and re-render with mention styling
const handleInput = useCallback(() => {
if (isComposing.current) return;
const el = editorRef.current;
if (!el) return;
let text = el.innerText;
if (text.endsWith("\n")) {
text = text.slice(0, -1);
}
const caret = getCaretOffset(el);
const html = buildHTML(text);
if (el.innerHTML !== html) {
el.innerHTML = html;
setCaretOffset(el, caret);
}
isInternalUpdate.current = true;
onChange(text);
detectMention(text, caret);
// Keep caret visible when content overflows (contentEditable doesn't auto-scroll)
requestAnimationFrame(() => {
scrollCaretIntoView(el);
});
}, [onChange, buildHTML, detectMention]);
const handleCompositionStart = useCallback(() => {
isComposing.current = true;
}, []);
const handleCompositionEnd = useCallback(() => {
isComposing.current = false;
handleInput();
}, [handleInput]);
// Select a mention from the dropdown
const handleSelect = useCallback(
(item: MentionItem) => {
const el = editorRef.current;
if (!el) return;
const caretPos = getCaretOffset(el);
const before = value.slice(0, mentionState.triggerIndex);
const after = value.slice(caretPos);
const mention = `${item.label} `;
const newValue = before + mention + after;
pendingCaret.current = before.length + mention.length;
setMentionState({
isOpen: false,
triggerIndex: -1,
query: "",
activeIndex: 0,
});
onChange(newValue);
requestAnimationFrame(() => {
el.focus();
});
},
[value, mentionState.triggerIndex, onChange],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (mentionState.isOpen && filteredItems.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setMentionState((prev) => ({
...prev,
activeIndex: Math.min(prev.activeIndex + 1, filteredItems.length - 1),
}));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setMentionState((prev) => ({
...prev,
activeIndex: Math.max(prev.activeIndex - 1, 0),
}));
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
handleSelect(filteredItems[mentionState.activeIndex]);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setMentionState((prev) => ({ ...prev, isOpen: false }));
return;
}
}
// Shift+Enter (or Cmd+Enter on Mac): insert a newline instead of
// letting the contentEditable create a <div>
if (e.key === "Enter" && (e.shiftKey || e.metaKey)) {
e.preventDefault();
document.execCommand("insertLineBreak");
handleInput();
// Scroll caret into view (textarea does this automatically, contentEditable does not)
scrollCaretIntoView(editorRef.current!);
return;
}
externalOnKeyDown?.(e);
},
[
mentionState.isOpen,
mentionState.activeIndex,
filteredItems,
handleSelect,
externalOnKeyDown,
handleInput,
],
);
const handleClick = useCallback(() => {
const el = editorRef.current;
if (el) {
detectMention(value, getCaretOffset(el));
}
}, [value, detectMention]);
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLDivElement>) => {
e.preventDefault();
const text = e.clipboardData.getData("text/plain");
document.execCommand("insertText", false, text);
},
[],
);
return (
<div className="relative flex-1 min-w-0">
{!value && placeholder && (
<div
className={twMerge(
className,
"absolute inset-0 pointer-events-none text-base-fg/60 z-[1]",
)}
>
{placeholder}
</div>
)}
<div
ref={editorRef}
contentEditable={!disabled}
onInput={handleInput}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyDown}
onClick={handleClick}
onPaste={handlePaste}
onFocus={onFocus}
onBlur={onBlur}
className={twMerge(
className,
"outline-none whitespace-pre-wrap break-words overflow-y-auto",
)}
/>
{mentionState.isOpen && filteredItems.length > 0 && (
<MentionDropdown
items={filteredItems}
activeIndex={mentionState.activeIndex}
onSelect={handleSelect}
onHover={(i) =>
setMentionState((prev) => ({ ...prev, activeIndex: i }))
}
position={dropdownPos}
/>
)}
</div>
);
});
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, useMemo } from "react";
import { useSignals } from "@preact/signals-react/runtime";
import { JobContextType } from "@storyteller/common";
import { downloadFileFromUrl } from "@storyteller/api";
@@ -18,11 +18,7 @@ import {
faChevronDown,
faChevronUp,
} from "@fortawesome/pro-solid-svg-icons";
import {
faCircleInfo,
faVideo,
faMusic,
} from "@fortawesome/pro-regular-svg-icons";
import { faCircleInfo } from "@fortawesome/pro-regular-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { GalleryItem, GalleryModal } from "@storyteller/ui-gallery-modal";
import {
@@ -46,7 +42,8 @@ import { toast } from "@storyteller/ui-toaster";
import { GenerationProvider } from "@storyteller/api-enums";
import { CharactersModal } from "./CharactersModal";
import { CharactersApi } from "@storyteller/api";
import { faUser } from "@fortawesome/pro-solid-svg-icons";
import { MentionTextarea } from "./MentionTextarea";
import type { MentionItem } from "./MentionTextarea";
declare global {
interface Window {
@@ -175,8 +172,9 @@ export const PromptBoxVideo = ({
const toggleExpand = () => {
setIsExpanded((prev) => {
const next = !prev;
if (textareaRef.current) {
textareaRef.current.style.height = next ? EXPANDED_HEIGHT : "auto";
const el = (mentionEditorRef.current ?? textareaRef.current) as HTMLElement | null;
if (el) {
el.style.height = next ? EXPANDED_HEIGHT : "auto";
}
return next;
});
@@ -229,17 +227,18 @@ export const PromptBoxVideo = ({
useState<PopoverItem[]>(aspectRatioOptions);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const mentionEditorRef = useRef<HTMLDivElement>(null);
// Apply height constraints to whichever editor element is active
useEffect(() => {
if (textareaRef.current) {
// Hard pixel limit so the resize handle can never exceed viewport
const el = (mentionEditorRef.current ?? textareaRef.current) as HTMLElement | null;
if (el) {
const maxH = isExpanded ? 500 : Math.min(window.innerHeight - 700, 500);
textareaRef.current.style.maxHeight = `${Math.max(maxH, 88)}px`;
textareaRef.current.style.minHeight = "0";
el.style.maxHeight = `${Math.max(maxH, 88)}px`;
el.style.minHeight = "0";
if (!isExpanded) {
// Cap auto-grow at ~5.5em so it doesn't fight with manual resize
const capped = Math.min(textareaRef.current.scrollHeight, 88);
textareaRef.current.style.minHeight = `${capped}px`;
const capped = Math.min(el.scrollHeight, 88);
el.style.minHeight = `${capped}px`;
}
}
});
@@ -391,15 +390,6 @@ export const PromptBoxVideo = ({
? (selectedModel?.maxReferenceImages ?? 3)
: 1;
const highlightRef = useRef<HTMLDivElement>(null);
// Sync scroll between textarea and highlight overlay
const handleScroll = () => {
if (highlightRef.current && textareaRef.current) {
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
}
};
// Color palettes for @-mention highlights
const IMAGE_COLORS = [
"rgb(96, 165, 250)", // blue
@@ -436,78 +426,7 @@ export const PromptBoxVideo = ({
const hasAnyMentionables = hasAnyRefs || activeCharacters.length > 0;
const renderHighlightedPrompt = () => {
if (!hasAnyMentionables) return null;
// Build regex that matches @Image1, @Video1, @Audio1 and @CharacterName
const charPattern = characterNames
.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join("|");
const refPattern = "@(?:Image|Video|Audio)\\d+";
const fullPattern = charPattern
? `(${refPattern}|@(?:${charPattern}))`
: `(${refPattern})`;
const regex = new RegExp(fullPattern, "g");
const parts = prompt.split(regex);
return parts.map((part, i) => {
const imgMatch = part.match(/^@Image(\d+)$/);
if (imgMatch) {
const idx = parseInt(imgMatch[1]) - 1;
return (
<span
key={i}
style={{ color: IMAGE_COLORS[idx % IMAGE_COLORS.length] }}
>
{part}
</span>
);
}
const vidMatch = part.match(/^@Video(\d+)$/);
if (vidMatch) {
const idx = parseInt(vidMatch[1]) - 1;
return (
<span
key={i}
style={{ color: VIDEO_COLORS[idx % VIDEO_COLORS.length] }}
>
{part}
</span>
);
}
const audMatch = part.match(/^@Audio(\d+)$/);
if (audMatch) {
const idx = parseInt(audMatch[1]) - 1;
return (
<span
key={i}
style={{ color: AUDIO_COLORS[idx % AUDIO_COLORS.length] }}
>
{part}
</span>
);
}
// Check character match
if (part.startsWith("@")) {
const charName = part.slice(1);
const charIdx = characterNames.indexOf(charName);
if (charIdx !== -1) {
return (
<span
key={i}
style={{
color: CHARACTER_COLORS[charIdx % CHARACTER_COLORS.length],
}}
>
{part}
</span>
);
}
}
return <span key={i}>{part}</span>;
});
};
// @-mention autocomplete state
// @-mention autocomplete state (for fallback textarea path)
const [mentionOpen, setMentionOpen] = useState(false);
const [mentionFilter, setMentionFilter] = useState("");
const [mentionIndex, setMentionIndex] = useState(0);
@@ -544,6 +463,67 @@ export const PromptBoxVideo = ({
: true,
);
// All mention items (unfiltered) for the contentEditable MentionTextarea
const allMentionItems: MentionItem[] = useMemo(() => [
...(isReferenceMode
? [
...referenceImages.map((img, i) => ({
label: `@Image${i + 1}`,
type: "image" as const,
preview: img.url,
})),
...referenceVideos.map((vid, i) => ({
label: `@Video${i + 1}`,
type: "video" as const,
preview: vid.url,
})),
...referenceAudios.map((_aud, i) => ({
label: `@Audio${i + 1}`,
type: "audio" as const,
preview: undefined as string | undefined,
})),
]
: []),
...activeCharacters.map((char) => ({
label: `@${char.name}`,
type: "character" as const,
preview: char.avatar_image_url,
})),
], [isReferenceMode, referenceImages, referenceVideos, referenceAudios, activeCharacters]);
// Build label → color map for inline mention highlighting
const mentionColorMap = useMemo(() => {
const map: Record<string, string> = {};
for (const item of allMentionItems) {
const imgMatch = item.label.match(/^@Image(\d+)$/);
if (imgMatch) {
const idx = parseInt(imgMatch[1]) - 1;
map[item.label] = IMAGE_COLORS[idx % IMAGE_COLORS.length];
continue;
}
const vidMatch = item.label.match(/^@Video(\d+)$/);
if (vidMatch) {
const idx = parseInt(vidMatch[1]) - 1;
map[item.label] = VIDEO_COLORS[idx % VIDEO_COLORS.length];
continue;
}
const audMatch = item.label.match(/^@Audio(\d+)$/);
if (audMatch) {
const idx = parseInt(audMatch[1]) - 1;
map[item.label] = AUDIO_COLORS[idx % AUDIO_COLORS.length];
continue;
}
if (item.type === "character") {
const charName = item.label.slice(1);
const charIdx = characterNames.indexOf(charName);
if (charIdx !== -1) {
map[item.label] = CHARACTER_COLORS[charIdx % CHARACTER_COLORS.length];
}
}
}
return map;
}, [allMentionItems, characterNames]);
const insertMention = (label: string) => {
const textarea = textareaRef.current;
if (!textarea || mentionAnchorRef.current === null) return;
@@ -913,125 +893,45 @@ export const PromptBoxVideo = ({
)}
>
<div className="relative flex justify-center gap-2">
{/* @-mention autocomplete dropdown */}
{mentionOpen && mentionItems.length > 0 && (
<div className="absolute bottom-full left-0 z-50 mb-1 w-64 max-h-72 overflow-y-auto rounded-lg border border-white/10 bg-ui-controls shadow-lg backdrop-blur-xl">
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-base-fg/50">
References
</div>
{mentionItems.map((item, i) => (
<button
key={item.label}
className={twMerge(
"flex w-full items-center gap-2.5 px-3 py-2 text-sm text-base-fg transition-colors cursor-pointer",
i === mentionIndex ? "bg-white/10" : "hover:bg-white/5",
)}
onMouseDown={(e) => {
e.preventDefault();
insertMention(item.label);
}}
onMouseEnter={() => setMentionIndex(i)}
>
<div className="h-8 w-8 flex-shrink-0 overflow-hidden rounded-md border border-white/20 flex items-center justify-center bg-black/20">
{item.type === "character" && item.preview ? (
<img
src={item.preview}
alt={item.label}
className="h-full w-full object-cover"
/>
) : item.type === "character" ? (
<FontAwesomeIcon
icon={faUser}
className="h-3.5 w-3.5 text-base-fg/60"
/>
) : item.type === "image" && item.preview ? (
<img
src={item.preview}
alt={item.label}
className="h-full w-full object-cover"
/>
) : item.type === "video" && item.preview ? (
<video
src={item.preview}
muted
preload="metadata"
className="h-full w-full object-cover"
/>
) : (
<FontAwesomeIcon
icon={item.type === "video" ? faVideo : faMusic}
className="h-3.5 w-3.5 text-base-fg/60"
/>
)}
</div>
<span className="font-medium">{item.label}</span>
</button>
))}
</div>
)}
{/* Hide the Add image button for video for now */}
{/* <Tooltip
content="Add Image"
position="top"
closeOnClick={true}
className={isImageRowVisible ? "hidden opacity-0" : undefined}
>
<Button
variant="action"
className={`h-8 w-8 p-0 bg-transparent hover:bg-transparent group transition-all ${
isImageRowVisible ? "text-primary" : ""
}`}
onClick={() => setShowImagePrompts((prev) => !prev)}
>
<svg
width="24"
height="20"
viewBox="0 0 24 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="group-hover:opacity-100 opacity-80 transition-all"
>
<path
opacity="1"
d="M2.66667 2H16C16.3667 2 16.6667 2.3 16.6667 2.66667V6.1125C17.1 6.04167 17.5458 6 18 6C18.225 6 18.4458 6.00833 18.6667 6.02917V2.66667C18.6667 1.19583 17.4708 0 16 0H2.66667C1.19583 0 0 1.19583 0 2.66667V16C0 17.4708 1.19583 18.6667 2.66667 18.6667H11.5C11.0625 18.0583 10.7083 17.3875 10.4542 16.6667H2.66667C2.3 16.6667 2 16.3667 2 16V2.66667C2 2.3 2.3 2 2.66667 2ZM11.8625 7.49167C11.6833 7.1875 11.3542 7 11 7C10.6458 7 10.3167 7.1875 10.1375 7.49167L8.2 10.7833L7.48333 9.75833C7.29583 9.49167 6.99167 9.33333 6.6625 9.33333C6.33333 9.33333 6.02917 9.49167 5.84167 9.75833L3.50833 13.0917C3.29583 13.3958 3.26667 13.7958 3.44167 14.125C3.61667 14.4542 3.9625 14.6667 4.33333 14.6667H10.0292C10.0125 14.4458 10 14.225 10 14C10 11.7833 10.9 9.77917 12.3542 8.33333L11.8625 7.49583V7.49167ZM5.33333 6.66667C6.07083 6.66667 6.66667 6.07083 6.66667 5.33333C6.66667 4.59583 6.07083 4 5.33333 4C4.59583 4 4 4.59583 4 5.33333C4 6.07083 4.59583 6.66667 5.33333 6.66667ZM18 20C21.3125 20 24 17.3125 24 14C24 10.6875 21.3125 8 18 8C14.6875 8 12 10.6875 12 14C12 17.3125 14.6875 20 18 20ZM18.6667 11.3333V13.3333H20.6667C21.0333 13.3333 21.3333 13.6333 21.3333 14C21.3333 14.3667 21.0333 14.6667 20.6667 14.6667H18.6667V16.6667C18.6667 17.0333 18.3667 17.3333 18 17.3333C17.6333 17.3333 17.3333 17.0333 17.3333 16.6667V14.6667H15.3333C14.9667 14.6667 14.6667 14.3667 14.6667 14C14.6667 13.6333 14.9667 13.3333 15.3333 13.3333H17.3333V11.3333C17.3333 10.9667 17.6333 10.6667 18 10.6667C18.3667 10.6667 18.6667 10.9667 18.6667 11.3333Z"
fill="currentColor"
/>
</svg>
</Button>
</Tooltip> */}
<div className="promptbox-resize-wrap relative flex-1">
{hasAnyMentionables && (
<div
ref={highlightRef}
aria-hidden
className="text-md pointer-events-none absolute inset-0 overflow-y-auto whitespace-pre-wrap break-words rounded pb-2 pr-2 pt-1 text-base-fg"
>
{renderHighlightedPrompt()}
</div>
{hasAnyMentionables ? (
<MentionTextarea
ref={mentionEditorRef}
value={prompt}
onChange={setPrompt}
mentionItems={allMentionItems}
colorMap={mentionColorMap}
placeholder={
isReferenceMode
? "Use @Image1, @Video1, @Audio1... to reference uploads in prompt..."
: "Describe what you want to happen in the video..."
}
className="promptbox-scrollbar text-md relative mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-base-fg"
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
if (selectedModel?.requiresImage && referenceImages.length === 0) return;
if (!prompt.trim()) return;
handleEnqueue();
}
}}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
) : (
<textarea
ref={textareaRef}
rows={1}
placeholder="Describe what you want to happen in the video..."
className="promptbox-scrollbar text-md relative mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none"
value={prompt}
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
)}
<textarea
ref={textareaRef}
rows={1}
placeholder={
isReferenceMode
? "Use @Image1, @Video1, @Audio1... to reference uploads in prompt..."
: "Describe what you want to happen in the video..."
}
className={twMerge(
"promptbox-scrollbar text-md relative mb-2 min-h-[2.5em] w-full resize-y overflow-y-auto rounded bg-transparent pb-2 pr-2 pt-1 placeholder-base-fg/60 focus:outline-none",
hasAnyMentionables
? "text-transparent caret-base-fg"
: "text-base-fg",
)}
value={prompt}
onChange={handleChange}
onPaste={handlePaste}
onKeyDown={handleKeyDown}
onScroll={handleScroll}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
/>
<span
className={`absolute -bottom-1 right-0 text-[10px] tabular-nums ${prompt.length > maxLen ? "text-red-500" : "text-base-fg/40"}`}
>
@@ -1229,22 +1129,8 @@ export const PromptBoxVideo = ({
onClose={() => setIsCharactersModalOpen(false)}
onSelectCharacter={(character) => {
const mention = `@${character.name}`;
const textarea = textareaRef.current;
if (textarea) {
const pos = textarea.selectionStart ?? prompt.length;
const before = prompt.slice(0, pos);
const after = prompt.slice(pos);
const spaceBefore =
before.length > 0 && !before.endsWith(" ") ? " " : "";
setPrompt(before + spaceBefore + mention + " " + after);
requestAnimationFrame(() => {
const newPos = pos + spaceBefore.length + mention.length + 1;
textarea.setSelectionRange(newPos, newPos);
textarea.focus();
});
} else {
setPrompt(prompt ? prompt + " " + mention + " " : mention + " ");
}
const spaceBefore = prompt.length > 0 && !prompt.endsWith(" ") ? " " : "";
setPrompt(prompt + spaceBefore + mention + " ");
setIsCharactersModalOpen(false);
}}
/>