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:
poduck
2025-12-22 18:21:52 -05:00
parent f1b1f18bc5
commit 2bfa01e0d4
21 changed files with 120 additions and 64 deletions

View File

@@ -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;

View File

@@ -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

View File

@@ -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',

View File

@@ -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') && (

View File

@@ -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 && (

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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)' },

View File

@@ -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',

View File

@@ -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' },
},
},

View File

@@ -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)' },
},

View File

@@ -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' },
},
},

View File

@@ -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',

View File

@@ -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;

View File

@@ -33,11 +33,9 @@ class StorageQuotaService:
# Get storage_gb feature value from billing
if hasattr(tenant, 'billing_subscription') and tenant.billing_subscription:
storage_gb = EntitlementService.get_feature_value(
tenant,
'storage_gb',
default=StorageQuotaService.DEFAULT_STORAGE_GB
)
storage_gb = EntitlementService.get_limit(tenant, 'storage_gb')
if storage_gb is None:
storage_gb = StorageQuotaService.DEFAULT_STORAGE_GB
else:
storage_gb = StorageQuotaService.DEFAULT_STORAGE_GB

View File

@@ -169,6 +169,7 @@ def current_business_view(request):
'webhooks': tenant.has_feature('integrations_enabled'),
'api_access': tenant.has_feature('api_access'),
'custom_domain': tenant.has_feature('custom_domain'),
'custom_branding': tenant.has_feature('custom_branding'),
'remove_branding': tenant.has_feature('remove_branding'),
'custom_oauth': tenant.has_feature('can_manage_oauth'),
'automations': tenant.has_feature('can_use_automations'),

View File

@@ -1413,7 +1413,7 @@ class AlbumSerializer(serializers.ModelSerializer):
Provides read-only computed fields for file_count and cover_url.
"""
file_count = serializers.IntegerField(read_only=True)
file_count = serializers.SerializerMethodField()
cover_url = serializers.SerializerMethodField()
class Meta:
@@ -1424,23 +1424,30 @@ class AlbumSerializer(serializers.ModelSerializer):
]
read_only_fields = ['id', 'created_at', 'updated_at']
def get_cover_url(self, obj):
"""Get the URL of the cover image."""
if obj.cover_image and obj.cover_image.file:
return obj.cover_image.file.url
# If no explicit cover, use first file in album
first_file = obj.files.first()
if first_file and first_file.file:
return first_file.file.url
return None
def get_file_count(self, obj):
"""Get file count from annotation or compute it."""
# Try to get from annotation first (more efficient for list views)
if hasattr(obj, 'annotated_file_count'):
return obj.annotated_file_count
# Fall back to model property
return obj.file_count
def to_representation(self, instance):
"""Add file_count if not already annotated."""
data = super().to_representation(instance)
# If file_count wasn't annotated in the queryset, compute it
if data.get('file_count') is None:
data['file_count'] = instance.files.count()
return data
def get_cover_url(self, obj):
"""Get the full URL of the cover image."""
request = self.context.get('request')
url = None
if obj.cover_image and obj.cover_image.file:
url = obj.cover_image.file.url
else:
# If no explicit cover, use first file in album
first_file = obj.files.first()
if first_file and first_file.file:
url = first_file.file.url
if url and request:
return request.build_absolute_uri(url)
return url
class MediaFileSerializer(serializers.ModelSerializer):
@@ -1471,8 +1478,11 @@ class MediaFileSerializer(serializers.ModelSerializer):
read_only_fields = ['id', 'filename', 'file_size', 'width', 'height', 'mime_type', 'created_at']
def get_url(self, obj):
"""Get the URL to access the file."""
"""Get the full URL to access the file."""
if obj.file:
request = self.context.get('request')
if request:
return request.build_absolute_uri(obj.file.url)
return obj.file.url
return None

View File

@@ -31,7 +31,7 @@ router.register(r'holidays', HolidayViewSet, basename='holiday')
router.register(r'time-blocks', TimeBlockViewSet, basename='timeblock')
router.register(r'locations', LocationViewSet, basename='location')
router.register(r'albums', AlbumViewSet, basename='album')
router.register(r'media', MediaFileViewSet, basename='media')
router.register(r'media-files', MediaFileViewSet, basename='media')
# URL patterns
urlpatterns = [

View File

@@ -1983,7 +1983,7 @@ class AlbumViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
def get_queryset(self):
"""Annotate with file count for list view."""
queryset = super().get_queryset()
return queryset.annotate(file_count=Count('files'))
return queryset.annotate(annotated_file_count=Count('files'))
def perform_destroy(self, instance):
"""When deleting an album, move files to uncategorized (null album)."""