Add image picker to site builder components and fix media gallery bugs
- Add ImagePickerField to all site builder components with image URLs:
- Marketing: Hero, SplitContent, LogoCloud, GalleryGrid, Testimonials,
ContentBlocks, Header, Footer
- Email: EmailImage, EmailHeader
- Fix media gallery issues:
- Change API endpoint from /media/ to /media-files/ to avoid URL conflict
- Fix album file_count annotation conflict with model property
- Fix image URLs to use absolute paths for cross-domain access
- Add clipboard fallback for non-HTTPS copy URL
- Show permission slugs in plan feature editor
- Fix branding settings access for tenants with custom_branding feature
- Fix EntitlementService method call in StorageQuotaService
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -123,7 +123,7 @@ export async function deleteAlbum(id: number): Promise<void> {
|
||||
*/
|
||||
export async function listMediaFiles(albumId?: number | 'null'): Promise<MediaFile[]> {
|
||||
const params = albumId !== undefined ? { album: albumId } : {};
|
||||
const response = await apiClient.get('/media/', { params });
|
||||
const response = await apiClient.get('/media-files/', { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ export async function listMediaFiles(albumId?: number | 'null'): Promise<MediaFi
|
||||
* Get a single media file
|
||||
*/
|
||||
export async function getMediaFile(id: number): Promise<MediaFile> {
|
||||
const response = await apiClient.get(`/media/${id}/`);
|
||||
const response = await apiClient.get(`/media-files/${id}/`);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -152,7 +152,7 @@ export async function uploadMediaFile(
|
||||
formData.append('alt_text', altText);
|
||||
}
|
||||
|
||||
const response = await apiClient.post('/media/', formData, {
|
||||
const response = await apiClient.post('/media-files/', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
@@ -167,7 +167,7 @@ export async function updateMediaFile(
|
||||
id: number,
|
||||
data: MediaFileUpdatePayload
|
||||
): Promise<MediaFile> {
|
||||
const response = await apiClient.patch(`/media/${id}/`, data);
|
||||
const response = await apiClient.patch(`/media-files/${id}/`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ export async function updateMediaFile(
|
||||
* Delete a media file
|
||||
*/
|
||||
export async function deleteMediaFile(id: number): Promise<void> {
|
||||
await apiClient.delete(`/media/${id}/`);
|
||||
await apiClient.delete(`/media-files/${id}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,7 +185,7 @@ export async function bulkMoveFiles(
|
||||
fileIds: number[],
|
||||
albumId: number | null
|
||||
): Promise<{ updated: number }> {
|
||||
const response = await apiClient.post('/media/bulk_move/', {
|
||||
const response = await apiClient.post('/media-files/bulk_move/', {
|
||||
file_ids: fileIds,
|
||||
album_id: albumId,
|
||||
});
|
||||
@@ -196,7 +196,7 @@ export async function bulkMoveFiles(
|
||||
* Delete multiple files
|
||||
*/
|
||||
export async function bulkDeleteFiles(fileIds: number[]): Promise<{ deleted: number }> {
|
||||
const response = await apiClient.post('/media/bulk_delete/', {
|
||||
const response = await apiClient.post('/media-files/bulk_delete/', {
|
||||
file_ids: fileIds,
|
||||
});
|
||||
return response.data;
|
||||
|
||||
@@ -171,6 +171,9 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{feature.name}
|
||||
</span>
|
||||
<code className="text-xs text-gray-400 dark:text-gray-500 block mt-0.5 font-mono">
|
||||
{feature.code}
|
||||
</code>
|
||||
{feature.description && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
|
||||
{feature.description}
|
||||
@@ -207,17 +210,22 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<label className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer">
|
||||
<label className="flex items-start gap-3 flex-1 min-w-0 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => toggleIntegerFeature(feature.code)}
|
||||
aria-label={feature.name}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
className="mt-0.5 rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1 min-w-0">
|
||||
{feature.name}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white block">
|
||||
{feature.name}
|
||||
</span>
|
||||
<code className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
{feature.code}
|
||||
</code>
|
||||
</div>
|
||||
</label>
|
||||
{selected && (
|
||||
<input
|
||||
|
||||
@@ -81,6 +81,7 @@ export const FEATURE_NAMES: Record<FeatureKey, string> = {
|
||||
webhooks: 'Webhooks',
|
||||
api_access: 'API Access',
|
||||
custom_domain: 'Custom Domain',
|
||||
custom_branding: 'Custom Branding',
|
||||
remove_branding: 'Remove Branding',
|
||||
custom_oauth: 'Custom OAuth',
|
||||
automations: 'Automations',
|
||||
@@ -104,6 +105,7 @@ export const FEATURE_DESCRIPTIONS: Record<FeatureKey, string> = {
|
||||
webhooks: 'Integrate with external services using webhooks',
|
||||
api_access: 'Access the SmoothSchedule API for custom integrations',
|
||||
custom_domain: 'Use your own custom domain for your booking site',
|
||||
custom_branding: 'Customize branding colors, logo, and styling',
|
||||
remove_branding: 'Remove SmoothSchedule branding from customer-facing pages',
|
||||
custom_oauth: 'Configure your own OAuth credentials for social login',
|
||||
automations: 'Automate repetitive tasks with custom workflows',
|
||||
|
||||
@@ -41,7 +41,7 @@ interface ParentContext {
|
||||
|
||||
// Map settings pages to their required plan features
|
||||
const SETTINGS_PAGE_FEATURES: Record<string, FeatureKey> = {
|
||||
'/dashboard/settings/branding': 'remove_branding',
|
||||
'/dashboard/settings/branding': 'custom_branding',
|
||||
'/dashboard/settings/custom-domains': 'custom_domain',
|
||||
'/dashboard/settings/api': 'api_access',
|
||||
'/dashboard/settings/authentication': 'custom_oauth',
|
||||
@@ -154,7 +154,7 @@ const SettingsLayout: React.FC = () => {
|
||||
icon={Palette}
|
||||
label={t('settings.appearance.title', 'Appearance')}
|
||||
description={t('settings.appearance.description', 'Logo, colors, theme')}
|
||||
locked={isLocked('remove_branding')}
|
||||
locked={isLocked('custom_branding')}
|
||||
/>
|
||||
)}
|
||||
{hasSettingsPermission('can_access_settings_email_templates') && (
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
ExternalLink,
|
||||
ChevronLeft,
|
||||
} from 'lucide-react';
|
||||
import { Modal, FormInput, Button, Alert, FormTextarea } from '../components/ui';
|
||||
import { Modal, FormInput, Button, FormTextarea } from '../components/ui';
|
||||
import {
|
||||
Album,
|
||||
MediaFile,
|
||||
@@ -55,7 +55,7 @@ interface StorageUsageBarProps {
|
||||
}
|
||||
|
||||
const StorageUsageBar: React.FC<StorageUsageBarProps> = ({ usage, isLoading }) => {
|
||||
if (isLoading || !usage) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 animate-pulse">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/3 mb-2" />
|
||||
@@ -64,6 +64,10 @@ const StorageUsageBar: React.FC<StorageUsageBarProps> = ({ usage, isLoading }) =
|
||||
);
|
||||
}
|
||||
|
||||
if (!usage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const percentUsed = usage.percent_used;
|
||||
const barColor =
|
||||
percentUsed >= 95
|
||||
@@ -291,10 +295,27 @@ const ImagePreviewModal: React.FC<ImagePreviewModalProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyUrl = () => {
|
||||
navigator.clipboard.writeText(file.url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
const handleCopyUrl = async () => {
|
||||
try {
|
||||
// Try modern clipboard API first
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(file.url);
|
||||
} else {
|
||||
// Fallback for non-secure contexts (http://)
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = file.url;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy URL:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -763,12 +784,17 @@ const MediaGalleryPage: React.FC = () => {
|
||||
{/* Storage usage */}
|
||||
<StorageUsageBar usage={storageUsage} isLoading={usageLoading} />
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<Alert type="error" className="mt-4" onClose={() => setError(null)}>
|
||||
{/* Error Modal */}
|
||||
<Modal isOpen={!!error} onClose={() => setError(null)} title="Error" size="sm">
|
||||
<div className="text-red-600 dark:text-red-400">
|
||||
<pre className="whitespace-pre-wrap text-sm">{error}</pre>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button variant="primary" onClick={() => setError(null)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
{selectedFiles.size > 0 && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import type { ComponentConfig } from '@measured/puck';
|
||||
import type { EmailHeaderProps } from './types';
|
||||
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||
|
||||
/**
|
||||
* EmailHeader - Business logo and name header
|
||||
@@ -61,8 +62,8 @@ export const EmailHeader: ComponentConfig<EmailHeaderProps> = {
|
||||
label: 'Email Header',
|
||||
fields: {
|
||||
logoUrl: {
|
||||
type: 'text',
|
||||
label: 'Logo URL',
|
||||
...imagePickerField,
|
||||
label: 'Logo Image',
|
||||
},
|
||||
businessName: {
|
||||
type: 'text',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import type { ComponentConfig } from '@measured/puck';
|
||||
import type { EmailImageProps } from './types';
|
||||
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||
|
||||
/**
|
||||
* EmailImage - Image component
|
||||
@@ -12,8 +13,8 @@ export const EmailImage: ComponentConfig<EmailImageProps> = {
|
||||
label: 'Email Image',
|
||||
fields: {
|
||||
src: {
|
||||
type: 'text',
|
||||
label: 'Image URL',
|
||||
...imagePickerField,
|
||||
label: 'Image',
|
||||
},
|
||||
alt: {
|
||||
type: 'text',
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
applyDesignControls,
|
||||
type DesignControlsProps,
|
||||
} from '../../theme';
|
||||
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||
|
||||
export interface Feature {
|
||||
heading: string;
|
||||
@@ -113,7 +114,7 @@ export const ContentBlocks: ComponentConfig<ContentBlocksProps> = {
|
||||
// Using external field for string arrays
|
||||
},
|
||||
},
|
||||
image: { type: 'text', label: 'Image URL' },
|
||||
image: { ...imagePickerField, label: 'Image' },
|
||||
},
|
||||
defaultItemProps: {
|
||||
heading: 'Feature Heading',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import type { ComponentConfig } from '@measured/puck';
|
||||
import { Twitter, Linkedin, Github, Facebook, Instagram, Youtube } from 'lucide-react';
|
||||
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||
|
||||
export interface FooterLink {
|
||||
label: string;
|
||||
@@ -156,8 +157,8 @@ export const Footer: ComponentConfig<FooterProps> = {
|
||||
label: 'Brand Name',
|
||||
},
|
||||
brandLogo: {
|
||||
type: 'text',
|
||||
label: 'Brand Logo URL',
|
||||
...imagePickerField,
|
||||
label: 'Brand Logo',
|
||||
},
|
||||
description: {
|
||||
type: 'textarea',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
applyDesignControls,
|
||||
type DesignControlsProps,
|
||||
} from '../../theme';
|
||||
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||
|
||||
export interface GalleryItem {
|
||||
image: string;
|
||||
@@ -105,7 +106,7 @@ export const GalleryGrid: ComponentConfig<GalleryGridProps> = {
|
||||
type: 'array',
|
||||
label: 'Items',
|
||||
arrayFields: {
|
||||
image: { type: 'text', label: 'Image URL' },
|
||||
image: { ...imagePickerField, label: 'Image' },
|
||||
title: { type: 'text', label: 'Title' },
|
||||
text: { type: 'textarea', label: 'Description' },
|
||||
link: { type: 'text', label: 'Link URL (optional)' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { ComponentConfig } from '@measured/puck';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||
|
||||
export interface HeaderLink {
|
||||
label: string;
|
||||
@@ -145,8 +146,8 @@ export const Header: ComponentConfig<HeaderProps> = {
|
||||
label: 'Business Name',
|
||||
},
|
||||
brandLogo: {
|
||||
type: 'text',
|
||||
label: 'Logo URL',
|
||||
...imagePickerField,
|
||||
label: 'Logo Image',
|
||||
},
|
||||
links: {
|
||||
type: 'array',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
applyDesignControls,
|
||||
type DesignControlsProps,
|
||||
} from '../../theme';
|
||||
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||
|
||||
export interface HeroCtaButton {
|
||||
text: string;
|
||||
@@ -208,7 +209,7 @@ export const Hero: ComponentConfig<HeroProps> = {
|
||||
{ label: 'Image', value: 'image' },
|
||||
],
|
||||
},
|
||||
src: { type: 'text', label: 'Image URL' },
|
||||
src: { ...imagePickerField, label: 'Image' },
|
||||
alt: { type: 'text', label: 'Alt Text' },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
applyDesignControls,
|
||||
type DesignControlsProps,
|
||||
} from '../../theme';
|
||||
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||
|
||||
export interface Logo {
|
||||
src: string;
|
||||
@@ -102,7 +103,7 @@ export const LogoCloud: ComponentConfig<LogoCloudProps> = {
|
||||
type: 'array',
|
||||
label: 'Logos',
|
||||
arrayFields: {
|
||||
src: { type: 'text', label: 'Image URL' },
|
||||
src: { ...imagePickerField, label: 'Logo Image' },
|
||||
alt: { type: 'text', label: 'Alt Text' },
|
||||
href: { type: 'text', label: 'Link URL (optional)' },
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
applyDesignControls,
|
||||
type DesignControlsProps,
|
||||
} from '../../theme';
|
||||
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||
|
||||
export interface Bullet {
|
||||
text: string;
|
||||
@@ -223,7 +224,7 @@ export const SplitContent: ComponentConfig<SplitContentProps> = {
|
||||
type: 'object',
|
||||
label: 'Media',
|
||||
objectFields: {
|
||||
src: { type: 'text', label: 'Image URL' },
|
||||
src: { ...imagePickerField, label: 'Image' },
|
||||
alt: { type: 'text', label: 'Alt Text' },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
applyDesignControls,
|
||||
type DesignControlsProps,
|
||||
} from '../../theme';
|
||||
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||
|
||||
export interface TestimonialItem {
|
||||
quote: string;
|
||||
@@ -180,7 +181,7 @@ export const Testimonials: ComponentConfig<TestimonialsProps> = {
|
||||
quote: { type: 'textarea', label: 'Quote' },
|
||||
name: { type: 'text', label: 'Name' },
|
||||
title: { type: 'text', label: 'Title/Company' },
|
||||
avatar: { type: 'text', label: 'Avatar URL' },
|
||||
avatar: { ...imagePickerField, label: 'Avatar Image' },
|
||||
rating: {
|
||||
type: 'select',
|
||||
label: 'Rating',
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface PlanPermissions {
|
||||
webhooks: boolean;
|
||||
api_access: boolean;
|
||||
custom_domain: boolean;
|
||||
custom_branding: boolean;
|
||||
remove_branding: boolean;
|
||||
custom_oauth: boolean;
|
||||
automations: boolean;
|
||||
|
||||
Reference in New Issue
Block a user