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[]> {
|
export async function listMediaFiles(albumId?: number | 'null'): Promise<MediaFile[]> {
|
||||||
const params = albumId !== undefined ? { album: albumId } : {};
|
const params = albumId !== undefined ? { album: albumId } : {};
|
||||||
const response = await apiClient.get('/media/', { params });
|
const response = await apiClient.get('/media-files/', { params });
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ export async function listMediaFiles(albumId?: number | 'null'): Promise<MediaFi
|
|||||||
* Get a single media file
|
* Get a single media file
|
||||||
*/
|
*/
|
||||||
export async function getMediaFile(id: number): Promise<MediaFile> {
|
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;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ export async function uploadMediaFile(
|
|||||||
formData.append('alt_text', altText);
|
formData.append('alt_text', altText);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await apiClient.post('/media/', formData, {
|
const response = await apiClient.post('/media-files/', formData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
@@ -167,7 +167,7 @@ export async function updateMediaFile(
|
|||||||
id: number,
|
id: number,
|
||||||
data: MediaFileUpdatePayload
|
data: MediaFileUpdatePayload
|
||||||
): Promise<MediaFile> {
|
): Promise<MediaFile> {
|
||||||
const response = await apiClient.patch(`/media/${id}/`, data);
|
const response = await apiClient.patch(`/media-files/${id}/`, data);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +175,7 @@ export async function updateMediaFile(
|
|||||||
* Delete a media file
|
* Delete a media file
|
||||||
*/
|
*/
|
||||||
export async function deleteMediaFile(id: number): Promise<void> {
|
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[],
|
fileIds: number[],
|
||||||
albumId: number | null
|
albumId: number | null
|
||||||
): Promise<{ updated: number }> {
|
): Promise<{ updated: number }> {
|
||||||
const response = await apiClient.post('/media/bulk_move/', {
|
const response = await apiClient.post('/media-files/bulk_move/', {
|
||||||
file_ids: fileIds,
|
file_ids: fileIds,
|
||||||
album_id: albumId,
|
album_id: albumId,
|
||||||
});
|
});
|
||||||
@@ -196,7 +196,7 @@ export async function bulkMoveFiles(
|
|||||||
* Delete multiple files
|
* Delete multiple files
|
||||||
*/
|
*/
|
||||||
export async function bulkDeleteFiles(fileIds: number[]): Promise<{ deleted: number }> {
|
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,
|
file_ids: fileIds,
|
||||||
});
|
});
|
||||||
return response.data;
|
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">
|
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
{feature.name}
|
{feature.name}
|
||||||
</span>
|
</span>
|
||||||
|
<code className="text-xs text-gray-400 dark:text-gray-500 block mt-0.5 font-mono">
|
||||||
|
{feature.code}
|
||||||
|
</code>
|
||||||
{feature.description && (
|
{feature.description && (
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
|
<span className="text-xs text-gray-500 dark:text-gray-400 block mt-0.5">
|
||||||
{feature.description}
|
{feature.description}
|
||||||
@@ -207,17 +210,22 @@ export const FeaturePicker: React.FC<FeaturePickerProps> = ({
|
|||||||
: 'border-gray-200 dark:border-gray-700'
|
: '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
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={selected}
|
checked={selected}
|
||||||
onChange={() => toggleIntegerFeature(feature.code)}
|
onChange={() => toggleIntegerFeature(feature.code)}
|
||||||
aria-label={feature.name}
|
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">
|
<div className="flex-1 min-w-0">
|
||||||
{feature.name}
|
<span className="text-sm font-medium text-gray-900 dark:text-white block">
|
||||||
</span>
|
{feature.name}
|
||||||
|
</span>
|
||||||
|
<code className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||||
|
{feature.code}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
{selected && (
|
{selected && (
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export const FEATURE_NAMES: Record<FeatureKey, string> = {
|
|||||||
webhooks: 'Webhooks',
|
webhooks: 'Webhooks',
|
||||||
api_access: 'API Access',
|
api_access: 'API Access',
|
||||||
custom_domain: 'Custom Domain',
|
custom_domain: 'Custom Domain',
|
||||||
|
custom_branding: 'Custom Branding',
|
||||||
remove_branding: 'Remove Branding',
|
remove_branding: 'Remove Branding',
|
||||||
custom_oauth: 'Custom OAuth',
|
custom_oauth: 'Custom OAuth',
|
||||||
automations: 'Automations',
|
automations: 'Automations',
|
||||||
@@ -104,6 +105,7 @@ export const FEATURE_DESCRIPTIONS: Record<FeatureKey, string> = {
|
|||||||
webhooks: 'Integrate with external services using webhooks',
|
webhooks: 'Integrate with external services using webhooks',
|
||||||
api_access: 'Access the SmoothSchedule API for custom integrations',
|
api_access: 'Access the SmoothSchedule API for custom integrations',
|
||||||
custom_domain: 'Use your own custom domain for your booking site',
|
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',
|
remove_branding: 'Remove SmoothSchedule branding from customer-facing pages',
|
||||||
custom_oauth: 'Configure your own OAuth credentials for social login',
|
custom_oauth: 'Configure your own OAuth credentials for social login',
|
||||||
automations: 'Automate repetitive tasks with custom workflows',
|
automations: 'Automate repetitive tasks with custom workflows',
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ interface ParentContext {
|
|||||||
|
|
||||||
// Map settings pages to their required plan features
|
// Map settings pages to their required plan features
|
||||||
const SETTINGS_PAGE_FEATURES: Record<string, FeatureKey> = {
|
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/custom-domains': 'custom_domain',
|
||||||
'/dashboard/settings/api': 'api_access',
|
'/dashboard/settings/api': 'api_access',
|
||||||
'/dashboard/settings/authentication': 'custom_oauth',
|
'/dashboard/settings/authentication': 'custom_oauth',
|
||||||
@@ -154,7 +154,7 @@ const SettingsLayout: React.FC = () => {
|
|||||||
icon={Palette}
|
icon={Palette}
|
||||||
label={t('settings.appearance.title', 'Appearance')}
|
label={t('settings.appearance.title', 'Appearance')}
|
||||||
description={t('settings.appearance.description', 'Logo, colors, theme')}
|
description={t('settings.appearance.description', 'Logo, colors, theme')}
|
||||||
locked={isLocked('remove_branding')}
|
locked={isLocked('custom_branding')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{hasSettingsPermission('can_access_settings_email_templates') && (
|
{hasSettingsPermission('can_access_settings_email_templates') && (
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Modal, FormInput, Button, Alert, FormTextarea } from '../components/ui';
|
import { Modal, FormInput, Button, FormTextarea } from '../components/ui';
|
||||||
import {
|
import {
|
||||||
Album,
|
Album,
|
||||||
MediaFile,
|
MediaFile,
|
||||||
@@ -55,7 +55,7 @@ interface StorageUsageBarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const StorageUsageBar: React.FC<StorageUsageBarProps> = ({ usage, isLoading }) => {
|
const StorageUsageBar: React.FC<StorageUsageBarProps> = ({ usage, isLoading }) => {
|
||||||
if (isLoading || !usage) {
|
if (isLoading) {
|
||||||
return (
|
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="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" />
|
<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 percentUsed = usage.percent_used;
|
||||||
const barColor =
|
const barColor =
|
||||||
percentUsed >= 95
|
percentUsed >= 95
|
||||||
@@ -291,10 +295,27 @@ const ImagePreviewModal: React.FC<ImagePreviewModalProps> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyUrl = () => {
|
const handleCopyUrl = async () => {
|
||||||
navigator.clipboard.writeText(file.url);
|
try {
|
||||||
setCopied(true);
|
// Try modern clipboard API first
|
||||||
setTimeout(() => setCopied(false), 2000);
|
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 (
|
return (
|
||||||
@@ -763,12 +784,17 @@ const MediaGalleryPage: React.FC = () => {
|
|||||||
{/* Storage usage */}
|
{/* Storage usage */}
|
||||||
<StorageUsageBar usage={storageUsage} isLoading={usageLoading} />
|
<StorageUsageBar usage={storageUsage} isLoading={usageLoading} />
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error Modal */}
|
||||||
{error && (
|
<Modal isOpen={!!error} onClose={() => setError(null)} title="Error" size="sm">
|
||||||
<Alert type="error" className="mt-4" onClose={() => setError(null)}>
|
<div className="text-red-600 dark:text-red-400">
|
||||||
<pre className="whitespace-pre-wrap text-sm">{error}</pre>
|
<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 */}
|
{/* Bulk actions bar */}
|
||||||
{selectedFiles.size > 0 && (
|
{selectedFiles.size > 0 && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { ComponentConfig } from '@measured/puck';
|
import type { ComponentConfig } from '@measured/puck';
|
||||||
import type { EmailHeaderProps } from './types';
|
import type { EmailHeaderProps } from './types';
|
||||||
|
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EmailHeader - Business logo and name header
|
* EmailHeader - Business logo and name header
|
||||||
@@ -61,8 +62,8 @@ export const EmailHeader: ComponentConfig<EmailHeaderProps> = {
|
|||||||
label: 'Email Header',
|
label: 'Email Header',
|
||||||
fields: {
|
fields: {
|
||||||
logoUrl: {
|
logoUrl: {
|
||||||
type: 'text',
|
...imagePickerField,
|
||||||
label: 'Logo URL',
|
label: 'Logo Image',
|
||||||
},
|
},
|
||||||
businessName: {
|
businessName: {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { ComponentConfig } from '@measured/puck';
|
import type { ComponentConfig } from '@measured/puck';
|
||||||
import type { EmailImageProps } from './types';
|
import type { EmailImageProps } from './types';
|
||||||
|
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* EmailImage - Image component
|
* EmailImage - Image component
|
||||||
@@ -12,8 +13,8 @@ export const EmailImage: ComponentConfig<EmailImageProps> = {
|
|||||||
label: 'Email Image',
|
label: 'Email Image',
|
||||||
fields: {
|
fields: {
|
||||||
src: {
|
src: {
|
||||||
type: 'text',
|
...imagePickerField,
|
||||||
label: 'Image URL',
|
label: 'Image',
|
||||||
},
|
},
|
||||||
alt: {
|
alt: {
|
||||||
type: 'text',
|
type: 'text',
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
applyDesignControls,
|
applyDesignControls,
|
||||||
type DesignControlsProps,
|
type DesignControlsProps,
|
||||||
} from '../../theme';
|
} from '../../theme';
|
||||||
|
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||||
|
|
||||||
export interface Feature {
|
export interface Feature {
|
||||||
heading: string;
|
heading: string;
|
||||||
@@ -113,7 +114,7 @@ export const ContentBlocks: ComponentConfig<ContentBlocksProps> = {
|
|||||||
// Using external field for string arrays
|
// Using external field for string arrays
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
image: { type: 'text', label: 'Image URL' },
|
image: { ...imagePickerField, label: 'Image' },
|
||||||
},
|
},
|
||||||
defaultItemProps: {
|
defaultItemProps: {
|
||||||
heading: 'Feature Heading',
|
heading: 'Feature Heading',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { ComponentConfig } from '@measured/puck';
|
import type { ComponentConfig } from '@measured/puck';
|
||||||
import { Twitter, Linkedin, Github, Facebook, Instagram, Youtube } from 'lucide-react';
|
import { Twitter, Linkedin, Github, Facebook, Instagram, Youtube } from 'lucide-react';
|
||||||
|
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||||
|
|
||||||
export interface FooterLink {
|
export interface FooterLink {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -156,8 +157,8 @@ export const Footer: ComponentConfig<FooterProps> = {
|
|||||||
label: 'Brand Name',
|
label: 'Brand Name',
|
||||||
},
|
},
|
||||||
brandLogo: {
|
brandLogo: {
|
||||||
type: 'text',
|
...imagePickerField,
|
||||||
label: 'Brand Logo URL',
|
label: 'Brand Logo',
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
type: 'textarea',
|
type: 'textarea',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
applyDesignControls,
|
applyDesignControls,
|
||||||
type DesignControlsProps,
|
type DesignControlsProps,
|
||||||
} from '../../theme';
|
} from '../../theme';
|
||||||
|
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||||
|
|
||||||
export interface GalleryItem {
|
export interface GalleryItem {
|
||||||
image: string;
|
image: string;
|
||||||
@@ -105,7 +106,7 @@ export const GalleryGrid: ComponentConfig<GalleryGridProps> = {
|
|||||||
type: 'array',
|
type: 'array',
|
||||||
label: 'Items',
|
label: 'Items',
|
||||||
arrayFields: {
|
arrayFields: {
|
||||||
image: { type: 'text', label: 'Image URL' },
|
image: { ...imagePickerField, label: 'Image' },
|
||||||
title: { type: 'text', label: 'Title' },
|
title: { type: 'text', label: 'Title' },
|
||||||
text: { type: 'textarea', label: 'Description' },
|
text: { type: 'textarea', label: 'Description' },
|
||||||
link: { type: 'text', label: 'Link URL (optional)' },
|
link: { type: 'text', label: 'Link URL (optional)' },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import type { ComponentConfig } from '@measured/puck';
|
import type { ComponentConfig } from '@measured/puck';
|
||||||
import { Menu, X } from 'lucide-react';
|
import { Menu, X } from 'lucide-react';
|
||||||
|
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||||
|
|
||||||
export interface HeaderLink {
|
export interface HeaderLink {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -145,8 +146,8 @@ export const Header: ComponentConfig<HeaderProps> = {
|
|||||||
label: 'Business Name',
|
label: 'Business Name',
|
||||||
},
|
},
|
||||||
brandLogo: {
|
brandLogo: {
|
||||||
type: 'text',
|
...imagePickerField,
|
||||||
label: 'Logo URL',
|
label: 'Logo Image',
|
||||||
},
|
},
|
||||||
links: {
|
links: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
applyDesignControls,
|
applyDesignControls,
|
||||||
type DesignControlsProps,
|
type DesignControlsProps,
|
||||||
} from '../../theme';
|
} from '../../theme';
|
||||||
|
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||||
|
|
||||||
export interface HeroCtaButton {
|
export interface HeroCtaButton {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -208,7 +209,7 @@ export const Hero: ComponentConfig<HeroProps> = {
|
|||||||
{ label: 'Image', value: 'image' },
|
{ label: 'Image', value: 'image' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
src: { type: 'text', label: 'Image URL' },
|
src: { ...imagePickerField, label: 'Image' },
|
||||||
alt: { type: 'text', label: 'Alt Text' },
|
alt: { type: 'text', label: 'Alt Text' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
applyDesignControls,
|
applyDesignControls,
|
||||||
type DesignControlsProps,
|
type DesignControlsProps,
|
||||||
} from '../../theme';
|
} from '../../theme';
|
||||||
|
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||||
|
|
||||||
export interface Logo {
|
export interface Logo {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -102,7 +103,7 @@ export const LogoCloud: ComponentConfig<LogoCloudProps> = {
|
|||||||
type: 'array',
|
type: 'array',
|
||||||
label: 'Logos',
|
label: 'Logos',
|
||||||
arrayFields: {
|
arrayFields: {
|
||||||
src: { type: 'text', label: 'Image URL' },
|
src: { ...imagePickerField, label: 'Logo Image' },
|
||||||
alt: { type: 'text', label: 'Alt Text' },
|
alt: { type: 'text', label: 'Alt Text' },
|
||||||
href: { type: 'text', label: 'Link URL (optional)' },
|
href: { type: 'text', label: 'Link URL (optional)' },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
applyDesignControls,
|
applyDesignControls,
|
||||||
type DesignControlsProps,
|
type DesignControlsProps,
|
||||||
} from '../../theme';
|
} from '../../theme';
|
||||||
|
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||||
|
|
||||||
export interface Bullet {
|
export interface Bullet {
|
||||||
text: string;
|
text: string;
|
||||||
@@ -223,7 +224,7 @@ export const SplitContent: ComponentConfig<SplitContentProps> = {
|
|||||||
type: 'object',
|
type: 'object',
|
||||||
label: 'Media',
|
label: 'Media',
|
||||||
objectFields: {
|
objectFields: {
|
||||||
src: { type: 'text', label: 'Image URL' },
|
src: { ...imagePickerField, label: 'Image' },
|
||||||
alt: { type: 'text', label: 'Alt Text' },
|
alt: { type: 'text', label: 'Alt Text' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
applyDesignControls,
|
applyDesignControls,
|
||||||
type DesignControlsProps,
|
type DesignControlsProps,
|
||||||
} from '../../theme';
|
} from '../../theme';
|
||||||
|
import { imagePickerField } from '../../fields/ImagePickerField';
|
||||||
|
|
||||||
export interface TestimonialItem {
|
export interface TestimonialItem {
|
||||||
quote: string;
|
quote: string;
|
||||||
@@ -180,7 +181,7 @@ export const Testimonials: ComponentConfig<TestimonialsProps> = {
|
|||||||
quote: { type: 'textarea', label: 'Quote' },
|
quote: { type: 'textarea', label: 'Quote' },
|
||||||
name: { type: 'text', label: 'Name' },
|
name: { type: 'text', label: 'Name' },
|
||||||
title: { type: 'text', label: 'Title/Company' },
|
title: { type: 'text', label: 'Title/Company' },
|
||||||
avatar: { type: 'text', label: 'Avatar URL' },
|
avatar: { ...imagePickerField, label: 'Avatar Image' },
|
||||||
rating: {
|
rating: {
|
||||||
type: 'select',
|
type: 'select',
|
||||||
label: 'Rating',
|
label: 'Rating',
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export interface PlanPermissions {
|
|||||||
webhooks: boolean;
|
webhooks: boolean;
|
||||||
api_access: boolean;
|
api_access: boolean;
|
||||||
custom_domain: boolean;
|
custom_domain: boolean;
|
||||||
|
custom_branding: boolean;
|
||||||
remove_branding: boolean;
|
remove_branding: boolean;
|
||||||
custom_oauth: boolean;
|
custom_oauth: boolean;
|
||||||
automations: boolean;
|
automations: boolean;
|
||||||
|
|||||||
@@ -33,11 +33,9 @@ class StorageQuotaService:
|
|||||||
|
|
||||||
# Get storage_gb feature value from billing
|
# Get storage_gb feature value from billing
|
||||||
if hasattr(tenant, 'billing_subscription') and tenant.billing_subscription:
|
if hasattr(tenant, 'billing_subscription') and tenant.billing_subscription:
|
||||||
storage_gb = EntitlementService.get_feature_value(
|
storage_gb = EntitlementService.get_limit(tenant, 'storage_gb')
|
||||||
tenant,
|
if storage_gb is None:
|
||||||
'storage_gb',
|
storage_gb = StorageQuotaService.DEFAULT_STORAGE_GB
|
||||||
default=StorageQuotaService.DEFAULT_STORAGE_GB
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
storage_gb = StorageQuotaService.DEFAULT_STORAGE_GB
|
storage_gb = StorageQuotaService.DEFAULT_STORAGE_GB
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ def current_business_view(request):
|
|||||||
'webhooks': tenant.has_feature('integrations_enabled'),
|
'webhooks': tenant.has_feature('integrations_enabled'),
|
||||||
'api_access': tenant.has_feature('api_access'),
|
'api_access': tenant.has_feature('api_access'),
|
||||||
'custom_domain': tenant.has_feature('custom_domain'),
|
'custom_domain': tenant.has_feature('custom_domain'),
|
||||||
|
'custom_branding': tenant.has_feature('custom_branding'),
|
||||||
'remove_branding': tenant.has_feature('remove_branding'),
|
'remove_branding': tenant.has_feature('remove_branding'),
|
||||||
'custom_oauth': tenant.has_feature('can_manage_oauth'),
|
'custom_oauth': tenant.has_feature('can_manage_oauth'),
|
||||||
'automations': tenant.has_feature('can_use_automations'),
|
'automations': tenant.has_feature('can_use_automations'),
|
||||||
|
|||||||
@@ -1413,7 +1413,7 @@ class AlbumSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
Provides read-only computed fields for file_count and cover_url.
|
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()
|
cover_url = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -1424,23 +1424,30 @@ class AlbumSerializer(serializers.ModelSerializer):
|
|||||||
]
|
]
|
||||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||||
|
|
||||||
def get_cover_url(self, obj):
|
def get_file_count(self, obj):
|
||||||
"""Get the URL of the cover image."""
|
"""Get file count from annotation or compute it."""
|
||||||
if obj.cover_image and obj.cover_image.file:
|
# Try to get from annotation first (more efficient for list views)
|
||||||
return obj.cover_image.file.url
|
if hasattr(obj, 'annotated_file_count'):
|
||||||
# If no explicit cover, use first file in album
|
return obj.annotated_file_count
|
||||||
first_file = obj.files.first()
|
# Fall back to model property
|
||||||
if first_file and first_file.file:
|
return obj.file_count
|
||||||
return first_file.file.url
|
|
||||||
return None
|
|
||||||
|
|
||||||
def to_representation(self, instance):
|
def get_cover_url(self, obj):
|
||||||
"""Add file_count if not already annotated."""
|
"""Get the full URL of the cover image."""
|
||||||
data = super().to_representation(instance)
|
request = self.context.get('request')
|
||||||
# If file_count wasn't annotated in the queryset, compute it
|
url = None
|
||||||
if data.get('file_count') is None:
|
|
||||||
data['file_count'] = instance.files.count()
|
if obj.cover_image and obj.cover_image.file:
|
||||||
return data
|
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):
|
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']
|
read_only_fields = ['id', 'filename', 'file_size', 'width', 'height', 'mime_type', 'created_at']
|
||||||
|
|
||||||
def get_url(self, obj):
|
def get_url(self, obj):
|
||||||
"""Get the URL to access the file."""
|
"""Get the full URL to access the file."""
|
||||||
if obj.file:
|
if obj.file:
|
||||||
|
request = self.context.get('request')
|
||||||
|
if request:
|
||||||
|
return request.build_absolute_uri(obj.file.url)
|
||||||
return obj.file.url
|
return obj.file.url
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ router.register(r'holidays', HolidayViewSet, basename='holiday')
|
|||||||
router.register(r'time-blocks', TimeBlockViewSet, basename='timeblock')
|
router.register(r'time-blocks', TimeBlockViewSet, basename='timeblock')
|
||||||
router.register(r'locations', LocationViewSet, basename='location')
|
router.register(r'locations', LocationViewSet, basename='location')
|
||||||
router.register(r'albums', AlbumViewSet, basename='album')
|
router.register(r'albums', AlbumViewSet, basename='album')
|
||||||
router.register(r'media', MediaFileViewSet, basename='media')
|
router.register(r'media-files', MediaFileViewSet, basename='media')
|
||||||
|
|
||||||
# URL patterns
|
# URL patterns
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|||||||
@@ -1983,7 +1983,7 @@ class AlbumViewSet(TenantFilteredQuerySetMixin, viewsets.ModelViewSet):
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""Annotate with file count for list view."""
|
"""Annotate with file count for list view."""
|
||||||
queryset = super().get_queryset()
|
queryset = super().get_queryset()
|
||||||
return queryset.annotate(file_count=Count('files'))
|
return queryset.annotate(annotated_file_count=Count('files'))
|
||||||
|
|
||||||
def perform_destroy(self, instance):
|
def perform_destroy(self, instance):
|
||||||
"""When deleting an album, move files to uncategorized (null album)."""
|
"""When deleting an album, move files to uncategorized (null album)."""
|
||||||
|
|||||||
Reference in New Issue
Block a user