Publish: real card photo on the web hero + event banner (#5)
- Photo on web: the publish flow uploads the card's front photo (base64 → object storage) and sets it as the face image; the web profile renders it as a full-bleed hero (when there's no AI welcome). /demo/publish body limit raised to 12MB for the image. Verified live (image served from MinIO, photo-hero rendered). - Event mode (#5): optional `event` → a "📍 …" banner at the top of the profile. - Mobile publish screen: Event field + sends the front photo. - Gate green (120 tests), astro check clean, mobile tsc+jest green. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
b487195def
commit
a19f5abc6f
@@ -69,7 +69,11 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
.route("/cards/:id/welcome", post(cards::set_welcome))
|
.route("/cards/:id/welcome", post(cards::set_welcome))
|
||||||
.route("/cards/:id/connections", get(cards::list_connections))
|
.route("/cards/:id/connections", get(cards::list_connections))
|
||||||
.route("/profile/:handle/connect", post(profile::submit_connection))
|
.route("/profile/:handle/connect", post(profile::submit_connection))
|
||||||
.route("/demo/publish", post(demo::publish))
|
.route(
|
||||||
|
"/demo/publish",
|
||||||
|
// The front photo is sent as base64, so allow a larger body here.
|
||||||
|
post(demo::publish).layer(axum::extract::DefaultBodyLimit::max(12 * 1024 * 1024)),
|
||||||
|
)
|
||||||
.route("/ai/refine", post(ai::refine))
|
.route("/ai/refine", post(ai::refine))
|
||||||
.route("/ai/image", post(ai::image))
|
.route("/ai/image", post(ai::image))
|
||||||
.route("/ai/video", post(ai::start_video))
|
.route("/ai/video", post(ai::start_video))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//! Each publish creates a fresh password-less owner so the profile shows the
|
//! Each publish creates a fresh password-less owner so the profile shows the
|
||||||
//! right name and stays within the free-tier 1-card limit.
|
//! right name and stays within the free-tier 1-card limit.
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
use cardclaws_db::queries::users;
|
use cardclaws_db::queries::users;
|
||||||
use cardclaws_types::{AppError, Tier};
|
use cardclaws_types::{AppError, Tier};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -44,6 +45,14 @@ pub struct DemoPublishRequest {
|
|||||||
/// "Now" status — what the person is currently up to.
|
/// "Now" status — what the person is currently up to.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub now: Option<String>,
|
pub now: Option<String>,
|
||||||
|
/// Event context shown as a banner ("📍 met at …").
|
||||||
|
#[serde(default)]
|
||||||
|
pub event: Option<String>,
|
||||||
|
/// The card's front photo (base64), shown as the web hero.
|
||||||
|
#[serde(default)]
|
||||||
|
pub front_image_base64: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub front_image_mime: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Published {
|
pub struct Published {
|
||||||
@@ -64,6 +73,26 @@ pub async fn publish(state: &AppState, req: &DemoPublishRequest) -> Result<Publi
|
|||||||
.collect();
|
.collect();
|
||||||
let card_handle = format!("{}-{}", slugify(&req.name), &suffix[..6]);
|
let card_handle = format!("{}-{}", slugify(&req.name), &suffix[..6]);
|
||||||
|
|
||||||
|
// Upload the card's front photo (if any) so the web hero shows the real card.
|
||||||
|
let mut face_background = serde_json::json!({ "type": "solid", "value": "#101014" });
|
||||||
|
if let Some(b64) = req.front_image_base64.as_deref() {
|
||||||
|
if !b64.trim().is_empty() {
|
||||||
|
let bytes = base64::engine::general_purpose::STANDARD
|
||||||
|
.decode(b64.trim().as_bytes())
|
||||||
|
.map_err(|e| AppError::Internal(format!("decode front image: {e}")))?;
|
||||||
|
let mime = req.front_image_mime.as_deref().unwrap_or("image/jpeg");
|
||||||
|
let ext = if mime.contains("png") { "png" } else { "jpg" };
|
||||||
|
let key = format!("front/{suffix}.{ext}");
|
||||||
|
state
|
||||||
|
.assets
|
||||||
|
.put_object(&key, bytes, mime)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||||
|
face_background =
|
||||||
|
serde_json::json!({ "type": "image", "value": state.assets.public_url(&key) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fresh password-less owner so the profile shows this person's name.
|
// Fresh password-less owner so the profile shows this person's name.
|
||||||
let user = users::insert(
|
let user = users::insert(
|
||||||
&state.db,
|
&state.db,
|
||||||
@@ -98,8 +127,13 @@ pub async fn publish(state: &AppState, req: &DemoPublishRequest) -> Result<Publi
|
|||||||
profile["now"] = serde_json::json!(now.trim());
|
profile["now"] = serde_json::json!(now.trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(event) = req.event.as_deref() {
|
||||||
|
if !event.trim().is_empty() {
|
||||||
|
profile["event"] = serde_json::json!(event.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
let definition = serde_json::json!({
|
let definition = serde_json::json!({
|
||||||
"face": { "layers": [], "background": { "type": "solid", "value": "#101014" } },
|
"face": { "layers": [], "background": face_background },
|
||||||
"back": {
|
"back": {
|
||||||
"layers": [{
|
"layers": [{
|
||||||
"id": Uuid::new_v4(),
|
"id": Uuid::new_v4(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// welcome shown when the QR is scanned. Returns a real cardclaws.com/<handle>
|
// welcome shown when the QR is scanned. Returns a real cardclaws.com/<handle>
|
||||||
// URL and points the card's QR at it.
|
// URL and points the card's QR at it.
|
||||||
|
|
||||||
|
import * as FileSystem from "expo-file-system";
|
||||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -32,6 +33,7 @@ export default function PublishScreen() {
|
|||||||
const [payloadLabel, setPayloadLabel] = useState("");
|
const [payloadLabel, setPayloadLabel] = useState("");
|
||||||
const [payloadValue, setPayloadValue] = useState("");
|
const [payloadValue, setPayloadValue] = useState("");
|
||||||
const [now, setNow] = useState("");
|
const [now, setNow] = useState("");
|
||||||
|
const [event, setEvent] = useState("");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [result, setResult] = useState<string | null>(card?.publishedUrl ?? null);
|
const [result, setResult] = useState<string | null>(card?.publishedUrl ?? null);
|
||||||
|
|
||||||
@@ -47,9 +49,24 @@ export default function PublishScreen() {
|
|||||||
const publish = async () => {
|
const publish = async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
|
// Upload the card's front photo so the web hero shows the real card.
|
||||||
|
let frontImageBase64: string | undefined;
|
||||||
|
let frontImageMime: string | undefined;
|
||||||
|
if (card.imagePath) {
|
||||||
|
try {
|
||||||
|
frontImageBase64 = await FileSystem.readAsStringAsync(card.imagePath, {
|
||||||
|
encoding: FileSystem.EncodingType.Base64,
|
||||||
|
});
|
||||||
|
frontImageMime = card.imagePath.endsWith(".png") ? "image/png" : "image/jpeg";
|
||||||
|
} catch {
|
||||||
|
/* no readable photo — publish without it */
|
||||||
|
}
|
||||||
|
}
|
||||||
const { profileUrl } = await publishToWeb({
|
const { profileUrl } = await publishToWeb({
|
||||||
name: card.name,
|
name: card.name,
|
||||||
title: card.title || undefined,
|
title: card.title || undefined,
|
||||||
|
frontImageBase64,
|
||||||
|
frontImageMime,
|
||||||
links: card.links.filter((l) => l.url.trim()).map((l) => ({ label: l.label, url: l.url })),
|
links: card.links.filter((l) => l.url.trim()).map((l) => ({ label: l.label, url: l.url })),
|
||||||
welcomePrompt: welcomePrompt.trim() || undefined,
|
welcomePrompt: welcomePrompt.trim() || undefined,
|
||||||
welcomeMessage: welcomePrompt.trim() ? welcomeMessage : undefined,
|
welcomeMessage: welcomePrompt.trim() ? welcomeMessage : undefined,
|
||||||
@@ -58,6 +75,7 @@ export default function PublishScreen() {
|
|||||||
? { kind: payloadKind, label: payloadLabel.trim(), value: payloadValue.trim() }
|
? { kind: payloadKind, label: payloadLabel.trim(), value: payloadValue.trim() }
|
||||||
: undefined,
|
: undefined,
|
||||||
now: now.trim() || undefined,
|
now: now.trim() || undefined,
|
||||||
|
event: event.trim() || undefined,
|
||||||
});
|
});
|
||||||
// Point the card's QR at the real profile + remember it.
|
// Point the card's QR at the real profile + remember it.
|
||||||
upsert({ ...card, url: profileUrl, publishedUrl: profileUrl, updatedAt: Date.now() });
|
upsert({ ...card, url: profileUrl, publishedUrl: profileUrl, updatedAt: Date.now() });
|
||||||
@@ -103,6 +121,16 @@ export default function PublishScreen() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.label}>Event (optional)</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
editable={!busy}
|
||||||
|
placeholder="e.g. SXSW 2026"
|
||||||
|
placeholderTextColor="#6b6b70"
|
||||||
|
value={event}
|
||||||
|
onChangeText={setEvent}
|
||||||
|
/>
|
||||||
|
|
||||||
<Text style={styles.label}>Now — what you’re up to (optional)</Text>
|
<Text style={styles.label}>Now — what you’re up to (optional)</Text>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ export interface PublishPayload {
|
|||||||
welcomeMessage?: string;
|
welcomeMessage?: string;
|
||||||
payload?: { kind: "link" | "code"; label: string; value: string };
|
payload?: { kind: "link" | "code"; label: string; value: string };
|
||||||
now?: string;
|
now?: string;
|
||||||
|
event?: string;
|
||||||
|
frontImageBase64?: string;
|
||||||
|
frontImageMime?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Publish a demo card to the web (no auth) → a real, scannable profile URL. */
|
/** Publish a demo card to the web (no auth) → a real, scannable profile URL. */
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export interface ProfileData {
|
|||||||
payload?: { kind: "link" | "code"; label: string; value: string };
|
payload?: { kind: "link" | "code"; label: string; value: string };
|
||||||
/** "Now" status — what the person is currently up to. */
|
/** "Now" status — what the person is currently up to. */
|
||||||
now?: string;
|
now?: string;
|
||||||
|
/** Event context shown as a banner. */
|
||||||
|
event?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CardSide {
|
export interface CardSide {
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ const bio = profileData.bio?.trim();
|
|||||||
const links = (profileData.links ?? []).filter((l) => l.url.trim().length > 0);
|
const links = (profileData.links ?? []).filter((l) => l.url.trim().length > 0);
|
||||||
const payload = profileData.payload && profileData.payload.value?.trim() ? profileData.payload : null;
|
const payload = profileData.payload && profileData.payload.value?.trim() ? profileData.payload : null;
|
||||||
const now = profileData.now?.trim();
|
const now = profileData.now?.trim();
|
||||||
|
const event = profileData.event?.trim();
|
||||||
|
const faceBg = profile.definition.face?.background;
|
||||||
|
const faceImage = faceBg?.type === "image" && faceBg.value ? faceBg.value : null;
|
||||||
const vcard = buildVcard(name, contact);
|
const vcard = buildVcard(name, contact);
|
||||||
const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
|
const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
|
||||||
contact.company ? ` at ${contact.company}` : ""
|
contact.company ? ` at ${contact.company}` : ""
|
||||||
@@ -44,9 +47,18 @@ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
|
|||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
{welcome && (
|
{welcome ? (
|
||||||
<WelcomeHero message={welcome.message} name={name} imageUrl={welcome.imageUrl} />
|
<WelcomeHero message={welcome.message} name={name} imageUrl={welcome.imageUrl} />
|
||||||
)}
|
) : faceImage ? (
|
||||||
|
<section class="photo-hero">
|
||||||
|
<img src={faceImage} alt="" />
|
||||||
|
<div class="photo-scrim"></div>
|
||||||
|
<div class="photo-text">
|
||||||
|
<h1>{name}</h1>
|
||||||
|
{contact.title && <p>{contact.title}</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
<main>
|
<main>
|
||||||
<CardHero
|
<CardHero
|
||||||
name={name}
|
name={name}
|
||||||
@@ -56,6 +68,12 @@ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
|
|||||||
contact={contact}
|
contact={contact}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{event && (
|
||||||
|
<section class="block">
|
||||||
|
<p class="event-banner">📍 {event}</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{now && (
|
{now && (
|
||||||
<section class="block">
|
<section class="block">
|
||||||
<p class="now"><span class="now-dot"></span>Now: {now}</p>
|
<p class="now"><span class="now-dot"></span>Now: {now}</p>
|
||||||
@@ -145,6 +163,43 @@ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
|
|||||||
padding: 48px 20px 120px;
|
padding: 48px 20px 120px;
|
||||||
gap: 32px;
|
gap: 32px;
|
||||||
}
|
}
|
||||||
|
.photo-hero {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 70vh;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
.photo-hero img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.photo-scrim {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(to top, #0a0a0c 2%, rgba(10, 10, 12, 0) 55%);
|
||||||
|
}
|
||||||
|
.photo-text {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px 28px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.photo-text h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.photo-text p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: #cfcfd4;
|
||||||
|
}
|
||||||
.block {
|
.block {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 420px;
|
max-width: 420px;
|
||||||
@@ -162,6 +217,16 @@ const og = `${name}${contact.title ? ` — ${contact.title}` : ""}${
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
color: #d8d8dc;
|
color: #d8d8dc;
|
||||||
}
|
}
|
||||||
|
.event-banner {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #1a1430;
|
||||||
|
border: 1px solid #3a2d6b;
|
||||||
|
color: #cbb8ff;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
.now {
|
.now {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user