Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions apps/api/src/lib/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,11 +172,13 @@ async function fetchAssetBase64(asset: MediaAsset): Promise<{ mimeType: string;
if (!response.ok) {
throw new Error(`Failed to fetch asset: ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
return {
mimeType: asset.mimeType,
data: btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))),
};
const bytes = new Uint8Array(await response.arrayBuffer());
let binary = '';
const chunkSize = 8192;
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return { mimeType: asset.mimeType, data: btoa(binary) };
}

async function uploadGeneratedModel(
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,4 +282,19 @@ export const en = {
cancelBtn: 'Cancel',
},
},
brandPlacement: {
title: 'Brand Placement',
master: 'Enable Brand Placement for this Product',
assetsSection: 'Brand Assets',
description: 'Control which brand elements the AI applies during generation.',
save: 'Save brand settings',
logo: 'Brand Logo',
colors: 'Brand Colors',
text: 'Brand Text',
typography: 'Typography / Fonts',
watermark: 'Watermark',
packaging: 'Packaging Elements',
slogan: 'Brand Slogan',
socialTags: 'Social Media Tags',
},
} as const;
15 changes: 15 additions & 0 deletions apps/web/src/i18n/locales/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,4 +284,19 @@ export const tr = {
cancelBtn: 'İptal',
},
},
brandPlacement: {
title: 'Marka Yerleşimi',
master: 'Bu Ürün İçin Marka Yerleşimini Etkinleştir',
assetsSection: 'Marka Varlıkları',
description: 'Yapay zekanın üretim sırasında uygulayacağı marka öğelerini seçin.',
save: 'Marka ayarlarını kaydet',
logo: 'Marka Logosu',
colors: 'Marka Renkleri',
text: 'Marka Metni',
typography: 'Tipografi / Yazı Tipleri',
watermark: 'Filigran',
packaging: 'Ambalaj Öğeleri',
slogan: 'Marka Sloganı',
socialTags: 'Sosyal Medya Etiketleri',
},
} as const;
42 changes: 41 additions & 1 deletion apps/web/src/pages/ProductDetailPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@ import {
QualityReport,
SourceImageReadiness,
generateId,
type BrandPlacementConfig,
type ConversionSnapshot,
type Hotspot,
type Product,
type ProductCategory,
type ProductCluster,
} from '@minimalblock/core';
import { MultiProductClusterSelector } from '../components/import/MultiProductClusterSelector.js';
import { ModelViewer, ModelViewerPlaceholder, ModelInfoCard, StatusBadge, WorkflowStatusBadge, Button, Spinner, Card, Modal, AiDiagnosisPanel, SourceImageReadinessCard, HotspotEditorPanel, type ModelViewerHandle } from '@minimalblock/ui';
import { ModelViewer, ModelViewerPlaceholder, ModelInfoCard, StatusBadge, WorkflowStatusBadge, Button, Spinner, Card, Modal, AiDiagnosisPanel, SourceImageReadinessCard, HotspotEditorPanel, BrandPlacementPanel, type ModelViewerHandle } from '@minimalblock/ui';
import { useApp } from '../context/AppContext.js';
import type { SupabaseUser } from '../types.js';

Expand Down Expand Up @@ -147,6 +148,8 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) {
const [approvingProduct, setApprovingProduct] = useState(false);
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
const [diagnosisError, setDiagnosisError] = useState<string | null>(null);
const [brandPlacement, setBrandPlacement] = useState<BrandPlacementConfig | null>(null);
const [savingBrandPlacement, setSavingBrandPlacement] = useState(false);
const [importForm, setImportForm] = useState({
title: '',
description: '',
Expand Down Expand Up @@ -187,6 +190,7 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) {
setConversion(foundConversion);
setProduct(foundProduct);
setHotspots(foundProduct.hotspots);
setBrandPlacement(foundProduct.brandPlacement ?? null);
setMetaForm({
name: foundProduct.name,
description: foundProduct.description,
Expand Down Expand Up @@ -245,6 +249,18 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) {
}
}

async function saveBrandPlacement(config: BrandPlacementConfig) {
if (!product) return;
setSavingBrandPlacement(true);
try {
const saved = await productRepo.save(product.withBrandPlacement(config));
setProduct(saved);
setBrandPlacement(config);
} finally {
setSavingBrandPlacement(false);
}
}

async function saveImportReview() {
if (!product) return;
setSavingImportReview(true);
Expand Down Expand Up @@ -1311,6 +1327,30 @@ export function ProductDetailPage({ user }: ProductDetailPageProps) {
onContinueAnyway={() => navigate('/upload')}
/>

<BrandPlacementPanel
value={brandPlacement}
onChange={setBrandPlacement}
onSave={saveBrandPlacement}
isSaving={savingBrandPlacement}
labels={{
title: t('brandPlacement.title'),
master: t('brandPlacement.master'),
assetsSection: t('brandPlacement.assetsSection'),
description: t('brandPlacement.description'),
save: t('brandPlacement.save'),
options: {
logo: t('brandPlacement.logo'),
colors: t('brandPlacement.colors'),
text: t('brandPlacement.text'),
typography: t('brandPlacement.typography'),
watermark: t('brandPlacement.watermark'),
packaging: t('brandPlacement.packaging'),
slogan: t('brandPlacement.slogan'),
socialTags: t('brandPlacement.socialTags'),
},
}}
/>

<Card>
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-gray-900">Suggested hotspots</h2>
Expand Down
55 changes: 55 additions & 0 deletions libs/core/src/lib/domain/entities/product.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,54 @@ export interface ProductAiAnalysis {
sourceImageEntries?: SourceImageEntry[];
}

export type BrandPlacementKey = 'logo' | 'colors' | 'text' | 'typography' | 'watermark' | 'packaging' | 'slogan' | 'socialTags';

export interface BrandPlacementConfig {
enabled: boolean;
logo: boolean;
colors: boolean;
text: boolean;
typography: boolean;
watermark: boolean;
packaging: boolean;
slogan: boolean;
socialTags: boolean;
}

export const BRAND_PLACEMENT_KEYS: BrandPlacementKey[] = [
'logo', 'colors', 'text', 'typography', 'watermark', 'packaging', 'slogan', 'socialTags',
];

export const DEFAULT_BRAND_PLACEMENT: BrandPlacementConfig = {
enabled: false,
logo: false,
colors: false,
text: false,
typography: false,
watermark: false,
packaging: false,
slogan: false,
socialTags: false,
};

export function buildBrandPlacementPrompt(config: BrandPlacementConfig): string {
if (!config.enabled) return '';
const enabled: string[] = [];
const disabled: string[] = [];
if (config.logo) enabled.push('brand logo'); else disabled.push('brand logo');
if (config.colors) enabled.push('brand colors'); else disabled.push('brand colors');
if (config.text) enabled.push('brand text'); else disabled.push('brand text');
if (config.typography) enabled.push('typography/fonts'); else disabled.push('typography/fonts');
if (config.watermark) enabled.push('subtle watermark styling'); else disabled.push('watermark');
if (config.packaging) enabled.push('packaging elements'); else disabled.push('packaging elements');
if (config.slogan) enabled.push('brand slogan'); else disabled.push('brand slogan');
if (config.socialTags) enabled.push('social media tags'); else disabled.push('social media tags');
const parts: string[] = [];
if (enabled.length > 0) parts.push(`Use: ${enabled.join(', ')}.`);
if (disabled.length > 0) parts.push(`Do not include: ${disabled.join(', ')}.`);
return parts.join(' ');
}

export interface ProductProps {
id: string;
name: string;
Expand All @@ -224,6 +272,7 @@ export interface ProductProps {
workflowStatus?: ProductWorkflowStatusValue;
inputMethod?: ProductInputMethod;
importData?: ProductImportData | null;
brandPlacement?: BrandPlacementConfig | null;
createdAt: Date;
updatedAt: Date;
}
Expand All @@ -241,6 +290,7 @@ export class Product {
readonly workflowStatus: ProductWorkflowStatusValue;
readonly inputMethod: ProductInputMethod;
readonly importData: ProductImportData | null;
readonly brandPlacement: BrandPlacementConfig | null;
readonly createdAt: Date;
readonly updatedAt: Date;

Expand All @@ -257,6 +307,7 @@ export class Product {
this.workflowStatus = props.workflowStatus ?? 'draft';
this.inputMethod = props.inputMethod ?? 'manual_upload';
this.importData = props.importData ?? null;
this.brandPlacement = props.brandPlacement ?? null;
this.createdAt = props.createdAt;
this.updatedAt = props.updatedAt;
}
Expand Down Expand Up @@ -414,6 +465,10 @@ export class Product {
return new Product({ ...this, aiAnalysis, updatedAt: new Date() });
}

withBrandPlacement(brandPlacement: BrandPlacementConfig | null): Product {
return new Product({ ...this, brandPlacement, updatedAt: new Date() });
}

withSuggestedHotspots(hotspotsSuggested: SuggestedHotspot[]): Product {
return new Product({ ...this, hotspotsSuggested, updatedAt: new Date() });
}
Expand Down
7 changes: 7 additions & 0 deletions libs/data/src/lib/repositories/product.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ProductAiAnalysis,
ProductImportData,
SuggestedHotspot,
BrandPlacementConfig,
migrateLegacyProductCategory,
} from '@minimalblock/core';
import type { SupabaseClient } from '@supabase/supabase-js';
Expand Down Expand Up @@ -32,6 +33,9 @@ function rowToProduct(row: ProductRow): Product {
importData: row.import_data && !Array.isArray(row.import_data)
? (row.import_data as unknown as ProductImportData)
: null,
brandPlacement: row.brand_placement && !Array.isArray(row.brand_placement)
? (row.brand_placement as unknown as BrandPlacementConfig)
: null,
createdAt: new Date(row.created_at),
updatedAt: new Date(row.updated_at),
});
Expand Down Expand Up @@ -82,6 +86,9 @@ export class SupabaseProductRepository implements IProductRepository {
import_data: product.importData
? product.importData as unknown as import('../supabase/database.types.js').Json
: null,
brand_placement: product.brandPlacement
? product.brandPlacement as unknown as import('../supabase/database.types.js').Json
: null,
})
.select()
.single();
Expand Down
3 changes: 3 additions & 0 deletions libs/data/src/lib/supabase/database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type Database = {
workflow_status: string;
input_method: string;
import_data: Json | null;
brand_placement: Json | null;
created_at: string;
updated_at: string;
};
Expand All @@ -48,6 +49,7 @@ export type Database = {
workflow_status?: string;
input_method?: string;
import_data?: Json | null;
brand_placement?: Json | null;
created_at?: string;
updated_at?: string;
};
Expand All @@ -65,6 +67,7 @@ export type Database = {
workflow_status?: string;
input_method?: string;
import_data?: Json | null;
brand_placement?: Json | null;
created_at?: string;
updated_at?: string;
};
Expand Down
30 changes: 1 addition & 29 deletions libs/features/src/lib/gallery/hooks/use-gallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import { useState, useEffect, useRef } from 'react';
import { Conversion } from '@minimalblock/core';
import type { IConversionRepository, IProductRepository } from '@minimalblock/core';

const POLL_INTERVAL_MS = 3000;

export interface UseGalleryState {
conversions: Conversion[];
loading: boolean;
Expand All @@ -16,53 +14,27 @@ export function useGallery(
ownerId: string,
) {
const [state, setState] = useState<UseGalleryState>({ conversions: [], loading: true, error: null });
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const conversionsRef = useRef<Conversion[]>([]);

function hasNonTerminal(list: Conversion[]) {
return list.some(c => !c.status.isTerminal());
}

function scheduleRefresh(list: Conversion[]) {
if (!hasNonTerminal(list)) return;
pollTimerRef.current = setTimeout(async () => {
try {
const fresh = await conversionRepo.findByOwnerId(ownerId);
conversionsRef.current = fresh;
setState(s => ({ ...s, conversions: fresh }));
scheduleRefresh(fresh);
} catch {
// silently ignore poll errors
}
}, POLL_INTERVAL_MS);
}

useEffect(() => {
let cancelled = false;

conversionRepo.findByOwnerId(ownerId).then(conversions => {
if (cancelled) return;
conversionsRef.current = conversions;
setState({ conversions, loading: false, error: null });
scheduleRefresh(conversions);
}).catch(err => {
if (!cancelled) setState({ conversions: [], loading: false, error: err instanceof Error ? err.message : 'Failed to load' });
});

return () => {
cancelled = true;
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
return () => { cancelled = true; };
}, [conversionRepo, ownerId]);

const removeProduct = async (productId: string) => {
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
await productRepo.delete(productId);
const updated = conversionsRef.current.filter(c => c.productId !== productId);
conversionsRef.current = updated;
setState(s => ({ ...s, conversions: updated }));
scheduleRefresh(updated);
};

return { ...state, removeProduct };
Expand Down
3 changes: 3 additions & 0 deletions libs/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export type { WorkflowStatusBadgeProps } from './lib/components/WorkflowStatusBa
export { AiDiagnosisPanel } from './lib/components/AiDiagnosisPanel.js';
export type { AiDiagnosisPanelProps } from './lib/components/AiDiagnosisPanel.js';

export { BrandPlacementPanel } from './lib/components/BrandPlacementPanel.js';
export type { BrandPlacementPanelProps } from './lib/components/BrandPlacementPanel.js';

export { SourceImageReadinessCard } from './lib/components/SourceImageReadinessCard.js';
export type { SourceImageReadinessCardProps } from './lib/components/SourceImageReadinessCard.js';

Expand Down
Loading
Loading