refactor: standardize UI components, modernize localization structure, and remove legacy appearance management middleware.

This commit is contained in:
Paulo Castellano 2026-05-06 14:26:18 -03:00
parent 768760cdda
commit acdc78f8bd
140 changed files with 2114 additions and 1792 deletions

View file

@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware\App;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class HandleAppearance
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
View::share('appearance', $request->cookie('appearance') ?? 'system');
return $next($request);
}
}

View file

@ -1,7 +1,6 @@
<?php
use App\Http\Middleware\Api\LoadWorkspaceFromToken;
use App\Http\Middleware\App\HandleAppearance;
use App\Http\Middleware\App\HandleInertiaRequests;
use App\Http\Middleware\App\SetLocale;
use Illuminate\Foundation\Application;
@ -23,11 +22,10 @@
->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: '*');
$middleware->encryptCookies(except: ['appearance', 'sidebar_state', 'locale']);
$middleware->encryptCookies(except: ['sidebar_state', 'locale']);
$middleware->web(append: [
SetLocale::class,
HandleAppearance::class,
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,
]);

View file

@ -7,6 +7,7 @@
'add_social' => 'Add Social',
'add_social_title' => 'Connect a Social Account',
'add_social_description' => 'Connect a social account to TryPost to start posting',
'connect_cta' => 'Connect',
'no_accounts' => 'No accounts connected yet',
'no_accounts_description' => 'Connect your social networks to start scheduling and publishing posts',
'no_search_results' => 'No accounts match your search',

View file

@ -24,25 +24,26 @@
'subscribe' => [
'page_title' => 'Choose your plan',
'eyebrow' => 'Pricing',
'title' => 'Choose the right plan for you',
'description' => 'Start with a 7-day free trial. No charge until your trial ends.',
'trial_info' => '7-day free trial, then billed automatically',
'monthly' => 'Monthly',
'yearly' => 'Yearly',
'per_month' => 'mo',
'per_year' => 'yr',
'billed_monthly' => 'billed monthly',
'billed_yearly' => 'billed yearly',
'per_month' => 'monthly',
'per_year' => 'yearly',
'billed_monthly' => 'Billed monthly',
'billed_yearly' => 'Billed annually',
'features_included' => "What's included:",
'everything_in' => 'Everything in :plan, plus:',
'save_months' => '2 months free',
'popular' => 'Most popular',
'start_trial' => 'Start 7-day free trial',
'card_required' => 'Credit card required to start your trial.',
'cancel_anytime' => 'No charges will be made before the trial period ends.',
'prices' => [
'starter' => ['monthly' => '$19', 'yearly' => '$190'],
'plus' => ['monthly' => '$29', 'yearly' => '$290'],
'pro' => ['monthly' => '$49', 'yearly' => '$490'],
'max' => ['monthly' => '$99', 'yearly' => '$990'],
'starter' => ['monthly' => '$19', 'yearly_per_month' => '$16', 'yearly' => '$190'],
'plus' => ['monthly' => '$29', 'yearly_per_month' => '$24', 'yearly' => '$290'],
'pro' => ['monthly' => '$49', 'yearly_per_month' => '$41', 'yearly' => '$490'],
'max' => ['monthly' => '$99', 'yearly_per_month' => '$83', 'yearly' => '$990'],
],
'features' => [
'social_accounts' => ':count social accounts',
@ -50,6 +51,12 @@
'members' => ':count team members',
'credits' => ':count AI credits/mo',
],
'credit_tooltips' => [
'starter' => 'Roughly 150 medium-length posts plus 5 AI images per month.',
'plus' => 'Roughly 300 medium-length posts plus 10 AI images per month.',
'pro' => 'Roughly 700 medium-length posts plus 30 AI images per month.',
'max' => 'Roughly 2,000 medium-length posts plus 100 AI images per month.',
],
],
'plan' => [

View file

@ -13,4 +13,6 @@
'replying_to' => 'Replying to :name',
'empty' => 'No comments yet. Start the conversation.',
'load_more' => 'Load older comments',
'today' => 'Today',
'yesterday' => 'Yesterday',
];

View file

@ -29,5 +29,6 @@
'cancel' => 'Cancel',
'clear' => 'Clear',
'close' => 'Close',
'loading_more' => 'Loading more...',
];

View file

@ -14,6 +14,11 @@
'created_at' => 'Created',
],
'actions' => [
'edit' => 'Edit label',
'delete' => 'Delete label',
],
'create' => [
'title' => 'Create Label',
'description' => 'Give your label a name and pick a color',

View file

@ -10,6 +10,7 @@
'try_different_search' => 'Try a different keyword or clear the search.',
'start_creating' => 'Start by creating your first post.',
'table' => [
'post' => 'Post',
'status' => 'Status',
'content' => 'Content',
'platforms' => 'Platforms',
@ -230,8 +231,9 @@
'cancel' => 'Cancel',
'delete' => 'Delete',
'schedule_for' => 'Schedule for',
'scheduled_for' => 'Scheduled for',
'scheduled_for' => 'Scheduled · :date',
'schedule_date' => 'Schedule date',
'unschedule' => 'Unschedule',
'saving' => 'Saving...',
'saved' => 'Saved',
'draft' => 'Draft',
@ -305,6 +307,7 @@
],
'status' => [
'scheduled' => 'Scheduled',
'published' => 'Published',
'publishing' => 'Publishing...',
'failed' => 'Failed',
@ -539,6 +542,8 @@
'try_different_search' => 'Try a different keyword or clear the search.',
'slides_count' => '{count} slide|{count} slides',
'all_platforms' => 'All platforms',
'platform_search_placeholder' => 'Search platform…',
'no_platform_match' => 'No platform matches.',
'use_this' => 'Use this template',
'no_templates' => 'No templates available.',
'applying' => 'Applying template…',

View file

@ -15,6 +15,11 @@
'created_at' => 'Created',
],
'actions' => [
'edit' => 'Edit signature',
'delete' => 'Delete signature',
],
'create' => [
'title' => 'Create signature',
'description' => 'Give your signature a name and the content to append (hashtags, links, custom text — anything you reuse).',

View file

@ -7,6 +7,7 @@
'add_social' => 'Agregar Red Social',
'add_social_title' => 'Conectar una Cuenta Social',
'add_social_description' => 'Conecta una cuenta social a TryPost para empezar a publicar',
'connect_cta' => 'Conectar',
'no_accounts' => 'No hay cuentas conectadas todavía',
'no_accounts_description' => 'Conecta tus redes sociales para empezar a programar y publicar posts',
'no_search_results' => 'Ninguna cuenta coincide con tu búsqueda',

View file

@ -24,25 +24,26 @@
'subscribe' => [
'page_title' => 'Elige tu plan',
'eyebrow' => 'Precios',
'title' => 'Elige el plan ideal para ti',
'description' => 'Comienza con 7 días gratis. Sin cargo hasta que termine tu prueba.',
'trial_info' => 'Prueba gratuita de 7 días, luego se cobra automáticamente',
'monthly' => 'Mensual',
'yearly' => 'Anual',
'per_month' => 'mes',
'per_year' => 'año',
'billed_monthly' => 'facturado mensualmente',
'billed_yearly' => 'facturado anualmente',
'per_month' => 'mensual',
'per_year' => 'anual',
'billed_monthly' => 'Facturado mensualmente',
'billed_yearly' => 'Facturado anualmente',
'features_included' => 'Qué incluye:',
'everything_in' => 'Todo lo de :plan, más:',
'save_months' => '2 meses gratis',
'popular' => 'Más popular',
'start_trial' => 'Comenzar prueba de 7 días',
'card_required' => 'Tarjeta de crédito requerida para iniciar tu prueba.',
'cancel_anytime' => 'No se realizará ningún cobro antes de que termine el período de prueba.',
'prices' => [
'starter' => ['monthly' => '$19', 'yearly' => '$190'],
'plus' => ['monthly' => '$29', 'yearly' => '$290'],
'pro' => ['monthly' => '$49', 'yearly' => '$490'],
'max' => ['monthly' => '$99', 'yearly' => '$990'],
'starter' => ['monthly' => '$19', 'yearly_per_month' => '$16', 'yearly' => '$190'],
'plus' => ['monthly' => '$29', 'yearly_per_month' => '$24', 'yearly' => '$290'],
'pro' => ['monthly' => '$49', 'yearly_per_month' => '$41', 'yearly' => '$490'],
'max' => ['monthly' => '$99', 'yearly_per_month' => '$83', 'yearly' => '$990'],
],
'features' => [
'social_accounts' => ':count cuentas sociales',
@ -50,6 +51,12 @@
'members' => ':count miembros del equipo',
'credits' => ':count créditos IA/mes',
],
'credit_tooltips' => [
'starter' => 'En promedio 150 posts de largo medio + 5 imágenes IA por mes.',
'plus' => 'En promedio 300 posts de largo medio + 10 imágenes IA por mes.',
'pro' => 'En promedio 700 posts de largo medio + 30 imágenes IA por mes.',
'max' => 'En promedio 2.000 posts de largo medio + 100 imágenes IA por mes.',
],
],
'plan' => [

View file

@ -15,4 +15,6 @@
'replying_to' => 'Respondiendo a :name',
'empty' => 'Todavía no hay comentarios. Inicia la conversación.',
'load_more' => 'Cargar comentarios anteriores',
'today' => 'Hoy',
'yesterday' => 'Ayer',
];

View file

@ -29,5 +29,6 @@
'cancel' => 'Cancelar',
'clear' => 'Limpiar',
'close' => 'Cerrar',
'loading_more' => 'Cargando más...',
];

View file

@ -14,6 +14,11 @@
'created_at' => 'Creado',
],
'actions' => [
'edit' => 'Editar etiqueta',
'delete' => 'Eliminar etiqueta',
],
'create' => [
'title' => 'Crear etiqueta',
'description' => 'Dale un nombre y elige un color para tu etiqueta',

View file

@ -10,6 +10,7 @@
'try_different_search' => 'Prueba otra palabra clave o limpia la búsqueda.',
'start_creating' => 'Empieza creando tu primer post.',
'table' => [
'post' => 'Post',
'status' => 'Estado',
'content' => 'Contenido',
'platforms' => 'Plataformas',
@ -226,7 +227,8 @@
'schedule' => 'Programar',
'delete' => 'Eliminar',
'schedule_for' => 'Programar para',
'scheduled_for' => 'Programado para',
'scheduled_for' => 'Programado · :date',
'unschedule' => 'Desprogramar',
'saving' => 'Guardando...',
'saved' => 'Guardado',
'draft' => 'Borrador',
@ -305,6 +307,7 @@
],
'status' => [
'scheduled' => 'Programado',
'published' => 'Publicado',
'publishing' => 'Publicando...',
'failed' => 'Fallido',
@ -540,6 +543,8 @@
'try_different_search' => 'Prueba otra palabra clave o limpia la búsqueda.',
'slides_count' => '{count} slide|{count} slides',
'all_platforms' => 'Todas las plataformas',
'platform_search_placeholder' => 'Buscar plataforma…',
'no_platform_match' => 'Ninguna plataforma coincide.',
'use_this' => 'Usar esta plantilla',
'no_templates' => 'No hay plantillas disponibles.',
'applying' => 'Aplicando plantilla…',

View file

@ -15,6 +15,11 @@
'created_at' => 'Creado',
],
'actions' => [
'edit' => 'Editar firma',
'delete' => 'Eliminar firma',
],
'create' => [
'title' => 'Crear firma',
'description' => 'Dale un nombre a tu firma y el contenido para añadir (hashtags, links, texto libre — lo que reutilizas).',

1
lang/php_en.json Normal file

File diff suppressed because one or more lines are too long

1
lang/php_es.json Normal file

File diff suppressed because one or more lines are too long

1
lang/php_pt-BR.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -7,6 +7,7 @@
'add_social' => 'Adicionar Rede Social',
'add_social_title' => 'Conectar uma Conta Social',
'add_social_description' => 'Conecte uma conta social ao TryPost para começar a publicar',
'connect_cta' => 'Conectar',
'no_accounts' => 'Nenhuma conta conectada ainda',
'no_accounts_description' => 'Conecte suas redes sociais para começar a agendar e publicar posts',
'no_search_results' => 'Nenhuma conta corresponde à sua busca',

View file

@ -24,25 +24,26 @@
'subscribe' => [
'page_title' => 'Escolha seu plano',
'eyebrow' => 'Preços',
'title' => 'Escolha o plano ideal pra você',
'description' => 'Comece com 7 dias grátis. Sem cobrança até o fim do seu teste.',
'trial_info' => '7 dias grátis, depois cobrança automática',
'monthly' => 'Mensal',
'yearly' => 'Anual',
'per_month' => 'mês',
'per_year' => 'ano',
'billed_monthly' => 'cobrança mensal',
'billed_yearly' => 'cobrança anual',
'per_month' => 'mensal',
'per_year' => 'anual',
'billed_monthly' => 'Cobrança mensal',
'billed_yearly' => 'Cobrança anual',
'features_included' => 'O que está incluído:',
'everything_in' => 'Tudo do :plan, mais:',
'save_months' => '2 meses grátis',
'popular' => 'Mais popular',
'start_trial' => 'Iniciar teste de 7 dias',
'card_required' => 'Cartão de crédito necessário para iniciar o teste.',
'cancel_anytime' => 'Nenhuma cobrança será realizada antes do fim do período de testes.',
'prices' => [
'starter' => ['monthly' => 'R$ 95', 'yearly' => 'R$ 950'],
'plus' => ['monthly' => 'R$ 145', 'yearly' => 'R$ 1450'],
'pro' => ['monthly' => 'R$ 245', 'yearly' => 'R$ 2450'],
'max' => ['monthly' => 'R$ 495', 'yearly' => 'R$ 4950'],
'starter' => ['monthly' => 'R$ 95', 'yearly_per_month' => 'R$ 79', 'yearly' => 'R$ 950'],
'plus' => ['monthly' => 'R$ 145', 'yearly_per_month' => 'R$ 121', 'yearly' => 'R$ 1450'],
'pro' => ['monthly' => 'R$ 245', 'yearly_per_month' => 'R$ 204', 'yearly' => 'R$ 2450'],
'max' => ['monthly' => 'R$ 495', 'yearly_per_month' => 'R$ 413', 'yearly' => 'R$ 4950'],
],
'features' => [
'social_accounts' => ':count contas sociais',
@ -50,6 +51,12 @@
'members' => ':count membros da equipe',
'credits' => ':count créditos IA/mês',
],
'credit_tooltips' => [
'starter' => 'Em média 150 posts de tamanho médio + 5 imagens de IA por mês.',
'plus' => 'Em média 300 posts de tamanho médio + 10 imagens de IA por mês.',
'pro' => 'Em média 700 posts de tamanho médio + 30 imagens de IA por mês.',
'max' => 'Em média 2.000 posts de tamanho médio + 100 imagens de IA por mês.',
],
],
'plan' => [

View file

@ -15,4 +15,6 @@
'replying_to' => 'Respondendo a :name',
'empty' => 'Nenhum comentário ainda. Comece a conversa.',
'load_more' => 'Carregar comentários antigos',
'today' => 'Hoje',
'yesterday' => 'Ontem',
];

View file

@ -29,5 +29,6 @@
'cancel' => 'Cancelar',
'clear' => 'Limpar',
'close' => 'Fechar',
'loading_more' => 'Carregando mais...',
];

View file

@ -14,6 +14,11 @@
'created_at' => 'Criado',
],
'actions' => [
'edit' => 'Editar etiqueta',
'delete' => 'Excluir etiqueta',
],
'create' => [
'title' => 'Criar Etiqueta',
'description' => 'Dê um nome e escolha uma cor para sua etiqueta',

View file

@ -10,6 +10,7 @@
'try_different_search' => 'Tente outra palavra-chave ou limpe a busca.',
'start_creating' => 'Comece criando seu primeiro post.',
'table' => [
'post' => 'Post',
'status' => 'Status',
'content' => 'Conteúdo',
'platforms' => 'Plataformas',
@ -226,7 +227,8 @@
'schedule' => 'Agendar',
'delete' => 'Excluir',
'schedule_for' => 'Agendar para',
'scheduled_for' => 'Agendado para',
'scheduled_for' => 'Agendado · :date',
'unschedule' => 'Desagendar',
'saving' => 'Salvando...',
'saved' => 'Salvo',
'draft' => 'Rascunho',
@ -305,6 +307,7 @@
],
'status' => [
'scheduled' => 'Agendado',
'published' => 'Publicado',
'publishing' => 'Publicando...',
'failed' => 'Falhou',
@ -535,6 +538,8 @@
'browser_title' => 'Escolha um template',
'browser_description' => 'Comece com um template pronto e adapte ao seu jeito.',
'all_platforms' => 'Todas as plataformas',
'platform_search_placeholder' => 'Buscar plataforma…',
'no_platform_match' => 'Nenhuma plataforma corresponde.',
'use_this' => 'Usar este template',
'no_templates' => 'Nenhum template disponível.',
'applying' => 'Aplicando template…',

View file

@ -15,6 +15,11 @@
'created_at' => 'Criado em',
],
'actions' => [
'edit' => 'Editar assinatura',
'delete' => 'Excluir assinatura',
],
'create' => [
'title' => 'Criar assinatura',
'description' => 'Dê um nome à sua assinatura e o conteúdo pra anexar (hashtags, links, texto livre — o que você reutiliza).',

View file

@ -4,147 +4,79 @@
@plugin '@tailwindcss/typography';
@source '../../vendor/laravel/framework/src/Illuminate/Pagination/resources/views/*.blade.php';
@source '../../storage/framework/views/*.php';
@custom-variant dark (&:is(.dark *));
/* Dark mode is intentionally disabled to match the marketing site
(which is light-only). Tailwind v4 defaults `dark:` to the
`prefers-color-scheme: dark` media query, so without this override
the OS theme would still flip preview internals to dark. Pin the
variant to a `.dark` class that we never apply. */
@custom-variant dark (&:where(.dark, .dark *));
:root {
--background: #fafafa;
/* TryPost design system warm cream + ink + signature violet.
Mirrors the marketing site (~/Herd/trypost-site) so the app and the
site share one identity. All structural borders are ink-black. */
--background: #faf8f5;
--foreground: #0a0a0a;
--card: #ffffff;
--card-foreground: #0a0a0a;
--popover: #ffffff;
--popover-foreground: #0a0a0a;
--primary: #171717;
--primary-foreground: #fafafa;
--secondary: #e5e5e5;
--secondary-foreground: #171717;
--muted: #f5f5f5;
--muted-foreground: #737373;
--accent: #e5e5e5;
--accent-foreground: #171717;
--destructive: #dc2626;
--destructive-foreground: #fafafa;
--primary: #7c3aed;
--primary-foreground: #ffffff;
--secondary: #f4f4f0;
--secondary-foreground: #0a0a0a;
--muted: #f4f4f0;
--muted-foreground: #52525b;
--accent: #ede9fe;
--accent-foreground: #5b21b6;
--destructive: #e54b4f;
--destructive-foreground: #ffffff;
--success: #16a34a;
--error: #dc2626;
--error: #e54b4f;
--warning: #d97706;
--info: #2563eb;
--border: #e5e5e5;
--input: #d4d4d4;
--ring: #525252;
--chart-1: #1f3fad;
--chart-2: #1a4eda;
--chart-3: #2563ef;
--chart-4: #3a81f6;
--chart-5: #91c5ff;
--sidebar: #f5f5f5;
--border: #0a0a0a;
--input: #0a0a0a;
--ring: #7c3aed;
--chart-1: #7c3aed;
--chart-2: #8b5cf6;
--chart-3: #a78bfa;
--chart-4: #5b21b6;
--chart-5: #4c1d95;
--sidebar: #ffffff;
--sidebar-foreground: #0a0a0a;
--sidebar-primary: #1447e6;
--sidebar-primary-foreground: #fafafa;
--sidebar-accent: #e5e5e5;
--sidebar-accent-foreground: #171717;
--sidebar-border: #e5e5e5;
--sidebar-ring: #a3a3a3;
--font-sans:
'Inter', ui-sans-serif, system-ui, -apple-system,
--sidebar-primary: #7c3aed;
--sidebar-primary-foreground: #ffffff;
--sidebar-accent: #ede9fe;
--sidebar-accent-foreground: #5b21b6;
--sidebar-border: #0a0a0a;
--sidebar-ring: #7c3aed;
--font-sans: Figtree, ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
--radius: 0.725rem;
--shadow-x: 0;
--shadow-y: 1px;
--shadow-blur: 2.5px;
--shadow-spread: 0px;
--shadow-opacity: 0.04;
--shadow-color: oklch(0.3065 0.0243 243.82);
--shadow-2xs: 0 1px 2.5px 0px hsl(0 0% 0% / 0.02);
--shadow-xs: 0 1px 2.5px 0px hsl(0 0% 0% / 0.02);
--shadow-sm:
0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 1px 2px -1px hsl(0 0% 0% / 0.04);
--shadow:
0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 1px 2px -1px hsl(0 0% 0% / 0.04);
--shadow-md:
0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 2px 4px -1px hsl(0 0% 0% / 0.04);
--shadow-lg:
0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 4px 6px -1px hsl(0 0% 0% / 0.04);
--shadow-xl:
0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 8px 10px -1px hsl(0 0% 0% / 0.04);
--shadow-2xl: 0 1px 2.5px 0px hsl(0 0% 0% / 0.1);
--font-serif: 'Instrument Serif', ui-serif, Georgia, Cambria,
'Times New Roman', Times, serif;
--font-display: 'Instrument Serif', ui-serif, Georgia, Cambria,
'Times New Roman', Times, serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco,
Consolas, 'Liberation Mono', 'Courier New', monospace;
--radius: 0.75rem;
/* Gumroad-style offset shadows: solid ink, no blur. */
--shadow-2xs: 1px 1px 0 0 #0a0a0a;
--shadow-xs: 2px 2px 0 0 #0a0a0a;
--shadow-sm: 3px 3px 0 0 #0a0a0a;
--shadow: 4px 4px 0 0 #0a0a0a;
--shadow-md: 5px 5px 0 0 #0a0a0a;
--shadow-lg: 6px 6px 0 0 #0a0a0a;
--shadow-xl: 8px 8px 0 0 #0a0a0a;
--shadow-2xl: 10px 10px 0 0 #0a0a0a;
--tracking-normal: 0em;
--spacing: 0.25rem;
}
.dark {
--background: #0a0a0a;
--foreground: #fafafa;
--card: #171717;
--card-foreground: #fafafa;
--popover: #262626;
--popover-foreground: #fafafa;
--primary: #e5e5e5;
--primary-foreground: #171717;
--secondary: #262626;
--secondary-foreground: #fafafa;
--muted: #262626;
--muted-foreground: #a1a1a1;
--accent: #404040;
--accent-foreground: #fafafa;
--destructive: #ff6467;
--destructive-foreground: #fafafa;
--success: #16a34a;
--error: #dc2626;
--warning: #d97706;
--info: #2563eb;
--border: #333333;
--input: #343434;
--ring: #737373;
--chart-1: #91c5ff;
--chart-2: #3a81f6;
--chart-3: #2563ef;
--chart-4: #1a4eda;
--chart-5: #1f3fad;
--sidebar: #171717;
--sidebar-foreground: #fafafa;
--sidebar-primary: #1447e6;
--sidebar-primary-foreground: #fafafa;
--sidebar-accent: #262626;
--sidebar-accent-foreground: #fafafa;
--sidebar-border: #282828;
--sidebar-ring: #525252;
--font-sans:
'Inter', ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
--font-serif: ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif;
--font-mono:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
'Liberation Mono', 'Courier New', monospace;
--radius: 0.725rem;
--shadow-x: 0;
--shadow-y: 1px;
--shadow-blur: 2.5px;
--shadow-spread: 0px;
--shadow-opacity: 0.04;
--shadow-color: oklch(0 0 0);
--shadow-2xs: 0 1px 2.5px 0px hsl(0 0% 0% / 0.02);
--shadow-xs: 0 1px 2.5px 0px hsl(0 0% 0% / 0.02);
--shadow-sm:
0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 1px 2px -1px hsl(0 0% 0% / 0.04);
--shadow:
0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 1px 2px -1px hsl(0 0% 0% / 0.04);
--shadow-md:
0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 2px 4px -1px hsl(0 0% 0% / 0.04);
--shadow-lg:
0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 4px 6px -1px hsl(0 0% 0% / 0.04);
--shadow-xl:
0 1px 2.5px 0px hsl(0 0% 0% / 0.04), 0 8px 10px -1px hsl(0 0% 0% / 0.04);
--shadow-2xl: 0 1px 2.5px 0px hsl(0 0% 0% / 0.1);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
@ -182,6 +114,7 @@ @theme inline {
--font-sans: var(--font-sans);
--font-mono: var(--font-mono);
--font-serif: var(--font-serif);
--font-display: var(--font-display);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
@ -205,5 +138,29 @@ @layer base {
body {
@apply bg-background text-foreground;
font-family: var(--font-sans);
}
}
/* Site headline + container utility classes mirrors trypost-site's
`assets/css/utility-patterns.css`. Use `.h1`/.h2`/.h3` for serif
display headlines (Instrument Serif) instead of stacking Tailwind
text-* utilities. */
.h1 {
font-family: var(--font-display);
@apply text-4xl sm:text-5xl lg:text-7xl xl:text-[5.5rem] font-normal tracking-tight leading-[1.05];
}
.h2 {
font-family: var(--font-display);
@apply text-pretty text-3xl font-normal tracking-tight leading-[1.1] sm:text-5xl;
}
.h3 {
font-family: var(--font-display);
@apply text-pretty text-2xl font-normal tracking-tight sm:text-4xl;
}
.trypost-container {
@apply relative max-w-7xl w-full flex-1 mx-auto flex flex-col justify-center;
}

View file

@ -7,7 +7,6 @@ import { i18nVue } from 'laravel-vue-i18n';
import type { DefineComponent } from 'vue';
import { createApp, h } from 'vue';
import { initializeTheme } from './composables/useAppearance';
import { initializeDataLayer } from './datalayer';
import dayjs from './dayjs';
import posthog from './posthog';
@ -79,6 +78,3 @@ createInertiaApp({
color: '#4B5563',
},
});
// This will set light / dark mode on page load...
initializeTheme();

View file

@ -17,13 +17,19 @@ withDefaults(
<template>
<header
class="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-border px-4"
class="flex h-14 shrink-0 items-center justify-between gap-2 border-b-2 border-foreground bg-card px-4"
>
<div class="flex items-center gap-2">
<SidebarTrigger class="-ml-1" />
<slot name="left">
<Breadcrumbs v-if="breadcrumbs.length > 0" :breadcrumbs="breadcrumbs" />
<h2 v-else-if="title" class="text-lg font-semibold">{{ title }}</h2>
<h2
v-else-if="title"
class="text-lg text-foreground"
style="font-family: var(--font-display)"
>
{{ title }}
</h2>
</slot>
</div>
<div class="flex items-center gap-2">

View file

@ -41,6 +41,7 @@ import {
SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar';
import { useActiveUrl } from '@/composables/useActiveUrl';
import { useFeatureAccess } from '@/composables/useFeatureAccess';
import { useUpgradeDialog } from '@/composables/useUpgradeDialog';
import { accounts, analytics, calendar, settings as settingsHub } from '@/routes/app';
@ -130,6 +131,7 @@ const switchWorkspace = (workspaceId: string) => {
const { canCreateWorkspace } = useFeatureAccess();
const { openUpgrade } = useUpgradeDialog();
const { urlIsActive } = useActiveUrl();
const handleCreateWorkspace = () => {
if (!canCreateWorkspace.value) {
@ -150,8 +152,8 @@ const handleCreateWorkspace = () => {
<SidebarMenuButton size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
<Avatar :src="currentWorkspace?.logo_url" :name="currentWorkspace?.name ?? '?'"
class="h-8 w-8 shrink-0 rounded-lg"
fallback-class="bg-sidebar-accent text-sidebar-accent-foreground" />
class="h-8 w-8 shrink-0 rounded-md border-2 border-foreground"
fallback-class="bg-violet-100 text-violet-700 font-bold" />
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">
{{ currentWorkspace?.name ?? $t('sidebar.select_workspace') }}
@ -160,22 +162,22 @@ const handleCreateWorkspace = () => {
<IconChevronRight class="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent class="w-[--reka-dropdown-menu-trigger-width] min-w-56 rounded-lg"
<DropdownMenuContent class="w-[--reka-dropdown-menu-trigger-width] min-w-56"
align="start" side="right" :side-offset="4">
<DropdownMenuLabel class="text-xs text-muted-foreground">
<DropdownMenuLabel>
{{ $t('sidebar.workspaces') }}
</DropdownMenuLabel>
<div class="space-y-0.5">
<DropdownMenuItem v-for="workspace in workspaces" :key="workspace.id" class="gap-2"
:class="workspace.id === currentWorkspace?.id ? 'bg-accent' : ''"
<DropdownMenuItem v-for="workspace in workspaces" :key="workspace.id"
:class="workspace.id === currentWorkspace?.id ? 'bg-accent text-accent-foreground' : ''"
@click="switchWorkspace(workspace.id)">
<Avatar :src="workspace.logo_url" :name="workspace.name"
class="h-5 w-5 shrink-0 rounded-md" fallback-class="text-[10px]" />
class="h-6 w-6 shrink-0 rounded-md border-2 border-foreground" fallback-class="text-[10px] bg-violet-100 text-violet-700 font-bold" />
{{ workspace.name }}
</DropdownMenuItem>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem class="gap-2" @click="handleCreateWorkspace">
<DropdownMenuItem @click="handleCreateWorkspace">
<IconPlus class="size-4" />
{{ $t('sidebar.create_workspace') }}
</DropdownMenuItem>
@ -204,7 +206,11 @@ const handleCreateWorkspace = () => {
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton as-child :tooltip="trans('sidebar.settings')">
<SidebarMenuButton
as-child
:tooltip="trans('sidebar.settings')"
:is-active="urlIsActive(settingsHub.url())"
>
<Link :href="settingsHub.url()">
<IconSettings />
<span>{{ $t('sidebar.settings') }}</span>

View file

@ -1,9 +1,10 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { IconCopy, IconX } from '@tabler/icons-vue';
import { IconAlertTriangle, IconCopy } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -120,31 +121,30 @@ defineExpose({
<template>
<Dialog :open="isOpen" @update:open="onOpenChange">
<DialogContent :show-close-button="false" class="sm:max-w-md">
<Button
variant="ghost"
size="icon"
class="absolute top-4 right-4 size-7"
@click="close"
>
<IconX class="size-4" />
<span class="sr-only">Close</span>
</Button>
<DialogHeader>
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription class="space-y-1">
<span class="block">{{ description }}</span>
<span class="block font-semibold text-destructive">
{{ trans('common.confirm_modal.cannot_be_undone') }}
</span>
</DialogDescription>
<DialogHeader class="items-start text-left">
<div class="flex items-start gap-3">
<div
class="inline-flex size-12 -rotate-3 shrink-0 items-center justify-center rounded-2xl border-2 border-foreground bg-rose-200 shadow-2xs"
>
<IconAlertTriangle class="size-6 text-rose-700" stroke-width="2.25" />
</div>
<div class="flex-1 space-y-1">
<DialogTitle>{{ title }}</DialogTitle>
<DialogDescription class="space-y-1">
<span class="block">{{ description }}</span>
<span class="block font-semibold text-rose-700">
{{ trans('common.confirm_modal.cannot_be_undone') }}
</span>
</DialogDescription>
</div>
</div>
</DialogHeader>
<div v-if="requiresConfirmation" class="py-2">
<p
class="mb-2 flex flex-wrap items-center gap-1 text-sm text-muted-foreground"
>
<div v-if="requiresConfirmation" class="space-y-2">
<p class="flex flex-wrap items-center gap-1 text-sm text-foreground/80">
<span>{{ trans('common.confirm_modal.type') }}</span>
<code
class="inline-flex items-center gap-1 rounded-sm border bg-zinc-200 px-1.5 text-sm break-all text-foreground dark:bg-zinc-700"
class="inline-flex items-center gap-1.5 rounded-md border-2 border-foreground bg-amber-100 px-1.5 py-0.5 font-mono text-xs font-bold break-all text-foreground shadow-2xs"
>
{{ confirmText }}
<TooltipProvider>
@ -153,7 +153,7 @@ defineExpose({
<button
type="button"
tabindex="-1"
class="inline-flex shrink-0 items-center rounded text-muted-foreground hover:text-foreground"
class="inline-flex shrink-0 cursor-pointer items-center rounded text-foreground/60 hover:text-foreground"
@click="
copyToClipboard(
confirmText,
@ -175,9 +175,11 @@ defineExpose({
<Input
v-model="confirmInput"
autocomplete="off"
autofocus
/>
</div>
<DialogFooter>
<DialogFooter class="sm:justify-start sm:gap-2">
<Button
variant="destructive"
:disabled="processing || !isConfirmed"
@ -186,7 +188,7 @@ defineExpose({
{{ action }}
</Button>
<Button
variant="secondary"
variant="outline"
@click="close"
>
{{ cancel }}

View file

@ -175,20 +175,20 @@ const displayText = computed(() => {
<template>
<Popover v-model:open="popoverOpen">
<PopoverTrigger as-child :disabled="disabled">
<Button :id="name" type="button" variant="outline" class="w-full justify-between text-left font-normal"
:class="[{ 'text-muted-foreground': !displayText }, $attrs.class]" :disabled="disabled">
<Button :id="name" type="button" variant="outline" class="w-full justify-between text-left font-medium"
:class="[{ 'text-foreground/60': !displayText }, $attrs.class]" :disabled="disabled">
<span>{{ displayText || placeholder || $t('common.date_picker.select') }}</span>
<IconCalendar class="h-4 w-4 shrink-0" />
<IconCalendar class="size-4 shrink-0" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" :align="align">
<Calendar v-model="internalDate as any" :placeholder="(internalDate as any)" layout="month-and-year" locale="en"
calendar-label="Date picker" initial-focus />
<!-- Time Picker -->
<div v-if="showTime" class="border-t p-3">
<div v-if="showTime" class="border-t-2 border-foreground/10 p-3">
<div class="flex items-center gap-2">
<Select v-model="selectedHour" @update:model-value="onTimeChange">
<SelectTrigger class="w-[70px]">
<SelectTrigger class="w-[80px]">
<SelectValue placeholder="HH" />
</SelectTrigger>
<SelectContent>
@ -197,9 +197,9 @@ const displayText = computed(() => {
</SelectItem>
</SelectContent>
</Select>
<span class="text-muted-foreground">:</span>
<span class="font-bold text-foreground/40">:</span>
<Select v-model="selectedMinute" @update:model-value="onTimeChange">
<SelectTrigger class="w-[70px]">
<SelectTrigger class="w-[80px]">
<SelectValue placeholder="MM" />
</SelectTrigger>
<SelectContent>

View file

@ -45,12 +45,10 @@ const focusFirstInput = () => {
:title="$t('settings.delete_account.heading')"
:description="$t('settings.delete_account.description')"
/>
<div
class="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10"
>
<div class="relative space-y-0.5 text-red-600 dark:text-red-100">
<p class="font-medium">{{ $t('settings.delete_account.warning') }}</p>
<p class="text-sm">
<div class="space-y-4 rounded-xl border-2 border-foreground bg-rose-50 p-4 shadow-2xs">
<div class="relative space-y-0.5 text-rose-700">
<p class="font-bold">{{ $t('settings.delete_account.warning') }}</p>
<p class="text-sm font-medium">
{{ $t('settings.delete_account.warning_message') }}
</p>
</div>

View file

@ -10,12 +10,23 @@ defineProps<{
<template>
<div
class="flex flex-1 flex-col items-center justify-center rounded-xl border border-dashed p-12"
class="flex flex-1 flex-col items-center justify-center gap-4 rounded-xl border-2 border-dashed border-foreground/25 bg-card p-12 text-center"
>
<component :is="icon" class="mb-4 size-12 text-muted-foreground" />
<h3 class="mb-1 text-lg font-medium">{{ title }}</h3>
<p class="text-sm text-muted-foreground">{{ description }}</p>
<div v-if="$slots.action" class="mt-6">
<div
class="inline-flex size-16 -rotate-2 items-center justify-center rounded-2xl border-2 border-foreground bg-violet-200 shadow-2xs"
>
<component :is="icon" class="size-8 text-foreground" stroke-width="1.75" />
</div>
<div class="space-y-1">
<h3
class="text-xl text-foreground"
style="font-family: var(--font-display)"
>
{{ title }}
</h3>
<p class="text-sm text-foreground/60">{{ description }}</p>
</div>
<div v-if="$slots.action" class="mt-2">
<slot name="action" />
</div>
</div>

View file

@ -9,8 +9,8 @@ defineProps<Props>();
<template>
<header>
<h3 class="mb-0.5 text-base font-medium">{{ title }}</h3>
<p v-if="description" class="text-sm text-muted-foreground">
<h3 class="mb-0.5 text-base font-bold text-foreground">{{ title }}</h3>
<p v-if="description" class="text-sm text-foreground/70">
{{ description }}
</p>
</header>

View file

@ -22,8 +22,8 @@ const emit = defineEmits<{
const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
const PRESETS = [
'#000000', '#ffffff', '#ef4444', '#f97316', '#eab308',
'#22c55e', '#06b6d4', '#3b82f6', '#8b5cf6', '#ec4899',
'#000000', '#737373', '#ffffff', '#ef4444', '#f97316', '#f59e0b', '#eab308', '#84cc16', '#22c55e',
'#10b981', '#14b8a6', '#06b6d4', '#0ea5e9', '#3b82f6', '#6366f1', '#8b5cf6', '#d946ef', '#ec4899',
];
const text = ref(props.modelValue ?? '');
@ -207,8 +207,8 @@ const huePointerStyle = computed(() => ({
<button
type="button"
:disabled="disabled"
class="size-9 shrink-0 rounded-md border transition-shadow hover:shadow-sm disabled:cursor-not-allowed disabled:opacity-50"
:style="{ backgroundColor: swatchColor || 'transparent' }"
class="size-9 shrink-0 cursor-pointer rounded-md border-2 border-foreground bg-card shadow-xs transition-shadow hover:shadow-sm disabled:cursor-not-allowed disabled:opacity-50"
:style="swatchColor ? { backgroundColor: swatchColor } : undefined"
:aria-label="placeholder"
/>
</PopoverTrigger>
@ -262,7 +262,7 @@ const huePointerStyle = computed(() => ({
v-for="hex in PRESETS"
:key="hex"
type="button"
class="size-5 rounded-full border border-border/50 transition-transform hover:scale-110"
class="size-5 cursor-pointer rounded-full border-2 border-foreground transition-transform hover:scale-110"
:style="{ backgroundColor: hex }"
:aria-label="hex"
@click="pickPreset(hex)"

View file

@ -42,7 +42,7 @@ const { isMobile, state } = useSidebar();
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
class="w-(--reka-dropdown-menu-trigger-width) min-w-56 rounded-lg"
class="w-(--reka-dropdown-menu-trigger-width) min-w-56"
:side="isMobile ? 'bottom' : 'right'"
align="end"
:side-offset="4"

View file

@ -18,15 +18,15 @@ withDefaults(defineProps<Props>(), {
<Avatar
:src="user.photo_url"
:name="user.name"
:class="['h-8 w-8 rounded-lg', avatarClass]"
:class="['h-8 w-8 rounded-md border-2 border-foreground', avatarClass]"
:fallback-class="
fallbackClass ?? 'bg-sidebar-accent text-sidebar-accent-foreground'
fallbackClass ?? 'bg-violet-100 text-violet-700 font-bold'
"
/>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-medium">{{ user.name }}</span>
<span v-if="showEmail" class="truncate text-xs text-muted-foreground">{{
<span class="truncate font-bold text-foreground">{{ user.name }}</span>
<span v-if="showEmail" class="truncate text-xs font-medium text-foreground/60">{{
user.email
}}</span>
</div>

View file

@ -1,13 +1,10 @@
<script setup lang="ts">
import { Link, router, usePage } from '@inertiajs/vue3';
import {
IconBrightnessUp,
IconDeviceDesktop,
IconLanguage,
IconLifebuoy,
IconLogout,
IconMessageCircle,
IconMoon,
IconUser,
} from '@tabler/icons-vue';
import { loadLanguageAsync } from 'laravel-vue-i18n';
@ -25,7 +22,6 @@ import {
DropdownMenuSubTrigger,
} from '@/components/ui/dropdown-menu';
import UserInfo from '@/components/UserInfo.vue';
import { useAppearance } from '@/composables/useAppearance';
import dayjs from '@/dayjs';
import { logout } from '@/routes';
import { edit } from '@/routes/app/profile';
@ -46,8 +42,6 @@ const page = usePage();
const languages = computed<Language[]>(() => page.props.languages as Language[]);
const currentLanguage = computed(() => languages.value?.find((l: Language) => l.code === page.props.locale));
const { appearance, updateAppearance } = useAppearance();
const switchLanguage = (code: string) => {
const previousCode = currentLanguage.value?.code || 'en';
@ -70,12 +64,12 @@ const handleLogout = () => {
</script>
<template>
<DropdownMenuLabel class="p-0 font-normal">
<DropdownMenuLabel class="p-0 text-sm font-normal normal-case tracking-normal text-foreground">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<UserInfo
:user="user"
:show-email="true"
fallback-class="bg-neutral-200 text-neutral-600 dark:bg-neutral-700 dark:text-neutral-200"
fallback-class="bg-violet-100 text-violet-700"
/>
</div>
</DropdownMenuLabel>
@ -89,46 +83,10 @@ const handleLogout = () => {
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuSub>
<DropdownMenuSubTrigger class="gap-2">
<IconBrightnessUp
v-if="appearance === 'light'"
class="size-4 text-muted-foreground"
/>
<IconMoon
v-else-if="appearance === 'dark'"
class="size-4 text-muted-foreground"
/>
<IconDeviceDesktop
v-else
class="size-4 text-muted-foreground"
/>
{{ $t('sidebar.theme', { name: appearance === 'light' ? $t('sidebar.theme_light') : appearance === 'dark' ? $t('sidebar.theme_dark') : $t('sidebar.theme_system') }) }}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem @click="updateAppearance('light')">
<IconBrightnessUp class="size-4" />
{{ $t('sidebar.theme_light') }}
</DropdownMenuItem>
<DropdownMenuItem @click="updateAppearance('dark')">
<IconMoon class="size-4" />
{{ $t('sidebar.theme_dark') }}
</DropdownMenuItem>
<DropdownMenuItem @click="updateAppearance('system')">
<IconDeviceDesktop class="size-4" />
{{ $t('sidebar.theme_system') }}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuSub v-if="languages && languages.length > 1">
<DropdownMenuSubTrigger class="gap-2">
<IconLanguage class="size-4 text-muted-foreground" />
<DropdownMenuSubTrigger>
<IconLanguage />
{{ $t('sidebar.language', { name: currentLanguage?.name ?? 'English' }) }}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>

View file

@ -1,8 +1,10 @@
<script setup lang="ts">
import { router } from '@inertiajs/vue3';
import { IconPlus } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { onMounted, onUnmounted } from 'vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
@ -10,7 +12,6 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
export interface AvailablePlatform {
value: string;
@ -24,10 +25,31 @@ defineProps<{
const open = defineModel<boolean>('open', { default: false });
const getPlatformDescription = (platform: string): string => {
return trans(`accounts.descriptions.${platform}`);
const getPlatformDescription = (platform: string): string =>
trans(`accounts.descriptions.${platform}`);
// Mirrors `NetworksGrid.vue` from the marketing site pastel tile bg
// + ink 2px border + slight rotation per platform, real PNG logo inside.
// `linkedin-page` / `instagram-facebook` fall back to the base brand
// image and same color since they're variants of the same network.
const platformTheme: Record<string, { bg: string; rotate: string; image: string }> = {
instagram: { bg: 'bg-pink-200', rotate: '-rotate-2', image: '/images/accounts/instagram.png' },
'instagram-facebook': { bg: 'bg-pink-200', rotate: '-rotate-2', image: '/images/accounts/instagram.png' },
facebook: { bg: 'bg-sky-200', rotate: 'rotate-1', image: '/images/accounts/facebook.png' },
linkedin: { bg: 'bg-blue-200', rotate: '-rotate-1', image: '/images/accounts/linkedin.png' },
'linkedin-page': { bg: 'bg-blue-200', rotate: '-rotate-1', image: '/images/accounts/linkedin.png' },
x: { bg: 'bg-amber-200', rotate: 'rotate-2', image: '/images/accounts/x.png' },
tiktok: { bg: 'bg-fuchsia-200', rotate: '-rotate-1', image: '/images/accounts/tiktok.png' },
youtube: { bg: 'bg-red-200', rotate: 'rotate-1', image: '/images/accounts/youtube.png' },
pinterest: { bg: 'bg-rose-200', rotate: '-rotate-2', image: '/images/accounts/pinterest.png' },
threads: { bg: 'bg-emerald-200', rotate: 'rotate-2', image: '/images/accounts/threads.png' },
bluesky: { bg: 'bg-cyan-200', rotate: '-rotate-1', image: '/images/accounts/bluesky.png' },
mastodon: { bg: 'bg-violet-200', rotate: 'rotate-1', image: '/images/accounts/mastodon.png' },
};
const themeFor = (value: string) =>
platformTheme[value] ?? { bg: 'bg-muted', rotate: '', image: '' };
const openOAuthPopup = (platformValue: string) => {
const url = `/connect/${platformValue}`;
const width = 600;
@ -70,24 +92,55 @@ onUnmounted(() => {
{{ $t('accounts.add_social_description') }}
</DialogDescription>
</DialogHeader>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<button
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
<div
v-for="platform in platforms"
:key="platform.value"
class="flex flex-col items-center gap-2 rounded-lg border border-border p-4 text-center transition-colors hover:bg-accent"
@click="openOAuthPopup(platform.value)"
class="group relative flex flex-col items-center gap-3 rounded-xl border-2 border-foreground bg-card p-4 text-center shadow-xs transition-shadow hover:shadow-md"
>
<img :src="getPlatformLogo(platform.value)" :alt="platform.label" class="h-10 w-10 rounded-full object-contain" />
<span class="text-sm font-medium">
<template v-if="platform.label.includes('(')">
{{ platform.label.split('(')[0].trim() }}
</template>
<template v-else>{{ platform.label }}</template>
<!-- "+" sticker badge appears only on hover so the grid doesn't feel cluttered. -->
<span
class="pointer-events-none absolute -top-2 -right-2 inline-flex size-6 items-center justify-center rounded-full border-2 border-foreground bg-violet-200 text-foreground opacity-0 shadow-2xs transition-all group-hover:rotate-90 group-hover:scale-110 group-hover:opacity-100"
aria-hidden="true"
>
<IconPlus class="size-3.5" stroke-width="3" />
</span>
<p class="line-clamp-2 text-xs leading-tight text-muted-foreground">
{{ getPlatformDescription(platform.value) }}
</p>
</button>
<div
:class="[
themeFor(platform.value).bg,
themeFor(platform.value).rotate,
'inline-flex size-16 items-center justify-center rounded-2xl border-2 border-foreground shadow-sm transition-transform group-hover:!rotate-0',
]"
>
<img
:src="themeFor(platform.value).image"
:alt="platform.label"
class="size-9 rounded-lg"
loading="lazy"
/>
</div>
<div class="flex-1">
<span class="block text-sm font-semibold text-foreground">
<template v-if="platform.label.includes('(')">
{{ platform.label.split('(')[0].trim() }}
</template>
<template v-else>{{ platform.label }}</template>
</span>
<p class="mt-0.5 line-clamp-2 text-xs leading-tight text-foreground/60">
{{ getPlatformDescription(platform.value) }}
</p>
</div>
<Button
size="sm"
class="mt-auto w-full"
@click="openOAuthPopup(platform.value)"
>
{{ $t('accounts.connect_cta') }}
</Button>
</div>
</div>
</DialogContent>
</Dialog>

View file

@ -21,36 +21,42 @@ const emit = defineEmits<{
</script>
<template>
<div class="flex h-full w-64 shrink-0 flex-col border-r">
<div class="border-b px-4 py-3">
<h2 class="text-sm font-semibold">{{ $t('analytics.channels') }}</h2>
<div class="flex h-full w-64 shrink-0 flex-col border-r-2 border-foreground/10">
<div class="border-b-2 border-foreground/10 px-4 py-3">
<h2 class="text-[11px] font-black uppercase tracking-widest text-foreground/60">
{{ $t('analytics.channels') }}
</h2>
</div>
<div class="flex-1 overflow-y-auto p-2">
<div v-if="accounts.length === 0" class="px-2 py-8 text-center text-sm text-muted-foreground">
<div class="flex-1 space-y-1 overflow-y-auto p-2">
<div v-if="accounts.length === 0" class="px-2 py-8 text-center text-sm font-medium text-foreground/60">
{{ $t('analytics.no_accounts') }}
</div>
<button
v-for="account in accounts"
:key="account.id"
type="button"
class="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors"
:class="selectedId === account.id ? 'bg-accent text-accent-foreground' : 'hover:bg-muted'"
class="flex w-full cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-left text-sm transition-colors"
:class="selectedId === account.id ? 'bg-violet-100 text-foreground' : 'text-foreground/80 hover:bg-foreground/5 hover:text-foreground'"
@click="emit('select', account.id)"
>
<div class="relative">
<Avatar class="h-8 w-8">
<div class="relative shrink-0">
<Avatar class="size-9 rounded-full border-2 border-foreground shadow-2xs">
<AvatarImage v-if="account.avatar_url" :src="account.avatar_url" :alt="account.display_name" />
<AvatarFallback>{{ account.display_name?.charAt(0) }}</AvatarFallback>
<AvatarFallback class="rounded-full bg-violet-100 font-bold text-foreground">
{{ account.display_name?.charAt(0) }}
</AvatarFallback>
</Avatar>
<img
:src="getPlatformLogo(account.platform)"
:alt="account.platform"
class="absolute -bottom-0.5 -right-0.5 h-4 w-4 rounded-full border border-background"
/>
<span class="absolute -bottom-1 -right-1 inline-flex size-5 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img
:src="getPlatformLogo(account.platform)"
:alt="account.platform"
class="size-full object-cover"
/>
</span>
</div>
<div class="min-w-0 flex-1">
<p class="truncate font-medium">{{ account.display_name }}</p>
<p v-if="account.username" class="truncate text-xs text-muted-foreground">
<p class="truncate font-bold text-foreground">{{ account.display_name }}</p>
<p v-if="account.username" class="truncate text-xs font-medium text-foreground/60">
@{{ account.username }}
</p>
</div>

View file

@ -72,8 +72,8 @@ defineExpose({ supportsDateRange: true });
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tabular-nums tracking-tight text-foreground">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
@ -81,7 +81,7 @@ defineExpose({ supportsDateRange: true });
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
<div v-else class="flex h-full items-center justify-center text-sm font-medium text-foreground/60">
{{ $t('analytics.no_data') }}
</div>
</template>

View file

@ -72,8 +72,8 @@ defineExpose({ supportsDateRange: true });
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tabular-nums tracking-tight text-foreground">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
@ -81,7 +81,7 @@ defineExpose({ supportsDateRange: true });
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
<div v-else class="flex h-full items-center justify-center text-sm font-medium text-foreground/60">
{{ $t('analytics.no_data') }}
</div>
</template>

View file

@ -72,8 +72,8 @@ defineExpose({ supportsDateRange: true });
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tabular-nums tracking-tight text-foreground">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
@ -81,7 +81,7 @@ defineExpose({ supportsDateRange: true });
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
<div v-else class="flex h-full items-center justify-center text-sm font-medium text-foreground/60">
{{ $t('analytics.no_data') }}
</div>
</template>

View file

@ -72,8 +72,8 @@ defineExpose({ supportsDateRange: true });
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tabular-nums tracking-tight text-foreground">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
@ -81,7 +81,7 @@ defineExpose({ supportsDateRange: true });
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
<div v-else class="flex h-full items-center justify-center text-sm font-medium text-foreground/60">
{{ $t('analytics.no_data') }}
</div>
</template>

View file

@ -72,8 +72,8 @@ defineExpose({ supportsDateRange: true });
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tabular-nums tracking-tight text-foreground">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
@ -81,7 +81,7 @@ defineExpose({ supportsDateRange: true });
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
<div v-else class="flex h-full items-center justify-center text-sm font-medium text-foreground/60">
{{ $t('analytics.no_data') }}
</div>
</template>

View file

@ -61,8 +61,8 @@ defineExpose({ supportsDateRange: false });
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tabular-nums tracking-tight text-foreground">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
@ -70,7 +70,7 @@ defineExpose({ supportsDateRange: false });
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
<div v-else class="flex h-full items-center justify-center text-sm font-medium text-foreground/60">
{{ $t('analytics.no_data') }}
</div>
</template>

View file

@ -72,8 +72,8 @@ defineExpose({ supportsDateRange: true });
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tabular-nums tracking-tight text-foreground">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
@ -81,7 +81,7 @@ defineExpose({ supportsDateRange: true });
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
<div v-else class="flex h-full items-center justify-center text-sm font-medium text-foreground/60">
{{ $t('analytics.no_data') }}
</div>
</template>

View file

@ -72,8 +72,8 @@ defineExpose({ supportsDateRange: true });
<div v-else-if="metrics.length > 0" class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Card v-for="metric in metrics" :key="metric.label">
<CardContent class="p-6">
<p class="text-sm text-muted-foreground">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tracking-tight">
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ metric.label }}</p>
<p class="mt-2 text-3xl font-bold tabular-nums tracking-tight text-foreground">
{{ formatNumber(metric.value) }}
</p>
</CardContent>
@ -81,7 +81,7 @@ defineExpose({ supportsDateRange: true });
</div>
<!-- No Data -->
<div v-else class="flex h-full items-center justify-center text-muted-foreground">
<div v-else class="flex h-full items-center justify-center text-sm font-medium text-foreground/60">
{{ $t('analytics.no_data') }}
</div>
</template>

View file

@ -553,18 +553,20 @@ onUnmounted(() => {
</TabsList>
<!-- My Uploads -->
<TabsContent value="uploads" class="mt-4">
<TabsContent value="uploads" class="mt-6">
<div
class="relative mb-4 flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors"
:class="isDragging ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'"
class="relative mb-4 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed p-8 text-center transition-colors"
:class="isDragging ? 'border-foreground bg-violet-100' : 'border-foreground/25 bg-card hover:bg-foreground/5'"
@click="triggerFileInput"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop"
>
<IconCloudUpload class="mb-2 size-8 text-muted-foreground" />
<p class="text-sm font-medium">{{ trans('assets.upload.drag_drop') }}</p>
<p class="mt-1 text-xs text-muted-foreground">{{ trans('assets.upload.formats') }}</p>
<div class="inline-flex size-12 -rotate-3 items-center justify-center rounded-2xl border-2 border-foreground bg-violet-200 shadow-2xs">
<IconCloudUpload class="size-6 text-foreground" stroke-width="2" />
</div>
<p class="text-sm font-semibold text-foreground">{{ trans('assets.upload.drag_drop') }}</p>
<p class="text-xs text-foreground/60">{{ trans('assets.upload.formats') }}</p>
<input
ref="fileInput"
type="file"
@ -573,8 +575,8 @@ onUnmounted(() => {
accept="image/jpeg,image/png,image/gif,image/webp,video/mp4"
@change="handleFileSelect"
/>
<div v-if="uploading" class="absolute inset-0 flex items-center justify-center rounded-lg bg-background/80">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<div v-if="uploading" class="absolute inset-0 flex items-center justify-center rounded-2xl bg-card/85">
<div class="flex items-center gap-2 text-sm font-semibold text-foreground">
<IconLoader2 class="size-4 animate-spin" />
{{ trans('assets.upload.uploading') }}
</div>
@ -582,7 +584,7 @@ onUnmounted(() => {
</div>
<div class="relative mb-4">
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-foreground/60" />
<Input
v-model="uploadsSearch"
type="search"
@ -592,24 +594,24 @@ onUnmounted(() => {
</div>
<div v-if="uploadsLoading" class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
<Skeleton v-for="i in 8" :key="i" class="aspect-square rounded-lg" />
<Skeleton v-for="i in 8" :key="i" class="aspect-square rounded-xl" />
</div>
<div v-else-if="uploads.length === 0" class="flex flex-col items-center justify-center gap-2 py-12 text-muted-foreground">
<IconPhoto class="size-10" />
<p class="text-sm">{{ trans('assets.empty.title') }}</p>
</div>
<EmptyState
v-else-if="uploads.length === 0"
:icon="IconPhoto"
:title="trans('assets.empty.title')"
:description="trans('assets.empty.description')"
/>
<div v-else class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
<div
v-for="asset in uploads"
:key="asset.id"
class="group relative overflow-hidden rounded-lg border-2 bg-muted transition-all"
class="group relative overflow-hidden rounded-xl border-2 border-foreground bg-muted shadow-2xs transition-all hover:-translate-y-0.5 hover:shadow-md"
:class="[
(isPicker || asset.type !== 'video') ? 'cursor-pointer' : '',
isPicker && isSelected(asset.id)
? 'border-primary ring-2 ring-primary/30'
: 'border-transparent',
isPicker && isSelected(asset.id) ? 'ring-2 ring-primary ring-offset-2 ring-offset-background' : '',
]"
@click="handleAssetClick(asset)"
>
@ -632,32 +634,37 @@ onUnmounted(() => {
<div
v-if="isPicker && isSelected(asset.id)"
class="absolute right-2 top-2 flex size-6 items-center justify-center rounded-full bg-primary text-xs font-semibold text-primary-foreground"
class="absolute right-2 top-2 inline-flex size-6 items-center justify-center rounded-full border-2 border-foreground bg-primary text-xs font-bold text-primary-foreground shadow-2xs"
>
{{ selectionIndex(asset.id) }}
</div>
<div
v-if="!isPicker"
class="absolute inset-0 flex flex-col justify-between bg-black/60 p-2 opacity-0 transition-opacity group-hover:opacity-100"
class="absolute inset-0 flex flex-col justify-between bg-foreground/60 p-2 opacity-0 transition-opacity group-hover:opacity-100"
>
<div class="flex justify-end gap-1">
<div class="flex justify-end gap-1.5">
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button variant="secondary" size="icon" class="size-7" @click.stop="createPostFromAsset(asset)">
<IconPencilPlus class="size-3.5" />
<Button variant="outline" size="icon" class="size-8" @click.stop="createPostFromAsset(asset)">
<IconPencilPlus class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ trans('assets.create_post') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button variant="destructive" size="icon" class="size-7" @click.stop="handleDelete(asset.id)">
<IconTrash class="size-3.5" />
<Button
variant="outline"
size="icon"
class="size-8 bg-rose-100 hover:bg-rose-200"
@click.stop="handleDelete(asset.id)"
>
<IconTrash class="size-4 text-rose-700" />
</Button>
</div>
<div class="space-y-0.5">
<p class="truncate text-xs font-medium text-white">{{ asset.original_filename }}</p>
<p class="truncate text-xs font-semibold text-white">{{ asset.original_filename }}</p>
<p class="text-xs text-white/70">{{ formatFileSize(asset.size) }}</p>
</div>
</div>
@ -665,14 +672,14 @@ onUnmounted(() => {
</div>
<div v-if="uploadsHasMore" ref="uploadsSentinel" class="mt-4 flex justify-center">
<IconLoader2 v-if="uploadsLoadingMore" class="size-5 animate-spin text-muted-foreground" />
<IconLoader2 v-if="uploadsLoadingMore" class="size-5 animate-spin text-foreground/60" />
</div>
</TabsContent>
<!-- Stock Photos (Unsplash) -->
<TabsContent value="stock" class="mt-4" @vue:mounted="onUnsplashTabMounted">
<TabsContent value="stock" class="mt-6" @vue:mounted="onUnsplashTabMounted">
<div class="relative mb-4">
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-foreground/60" />
<Input
v-model="unsplashQuery"
:placeholder="trans('assets.unsplash.search_placeholder')"
@ -682,7 +689,10 @@ onUnmounted(() => {
</div>
<div v-if="displayedPhotos.length > 0" class="space-y-3">
<p v-if="!unsplashQuery && trendingPhotos.length > 0" class="text-sm font-medium text-muted-foreground">
<p
v-if="!unsplashQuery && trendingPhotos.length > 0"
class="text-[11px] font-black uppercase tracking-widest text-foreground/60"
>
{{ trans('assets.unsplash.trending') }}
</p>
@ -690,7 +700,7 @@ onUnmounted(() => {
<div
v-for="photo in displayedPhotos"
:key="photo.id"
class="group relative cursor-pointer overflow-hidden rounded-lg bg-muted"
class="group relative cursor-pointer overflow-hidden rounded-xl border-2 border-foreground bg-muted shadow-2xs transition-all hover:-translate-y-0.5 hover:shadow-md"
@click="previewImage = photo.url_regular"
>
<div class="aspect-[4/3]">
@ -702,19 +712,19 @@ onUnmounted(() => {
/>
</div>
<div class="absolute inset-0 flex flex-col justify-between bg-black/60 p-2 opacity-0 transition-opacity group-hover:opacity-100">
<div class="flex justify-end gap-1">
<div class="absolute inset-0 flex flex-col justify-between bg-foreground/60 p-2 opacity-0 transition-opacity group-hover:opacity-100">
<div class="flex justify-end gap-1.5">
<TooltipProvider v-if="!isPicker">
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
variant="outline"
size="icon"
class="size-7"
class="size-8"
:disabled="savingPhotoId === photo.id"
@click.stop="createPostFromUnsplash(photo)"
>
<IconPencilPlus class="size-3.5" />
<IconPencilPlus class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ trans('assets.create_post') }}</TooltipContent>
@ -724,14 +734,14 @@ onUnmounted(() => {
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
variant="outline"
size="icon"
class="size-7"
class="size-8 bg-violet-100 hover:bg-violet-200"
:disabled="savingPhotoId === photo.id"
@click.stop="saveAndPickUnsplash(photo)"
>
<IconLoader2 v-if="savingPhotoId === photo.id" class="size-3.5 animate-spin" />
<IconPlus v-else class="size-3.5" />
<IconLoader2 v-if="savingPhotoId === photo.id" class="size-4 animate-spin" />
<IconPlus v-else class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
@ -762,16 +772,16 @@ onUnmounted(() => {
/>
<div v-if="unsplashLoading" class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
<Skeleton v-for="i in 8" :key="i" class="aspect-[4/3] rounded-lg" />
<Skeleton v-for="i in 8" :key="i" class="aspect-[4/3] rounded-xl" />
</div>
<div v-if="hasMorePhotos" ref="unsplashSentinel" class="h-1" />
</TabsContent>
<!-- GIFs (Giphy) -->
<TabsContent value="gifs" class="mt-4" @vue:mounted="onGiphyTabMounted">
<TabsContent value="gifs" class="mt-6" @vue:mounted="onGiphyTabMounted">
<div class="relative mb-4">
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-foreground/60" />
<Input
v-model="giphyQuery"
:placeholder="trans('assets.giphy.search_placeholder')"
@ -781,7 +791,10 @@ onUnmounted(() => {
</div>
<div v-if="displayedGifs.length > 0" class="space-y-3">
<p v-if="!giphyQuery && giphyTrendingItems.length > 0" class="text-sm font-medium text-muted-foreground">
<p
v-if="!giphyQuery && giphyTrendingItems.length > 0"
class="text-[11px] font-black uppercase tracking-widest text-foreground/60"
>
{{ trans('assets.giphy.trending') }}
</p>
@ -789,26 +802,26 @@ onUnmounted(() => {
<div
v-for="gif in displayedGifs"
:key="gif.id"
class="group relative cursor-pointer overflow-hidden rounded-lg bg-muted"
class="group relative cursor-pointer overflow-hidden rounded-xl border-2 border-foreground bg-muted shadow-2xs transition-all hover:-translate-y-0.5 hover:shadow-md"
@click="previewImage = gif.url_original"
>
<div class="aspect-[4/3]">
<img :src="gif.url_preview" :alt="gif.title || 'GIF'" class="size-full object-cover" loading="lazy" />
</div>
<div class="absolute inset-0 flex flex-col justify-between bg-black/60 p-2 opacity-0 transition-opacity group-hover:opacity-100">
<div class="flex justify-end gap-1">
<div class="absolute inset-0 flex flex-col justify-between bg-foreground/60 p-2 opacity-0 transition-opacity group-hover:opacity-100">
<div class="flex justify-end gap-1.5">
<TooltipProvider v-if="!isPicker">
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
variant="outline"
size="icon"
class="size-7"
class="size-8"
:disabled="savingGifId === gif.id"
@click.stop="createPostFromGiphy(gif)"
>
<IconPencilPlus class="size-3.5" />
<IconPencilPlus class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ trans('assets.create_post') }}</TooltipContent>
@ -818,14 +831,14 @@ onUnmounted(() => {
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="secondary"
variant="outline"
size="icon"
class="size-7"
class="size-8 bg-violet-100 hover:bg-violet-200"
:disabled="savingGifId === gif.id"
@click.stop="saveAndPickGiphy(gif)"
>
<IconLoader2 v-if="savingGifId === gif.id" class="size-3.5 animate-spin" />
<IconPlus v-else class="size-3.5" />
<IconLoader2 v-if="savingGifId === gif.id" class="size-4 animate-spin" />
<IconPlus v-else class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
@ -848,13 +861,13 @@ onUnmounted(() => {
/>
<div v-if="giphyLoading" class="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
<Skeleton v-for="i in 8" :key="i" class="aspect-[4/3] rounded-lg" />
<Skeleton v-for="i in 8" :key="i" class="aspect-[4/3] rounded-xl" />
</div>
<div v-if="hasMoreGifs" ref="giphySentinel" class="h-1" />
<div v-if="displayedGifs.length > 0" class="mt-4 text-center">
<a href="https://giphy.com" target="_blank" rel="noopener noreferrer" class="text-xs text-muted-foreground hover:text-foreground">
<a href="https://giphy.com" target="_blank" rel="noopener noreferrer" class="text-xs font-medium text-foreground/60 hover:text-foreground">
{{ trans('assets.giphy.powered_by') }}
</a>
</div>

View file

@ -2,6 +2,7 @@
import { useForm } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import HexColorInput from '@/components/HexColorInput.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -17,21 +18,11 @@ import { store as labelsStore } from '@/routes/app/labels';
const open = defineModel<boolean>('open', { default: false });
const colors = [
'#FDFD96',
'#FFD580',
'#FFB3BA',
'#FF69B4',
'#DDA0DD',
'#89CFF0',
'#90EE90',
'#D2B48C',
'#D3D3D3',
];
const DEFAULT_COLOR = '#7c3aed';
const form = useForm({
name: '',
color: colors[0],
color: DEFAULT_COLOR,
});
const submit = () => {
@ -46,7 +37,7 @@ const submit = () => {
const handleOpenChange = (value: boolean) => {
if (value) {
form.reset();
form.color = colors[0];
form.color = DEFAULT_COLOR;
form.clearErrors();
}
open.value = value;
@ -77,22 +68,8 @@ const handleOpenChange = (value: boolean) => {
</div>
<div class="space-y-2">
<Label>{{ $t('labels.create.color') }}</Label>
<div class="flex flex-wrap gap-3">
<button
v-for="color in colors"
:key="color"
type="button"
class="size-8 rounded-full transition-all"
:class="[
form.color === color
? 'ring-2 ring-primary ring-offset-2 ring-offset-background'
: 'hover:scale-110'
]"
:style="{ backgroundColor: color }"
@click="form.color = color"
/>
</div>
<Label for="create-color">{{ $t('labels.create.color') }}</Label>
<HexColorInput v-model="form.color" name="color" />
<p v-if="form.errors.color" class="text-sm text-destructive">
{{ form.errors.color }}
</p>

View file

@ -3,6 +3,7 @@ import { useForm } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { watch } from 'vue';
import HexColorInput from '@/components/HexColorInput.vue';
import { Button } from '@/components/ui/button';
import {
Dialog,
@ -28,18 +29,6 @@ const props = defineProps<{
const open = defineModel<boolean>('open', { default: false });
const colors = [
'#FDFD96',
'#FFD580',
'#FFB3BA',
'#FF69B4',
'#DDA0DD',
'#89CFF0',
'#90EE90',
'#D2B48C',
'#D3D3D3',
];
const form = useForm({
name: '',
color: '',
@ -87,22 +76,8 @@ const submit = () => {
</div>
<div class="space-y-2">
<Label>{{ $t('labels.edit.color') }}</Label>
<div class="flex flex-wrap gap-3">
<button
v-for="color in colors"
:key="color"
type="button"
class="size-8 rounded-full transition-all"
:class="[
form.color === color
? 'ring-2 ring-primary ring-offset-2 ring-offset-background'
: 'hover:scale-110'
]"
:style="{ backgroundColor: color }"
@click="form.color = color"
/>
</div>
<Label for="edit-color">{{ $t('labels.edit.color') }}</Label>
<HexColorInput v-model="form.color" name="color" />
<p v-if="form.errors.color" class="text-sm text-destructive">
{{ form.errors.color }}
</p>

View file

@ -0,0 +1,41 @@
<script setup lang="ts">
interface Label {
id: string;
name: string;
color: string;
}
withDefaults(
defineProps<{
label: Label;
interactive?: boolean;
selected?: boolean;
disabled?: boolean;
}>(),
{
interactive: false,
selected: false,
disabled: false,
},
);
defineEmits<{ click: [] }>();
</script>
<template>
<component
:is="interactive ? 'button' : 'span'"
:type="interactive ? 'button' : undefined"
:disabled="interactive && disabled"
class="inline-flex items-center gap-1.5 rounded-full border-2 border-foreground px-2 py-0.5 text-[10px] font-bold uppercase tracking-widest text-foreground shadow-2xs"
:class="[
selected ? 'bg-violet-100 shadow-md' : 'bg-card',
interactive ? 'cursor-pointer transition-transform hover:-translate-y-0.5 disabled:cursor-not-allowed' : '',
interactive && !selected && !disabled ? 'opacity-80 hover:opacity-100' : '',
]"
@click="interactive && !disabled ? $emit('click') : undefined"
>
<span class="size-2 shrink-0 rounded-full" :style="{ backgroundColor: label.color }" />
<span class="truncate">{{ label.name }}</span>
</component>
</template>

View file

@ -121,8 +121,8 @@ onBeforeUnmount(() => {
</script>
<template>
<div class="flex w-[340px] flex-col overflow-hidden rounded-md bg-popover text-popover-foreground">
<div class="border-b p-2">
<div class="flex w-[340px] flex-col overflow-hidden rounded-[10px] bg-card text-foreground">
<div class="border-b-2 border-foreground/10 p-2">
<Input
v-model="search"
type="search"
@ -139,7 +139,7 @@ onBeforeUnmount(() => {
<template v-if="isSearching">
<div
v-if="searchResults.length === 0"
class="flex h-full items-center justify-center px-4 text-center text-xs text-muted-foreground"
class="flex h-full items-center justify-center px-4 text-center text-xs font-medium text-foreground/60"
>
{{ trans('posts.edit.emoji_picker.empty') }}
</div>
@ -148,7 +148,7 @@ onBeforeUnmount(() => {
v-for="emoji in searchResults"
:key="emoji.c"
type="button"
class="flex h-9 w-9 items-center justify-center rounded text-xl transition-colors hover:bg-muted focus:bg-muted focus:outline-none"
class="flex size-9 cursor-pointer items-center justify-center rounded-md text-xl transition-colors hover:bg-foreground/5 focus:bg-foreground/5 focus:outline-none"
:title="emoji.n"
:aria-label="emoji.n"
@click="onPick(emoji)"
@ -162,7 +162,7 @@ onBeforeUnmount(() => {
<section v-if="recentEmojis.length > 0">
<h3
:ref="setHeaderRef('recent')"
class="sticky top-0 z-10 bg-popover/95 px-1 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground backdrop-blur"
class="sticky top-0 z-10 bg-card/95 px-1 py-1.5 text-[11px] font-black uppercase tracking-widest text-foreground/60 backdrop-blur"
>
{{ categoryLabel('recent') }}
</h3>
@ -171,7 +171,7 @@ onBeforeUnmount(() => {
v-for="emoji in recentEmojis"
:key="`recent-${emoji.c}`"
type="button"
class="flex h-9 w-9 items-center justify-center rounded text-xl transition-colors hover:bg-muted focus:bg-muted focus:outline-none"
class="flex size-9 cursor-pointer items-center justify-center rounded-md text-xl transition-colors hover:bg-foreground/5 focus:bg-foreground/5 focus:outline-none"
:title="emoji.n"
:aria-label="emoji.n"
@click="onPick(emoji)"
@ -184,7 +184,7 @@ onBeforeUnmount(() => {
<section v-for="category in EMOJI_CATEGORIES" :key="category">
<h3
:ref="setHeaderRef(category)"
class="sticky top-0 z-10 bg-popover/95 px-1 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground backdrop-blur"
class="sticky top-0 z-10 bg-card/95 px-1 py-1.5 text-[11px] font-black uppercase tracking-widest text-foreground/60 backdrop-blur"
>
{{ categoryLabel(category) }}
</h3>
@ -193,7 +193,7 @@ onBeforeUnmount(() => {
v-for="emoji in grouped[category]"
:key="emoji.c"
type="button"
class="flex h-9 w-9 items-center justify-center rounded text-xl transition-colors hover:bg-muted focus:bg-muted focus:outline-none"
class="flex size-9 cursor-pointer items-center justify-center rounded-md text-xl transition-colors hover:bg-foreground/5 focus:bg-foreground/5 focus:outline-none"
:title="emoji.n"
:aria-label="emoji.n"
@click="onPick(emoji)"
@ -205,12 +205,12 @@ onBeforeUnmount(() => {
</template>
</div>
<div class="flex items-center justify-between border-t px-1 py-1">
<div class="flex items-center justify-between border-t-2 border-foreground/10 px-1 py-1">
<button
v-if="recentEmojis.length > 0"
type="button"
class="flex h-8 w-8 items-center justify-center rounded text-base transition-colors hover:bg-muted focus:bg-muted focus:outline-none"
:class="activeCategory === 'recent' && !isSearching ? 'bg-muted' : ''"
class="flex size-8 cursor-pointer items-center justify-center rounded-md text-base transition-colors hover:bg-foreground/5 focus:bg-foreground/5 focus:outline-none"
:class="activeCategory === 'recent' && !isSearching ? 'bg-violet-100 ring-2 ring-foreground' : ''"
:title="categoryLabel('recent')"
:aria-label="categoryLabel('recent')"
@click="scrollToCategory('recent')"
@ -221,8 +221,8 @@ onBeforeUnmount(() => {
v-for="category in EMOJI_CATEGORIES"
:key="category"
type="button"
class="flex h-8 w-8 items-center justify-center rounded text-base transition-colors hover:bg-muted focus:bg-muted focus:outline-none"
:class="activeCategory === category && !isSearching ? 'bg-muted' : ''"
class="flex size-8 cursor-pointer items-center justify-center rounded-md text-base transition-colors hover:bg-foreground/5 focus:bg-foreground/5 focus:outline-none"
:class="activeCategory === category && !isSearching ? 'bg-violet-100 ring-2 ring-foreground' : ''"
:title="categoryLabel(category)"
:aria-label="categoryLabel(category)"
@click="scrollToCategory(category)"

View file

@ -11,6 +11,7 @@ import dayjs from '@/dayjs';
const props = defineProps<{
modelValue: string;
disabled?: boolean;
showRemove?: boolean;
}>();
const timezoneAbbr = computed(() => dayjs().format('z'));
@ -18,6 +19,7 @@ const timezoneAbbr = computed(() => dayjs().format('z'));
const emit = defineEmits<{
'update:modelValue': [value: string];
confirm: [value: string];
remove: [];
}>();
const open = ref(false);
@ -73,6 +75,11 @@ const confirm = () => {
emit('confirm', value);
open.value = false;
};
const remove = () => {
emit('remove');
open.value = false;
};
</script>
<template>
@ -91,14 +98,14 @@ const confirm = () => {
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">{{ $t('posts.edit.time') }}</span>
<Select v-model="selectedHour">
<SelectTrigger class="w-[70px]"><SelectValue placeholder="HH" /></SelectTrigger>
<SelectTrigger class="w-[84px]"><SelectValue placeholder="HH" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="h in hours" :key="h" :value="h">{{ h }}</SelectItem>
</SelectContent>
</Select>
<span class="text-muted-foreground">:</span>
<Select v-model="selectedMinute">
<SelectTrigger class="w-[70px]"><SelectValue placeholder="MM" /></SelectTrigger>
<SelectTrigger class="w-[84px]"><SelectValue placeholder="MM" /></SelectTrigger>
<SelectContent>
<SelectItem v-for="m in minutes" :key="m" :value="m">{{ m }}</SelectItem>
</SelectContent>
@ -108,7 +115,17 @@ const confirm = () => {
</div>
<div class="flex items-center justify-between gap-2 border-t p-3">
<Button type="button" variant="ghost" size="sm" @click="cancel">{{ $t('posts.edit.cancel') }}</Button>
<Button
v-if="showRemove"
type="button"
variant="outline"
size="sm"
class="bg-rose-100 text-rose-700 hover:bg-rose-200"
@click="remove"
>
{{ $t('posts.edit.unschedule') }}
</Button>
<Button v-else type="button" variant="ghost" size="sm" @click="cancel">{{ $t('posts.edit.cancel') }}</Button>
<Button type="button" size="sm" @click="confirm">{{ $t('posts.edit.pick_time') }}</Button>
</div>
</DialogContent>

View file

@ -87,9 +87,9 @@ watch(open, () => {
</div>
<div v-if="status !== 'idle'" class="grid gap-2">
<Label>{{ $t('posts.ai.generate.preview_label') }}</Label>
<div class="min-h-[120px] whitespace-pre-wrap rounded-md border bg-muted/30 px-3 py-2 text-sm">{{ text || '...' }}</div>
<p v-if="status === 'failed'" class="text-xs text-destructive">{{ errorMessage }}</p>
<Label class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.ai.generate.preview_label') }}</Label>
<div class="min-h-[120px] whitespace-pre-wrap rounded-lg border-2 border-foreground bg-card px-3 py-2 text-sm font-medium text-foreground shadow-2xs">{{ text || '...' }}</div>
<p v-if="status === 'failed'" class="text-xs font-semibold text-rose-700">{{ errorMessage }}</p>
</div>
</div>
@ -104,7 +104,7 @@ watch(open, () => {
<Button v-if="canApply" @click="apply">
{{ $t('posts.ai.generate.apply') }}
</Button>
<Button v-if="canRetry" variant="secondary" @click="retry">
<Button v-if="canRetry" variant="outline" @click="retry">
{{ $t('posts.ai.generate.retry') }}
</Button>
<Button variant="outline" @click="open = false">

View file

@ -93,39 +93,39 @@ watch(open, (isOpen) => {
<DialogDescription>{{ $t('posts.ai.review.description') }}</DialogDescription>
</DialogHeader>
<p v-if="status === 'loading'" class="py-8 text-center text-sm text-muted-foreground">
<p v-if="status === 'loading'" class="py-8 text-center text-sm font-medium text-foreground/60">
{{ $t('posts.ai.review.loading') }}
</p>
<p v-else-if="status === 'failed'" class="py-4 text-sm text-destructive">{{ errorMessage }}</p>
<p v-else-if="status === 'failed'" class="py-4 text-sm font-semibold text-rose-700">{{ errorMessage }}</p>
<p v-else-if="noIssues" class="py-8 text-center text-sm text-muted-foreground">
<p v-else-if="noIssues" class="py-8 text-center text-sm font-medium text-foreground/60">
{{ $t('posts.ai.review.no_issues') }}
</p>
<div v-else-if="suggestions.length > 0" class="max-h-[400px] space-y-2 overflow-y-auto pr-1">
<div v-else-if="suggestions.length > 0" class="max-h-[400px] space-y-3 overflow-y-auto pr-1">
<article
v-for="(s, idx) in suggestions"
:key="idx"
class="rounded-md border bg-card px-4 py-3 transition-opacity"
class="rounded-xl border-2 border-foreground bg-card px-4 py-3 shadow-2xs transition-opacity"
:class="appliedSet.has(idx) ? 'opacity-50' : ''"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1 space-y-1.5">
<p class="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm leading-relaxed">
<span class="rounded bg-destructive/15 px-1.5 py-0.5 text-destructive line-through decoration-destructive/50">
<span class="rounded-md border-2 border-foreground bg-rose-100 px-1.5 py-0.5 font-bold text-rose-700 line-through decoration-rose-700/60 shadow-2xs">
{{ s.original }}
</span>
<span class="text-muted-foreground"></span>
<span class="rounded bg-emerald-500/15 px-1.5 py-0.5 font-medium text-emerald-700 dark:text-emerald-400">
<span class="font-bold text-foreground/40"></span>
<span class="rounded-md border-2 border-foreground bg-emerald-100 px-1.5 py-0.5 font-bold text-emerald-700 shadow-2xs">
{{ s.suggestion }}
</span>
</p>
<p class="text-xs text-muted-foreground">{{ s.reason }}</p>
<p class="text-xs font-medium text-foreground/60">{{ s.reason }}</p>
</div>
<Button
size="sm"
variant="secondary"
variant="outline"
:disabled="appliedSet.has(idx)"
@click="applySuggestion(idx, s)"
>

View file

@ -292,74 +292,80 @@ onUnmounted(() => unsubscribeEcho());
<template>
<div class="space-y-6">
<!-- Back button consistent across both steps -->
<div class="flex items-center">
<Button variant="ghost" size="sm" class="-ml-2 text-muted-foreground" @click="goBack">
<IconArrowLeft class="mr-1 size-4" />
{{ $t('posts.create.steps.back') }}
</Button>
</div>
<!-- Back button sticker arrow + ink label, mirrors the marketing site -->
<button
type="button"
class="group inline-flex cursor-pointer items-center gap-1.5 text-sm font-semibold text-foreground/70 transition-colors hover:text-foreground"
@click="goBack"
>
<span class="inline-flex size-7 items-center justify-center rounded-md border-2 border-foreground bg-card shadow-2xs transition-transform group-hover:-translate-x-0.5">
<IconArrowLeft class="size-3.5 text-foreground" stroke-width="2.5" />
</span>
{{ $t('posts.create.steps.back') }}
</button>
<!-- ====== Step 1: Configure (everything in one screen) ====== -->
<template v-if="step === 'configure'">
<!-- Format -->
<div class="space-y-2">
<Label class="text-sm font-medium">{{ $t('posts.create.steps.format_title') }}</Label>
<Label class="text-sm font-bold">{{ $t('posts.create.steps.format_title') }}</Label>
<div class="grid gap-2 sm:grid-cols-2">
<button
v-for="format in availableFormats"
:key="format.value"
type="button"
class="flex items-center gap-3 rounded-xl border bg-card p-3.5 text-left text-sm transition-all hover:border-primary/50 hover:bg-primary/5 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:border-border disabled:hover:bg-card"
:class="{ 'border-primary bg-primary/5 ring-1 ring-primary/30': selectedFormat === format.value }"
class="flex cursor-pointer items-center gap-3 rounded-xl border-2 border-foreground bg-card p-3.5 text-left text-sm shadow-2xs transition-all hover:bg-foreground/5 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-card"
:class="{ '!bg-violet-100 shadow-md': selectedFormat === format.value }"
:disabled="!isFormatConnected(format)"
:title="!isFormatConnected(format) ? $t('posts.create.steps.connect_first') : ''"
@click="selectFormat(format.value)"
>
<img
:src="getPlatformLogo(format.platforms[0])"
:alt="format.platforms[0]"
class="size-6 rounded-full ring-1 ring-background"
/>
<span class="flex-1 font-medium">{{ $t(`posts.create.steps.format.${format.value}`) }}</span>
<IconCheck v-if="selectedFormat === format.value" class="size-4 text-primary" />
<span class="inline-flex size-7 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img
:src="getPlatformLogo(format.platforms[0])"
:alt="format.platforms[0]"
class="size-full object-cover"
/>
</span>
<span class="flex-1 font-semibold text-foreground">{{ $t(`posts.create.steps.format.${format.value}`) }}</span>
<IconCheck v-if="selectedFormat === format.value" class="size-4 text-foreground" stroke-width="3" />
</button>
</div>
</div>
<!-- Account (only when there's a choice to make) -->
<div v-if="selectedFormat && showsAccountPicker" class="space-y-2">
<Label class="text-sm font-medium">{{ $t('posts.create.steps.account_title') }}</Label>
<Label class="text-sm font-bold">{{ $t('posts.create.steps.account_title') }}</Label>
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
<button
v-for="account in accountsForFormat"
:key="account.id"
type="button"
class="relative flex items-center gap-2 rounded-xl border bg-card p-2.5 text-left text-sm transition-all hover:border-primary/50 hover:bg-primary/5"
:class="{ 'border-primary bg-primary/5 ring-1 ring-primary/30': selectedAccountId === account.id }"
class="relative flex cursor-pointer items-center gap-2 rounded-xl border-2 border-foreground bg-card p-2.5 text-left text-sm shadow-2xs transition-all hover:bg-foreground/5"
:class="{ '!bg-violet-100 shadow-md': selectedAccountId === account.id }"
@click="selectedAccountId = account.id"
>
<img
v-if="account.avatar_url"
:src="account.avatar_url"
:alt="account.display_name"
class="size-8 shrink-0 rounded-full"
/>
<div v-else class="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted">
<img :src="getPlatformLogo(account.platform)" :alt="account.platform" class="size-4" />
</div>
<span class="inline-flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img
v-if="account.avatar_url"
:src="account.avatar_url"
:alt="account.display_name"
class="size-full object-cover"
/>
<img v-else :src="getPlatformLogo(account.platform)" :alt="account.platform" class="size-4" />
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-xs font-medium leading-tight">{{ account.display_name }}</p>
<p v-if="account.username" class="truncate text-xs text-muted-foreground">@{{ account.username }}</p>
<p class="truncate text-xs font-bold leading-tight text-foreground">{{ account.display_name }}</p>
<p v-if="account.username" class="truncate text-xs font-medium text-foreground/60">@{{ account.username }}</p>
</div>
<IconCheck v-if="selectedAccountId === account.id" class="absolute right-2 top-2 size-3.5 text-primary" />
<IconCheck v-if="selectedAccountId === account.id" class="absolute right-2 top-2 size-3.5 text-foreground" stroke-width="3" />
</button>
</div>
</div>
<!-- Media inline, only when format actually has options -->
<div v-if="selectedFormat && isCarousel" class="space-y-2">
<Label class="text-sm font-medium">{{ $t('posts.create.steps.media_carousel') }}</Label>
<Label class="text-sm font-bold">{{ $t('posts.create.steps.media_carousel') }}</Label>
<div class="flex flex-wrap gap-2">
<Button
v-for="n in [2, 3, 4, 5, 6, 7, 8, 9, 10]"
@ -375,7 +381,7 @@ onUnmounted(() => unsubscribeEcho());
</div>
<div v-if="selectedFormat && supportsOptionalImages" class="space-y-2">
<Label class="text-sm font-medium">{{ $t('posts.create.steps.media_optional_label') }}</Label>
<Label class="text-sm font-bold">{{ $t('posts.create.steps.media_optional_label') }}</Label>
<div class="flex flex-wrap gap-2">
<Button
type="button"
@ -399,7 +405,7 @@ onUnmounted(() => unsubscribeEcho());
<!-- Prompt -->
<div v-if="selectedFormat" class="space-y-2">
<Label for="ai-prompt" class="text-sm font-medium">{{ $t('posts.create.steps.prompt_label') }}</Label>
<Label for="ai-prompt" class="text-sm font-bold">{{ $t('posts.create.steps.prompt_label') }}</Label>
<Textarea
id="ai-prompt"
v-model="promptText"
@ -418,19 +424,21 @@ onUnmounted(() => unsubscribeEcho());
<!-- ====== Step 2: Preview ====== -->
<template v-else-if="step === 'preview'">
<div v-if="previewStatus === 'loading'" class="flex flex-col items-center gap-4 rounded-xl border bg-muted/20 py-16 text-center">
<IconLoader2 class="size-10 animate-spin text-primary" />
<p class="text-sm text-muted-foreground">{{ $t('posts.create.steps.preview_loading') }}</p>
<div v-if="previewStatus === 'loading'" class="flex flex-col items-center gap-4 rounded-2xl border-2 border-foreground bg-card py-16 text-center shadow-2xs">
<div class="inline-flex size-12 -rotate-2 items-center justify-center rounded-2xl border-2 border-foreground bg-violet-200 shadow-2xs">
<IconLoader2 class="size-6 animate-spin text-foreground" stroke-width="2" />
</div>
<p class="text-sm font-semibold text-foreground/70">{{ $t('posts.create.steps.preview_loading') }}</p>
</div>
<div v-else-if="previewStatus === 'error'" class="space-y-4">
<div class="rounded-xl border border-destructive/30 bg-destructive/5 p-4">
<p class="text-sm text-destructive">{{ previewError || $t('posts.create.steps.preview_error') }}</p>
<div class="rounded-xl border-2 border-foreground bg-rose-50 p-4 shadow-2xs">
<p class="text-sm font-semibold text-rose-700">{{ previewError || $t('posts.create.steps.preview_error') }}</p>
</div>
<div class="flex justify-end">
<Button variant="outline" @click="retryGeneration">
<IconRefresh class="mr-1 size-4" />
<IconRefresh class="size-4" />
{{ $t('posts.create.steps.retry') }}
</Button>
</div>
@ -438,14 +446,14 @@ onUnmounted(() => unsubscribeEcho());
<div v-else-if="previewStatus === 'done'" class="space-y-4">
<!-- Caption-less formats (Stories): edit title + body separately. -->
<div v-if="!supportsCaption" class="space-y-3 rounded-xl border bg-muted/20 p-5">
<div class="space-y-1">
<Label class="text-xs font-medium text-muted-foreground">{{ $t('posts.create.preview.image_title') }}</Label>
<Input v-model="previewImageTitle" class="bg-background" />
<div v-if="!supportsCaption" class="space-y-3 rounded-2xl border-2 border-foreground bg-card p-5 shadow-2xs">
<div class="space-y-1.5">
<Label class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.create.preview.image_title') }}</Label>
<Input v-model="previewImageTitle" />
</div>
<div class="space-y-1">
<Label class="text-xs font-medium text-muted-foreground">{{ $t('posts.create.preview.image_body') }}</Label>
<Textarea v-model="previewImageBody" class="min-h-[120px] resize-none bg-background" />
<div class="space-y-1.5">
<Label class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.create.preview.image_body') }}</Label>
<Textarea v-model="previewImageBody" class="min-h-[120px] resize-none" />
</div>
</div>
@ -453,15 +461,16 @@ onUnmounted(() => unsubscribeEcho());
<Textarea
v-else
v-model="previewContent"
class="min-h-[200px] resize-none rounded-xl border bg-muted/20 p-5 text-sm leading-relaxed"
class="min-h-[200px] resize-none p-5 text-sm leading-relaxed"
/>
<div class="flex justify-end gap-2">
<Button variant="outline" @click="retryGeneration">
<IconRefresh class="size-4" />
{{ $t('posts.create.steps.retry') }}
</Button>
<Button :disabled="finalizing" @click="createPost">
<IconLoader2 v-if="finalizing" class="mr-1 size-4 animate-spin" />
<IconLoader2 v-if="finalizing" class="size-4 animate-spin" />
{{ $t('posts.create.steps.create') }}
</Button>
</div>

View file

@ -8,6 +8,7 @@ import {
IconTrash,
IconX,
} from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import CommentBody from '@/components/CommentBody.vue';
@ -103,7 +104,11 @@ const commentsByDay = computed((): DayGroup[] => {
const yesterday = dayjs().subtract(1, 'day').format('YYYY-MM-DD');
return Array.from(groups.entries()).map(([day, items]) => ({
label: day === today ? 'Today' : day === yesterday ? 'Yesterday' : dayjs(day).format('MMM D, YYYY'),
label: day === today
? trans('comments.today')
: day === yesterday
? trans('comments.yesterday')
: dayjs(day).format('D MMM YYYY'),
comments: items,
}));
});
@ -475,20 +480,20 @@ watch(() => props.postId, () => {
<!-- Empty state -->
<div v-if="!loading && comments.length === 0" class="flex flex-col items-center justify-center py-12 text-center">
<p class="text-sm text-muted-foreground">{{ $t('comments.empty') }}</p>
<p class="text-sm font-medium text-foreground/60">{{ $t('comments.empty') }}</p>
</div>
<!-- Comments grouped by day -->
<div class="px-2 py-1">
<template v-for="group in commentsByDay" :key="group.label">
<div class="mt-2 mb-4 text-center text-xs text-muted-foreground">{{ group.label }}</div>
<div class="mb-4 mt-2 text-center text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ group.label }}</div>
<template v-for="comment in group.comments" :key="comment.id">
<!-- Top-level comment -->
<div
:data-comment-id="comment.id"
class="group relative rounded-lg py-1.5 px-2 transition-colors"
:class="highlightedId === comment.id ? 'bg-primary/10 ring-1 ring-primary/30' : 'hover:bg-muted/50'"
:class="highlightedId === comment.id ? 'bg-violet-100 ring-2 ring-foreground' : 'hover:bg-foreground/5'"
@mouseenter="hoveredCommentId = comment.id"
@mouseleave="hoveredCommentId = null; emojiPickerCommentId = null"
>
@ -509,39 +514,41 @@ watch(() => props.postId, () => {
<!-- Display mode -->
<template v-else>
<div class="flex items-start gap-2">
<Avatar class="h-6 w-6 shrink-0">
<div class="flex items-start gap-3">
<Avatar class="size-8 shrink-0 rounded-full border-2 border-foreground shadow-2xs">
<AvatarImage v-if="getAvatarUrl(comment.user)" :src="getAvatarUrl(comment.user)!" />
<AvatarFallback class="text-[10px]">{{ getInitials(comment.user.name) }}</AvatarFallback>
<AvatarFallback class="rounded-full bg-violet-100 text-[10px] font-bold text-violet-700">{{ getInitials(comment.user.name) }}</AvatarFallback>
</Avatar>
<div class="min-w-0 flex-1">
<div class="flex items-baseline gap-1.5">
<span class="text-xs font-medium">{{ comment.user.name }}</span>
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span class="text-sm font-bold text-foreground">{{ comment.user.name }}</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<span class="text-[10px] text-muted-foreground">{{ date.diffForHumans(comment.created_at) }}</span>
<span class="text-xs font-medium text-foreground/60">{{ date.diffForHumans(comment.created_at) }}</span>
</TooltipTrigger>
<TooltipContent side="top">
<span class="text-xs">{{ date.formatDateTime(comment.created_at) }}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span v-if="comment.updated_at !== comment.created_at" class="text-[10px] text-muted-foreground italic">({{ $t('comments.edited') }})</span>
<span v-if="comment.updated_at !== comment.created_at" class="text-xs font-medium italic text-foreground/60">({{ $t('comments.edited') }})</span>
</div>
<div class="mt-0.5 text-sm leading-relaxed text-foreground">
<CommentBody :body="comment.body" :members="memberNames" />
</div>
<CommentBody :body="comment.body" :members="memberNames" />
<!-- Reactions -->
<div v-if="groupedReactions(comment.reactions).length > 0" class="mt-1.5 flex flex-wrap gap-1">
<button
v-for="r in groupedReactions(comment.reactions)"
:key="r.emoji"
class="inline-flex items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-xs transition-colors"
:class="r.hasReacted ? 'border-primary/40 bg-primary/10' : 'border-border hover:border-primary/30'"
class="inline-flex items-center gap-1 rounded-full border-2 px-2 py-0.5 text-xs font-bold cursor-pointer transition-colors"
:class="r.hasReacted ? 'border-foreground bg-violet-100' : 'border-foreground/30 hover:border-foreground'"
@click="toggleReaction(comment, r.emoji)"
>
<span>{{ r.emoji }}</span>
<span class="text-[10px] text-muted-foreground">{{ r.count }}</span>
<span class="text-[10px] font-medium text-foreground/60">{{ r.count }}</span>
</button>
</div>
@ -550,31 +557,31 @@ watch(() => props.postId, () => {
<!-- Floating toolbar -->
<div
v-if="hoveredCommentId === comment.id && editingComment?.id !== comment.id"
class="absolute -top-3 right-2 z-50 flex items-center gap-0.5 rounded-md border bg-background px-1 py-0.5 shadow-sm"
class="absolute -top-3 right-2 z-50 flex items-center gap-0.5 rounded-md border-2 border-foreground bg-card px-1 py-0.5 shadow-2xs"
>
<TooltipProvider :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" @mouseenter="showEmojiPicker(comment.id)"><IconMoodSmile class="h-3.5 w-3.5" /></button>
<button class="rounded p-1 text-muted-foreground hover:bg-foreground/5 hover:text-foreground" @mouseenter="showEmojiPicker(comment.id)"><IconMoodSmile class="h-3.5 w-3.5" /></button>
</TooltipTrigger>
<TooltipContent side="top" class="text-xs">React</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" @click="startReply(comment)"><IconArrowBackUp class="h-3.5 w-3.5" /></button>
<button class="rounded p-1 text-muted-foreground hover:bg-foreground/5 hover:text-foreground" @click="startReply(comment)"><IconArrowBackUp class="h-3.5 w-3.5" /></button>
</TooltipTrigger>
<TooltipContent side="top" class="text-xs">{{ $t('comments.reply') }}</TooltipContent>
</Tooltip>
<template v-if="comment.user_id === currentUserId">
<Tooltip>
<TooltipTrigger as-child>
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" @click="startEdit(comment)"><IconEdit class="h-3.5 w-3.5" /></button>
<button class="rounded p-1 text-muted-foreground hover:bg-foreground/5 hover:text-foreground" @click="startEdit(comment)"><IconEdit class="h-3.5 w-3.5" /></button>
</TooltipTrigger>
<TooltipContent side="top" class="text-xs">{{ $t('comments.edit') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-destructive" @click="deleteComment(comment)"><IconTrash class="h-3.5 w-3.5" /></button>
<button class="rounded p-1 text-muted-foreground hover:bg-rose-100 hover:text-rose-700" @click="deleteComment(comment)"><IconTrash class="h-3.5 w-3.5" /></button>
</TooltipTrigger>
<TooltipContent side="top" class="text-xs">{{ $t('comments.delete') }}</TooltipContent>
</Tooltip>
@ -585,7 +592,7 @@ watch(() => props.postId, () => {
<!-- Emoji picker -->
<div
v-if="emojiPickerCommentId === comment.id"
class="absolute -top-10 right-2 z-50 flex items-center gap-0.5 rounded-lg border bg-popover p-1.5 shadow-md" >
class="absolute -top-10 right-2 z-50 flex items-center gap-0.5 rounded-lg border-2 border-foreground bg-card p-1.5 shadow-md" >
<button v-for="emoji in EMOJIS" :key="emoji" class="rounded p-0.5 text-sm transition-transform hover:scale-125 hover:bg-muted" @click="toggleReaction(comment, emoji)">{{ emoji }}</button>
</div>
</div>
@ -599,7 +606,7 @@ watch(() => props.postId, () => {
:key="reply.id"
:data-comment-id="reply.id"
class="group relative ml-8 rounded-lg py-1.5 px-2 transition-colors"
:class="highlightedId === reply.id ? 'bg-primary/10 ring-1 ring-primary/30' : 'hover:bg-muted/50'"
:class="highlightedId === reply.id ? 'bg-violet-100 ring-2 ring-foreground' : 'hover:bg-foreground/5'"
@mouseenter="hoveredCommentId = reply.id"
@mouseleave="hoveredCommentId = null; emojiPickerCommentId = null"
>
@ -620,39 +627,41 @@ watch(() => props.postId, () => {
<!-- Display reply -->
<template v-else>
<div class="flex items-start gap-2">
<Avatar class="h-5 w-5 shrink-0">
<div class="flex items-start gap-2.5">
<Avatar class="size-7 shrink-0 rounded-full border-2 border-foreground shadow-2xs">
<AvatarImage v-if="getAvatarUrl(reply.user)" :src="getAvatarUrl(reply.user)!" />
<AvatarFallback class="text-[9px]">{{ getInitials(reply.user.name) }}</AvatarFallback>
<AvatarFallback class="rounded-full bg-violet-100 text-[10px] font-bold text-violet-700">{{ getInitials(reply.user.name) }}</AvatarFallback>
</Avatar>
<div class="min-w-0 flex-1">
<div class="flex items-baseline gap-1.5">
<span class="text-xs font-medium">{{ reply.user.name }}</span>
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span class="text-sm font-bold text-foreground">{{ reply.user.name }}</span>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<span class="text-[10px] text-muted-foreground">{{ date.diffForHumans(reply.created_at) }}</span>
<span class="text-xs font-medium text-foreground/60">{{ date.diffForHumans(reply.created_at) }}</span>
</TooltipTrigger>
<TooltipContent side="top">
<span class="text-xs">{{ date.formatDateTime(reply.created_at) }}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span v-if="reply.updated_at !== reply.created_at" class="text-[10px] text-muted-foreground italic">({{ $t('comments.edited') }})</span>
<span v-if="reply.updated_at !== reply.created_at" class="text-xs font-medium italic text-foreground/60">({{ $t('comments.edited') }})</span>
</div>
<div class="mt-0.5 text-sm leading-relaxed text-foreground">
<CommentBody :body="reply.body" :members="memberNames" />
</div>
<CommentBody :body="reply.body" :members="memberNames" />
<!-- Reply reactions -->
<div v-if="groupedReactions(reply.reactions).length > 0" class="mt-1.5 flex flex-wrap gap-1">
<button
v-for="r in groupedReactions(reply.reactions)"
:key="r.emoji"
class="inline-flex items-center gap-0.5 rounded-full border px-1.5 py-0.5 text-xs transition-colors"
:class="r.hasReacted ? 'border-primary/40 bg-primary/10' : 'border-border hover:border-primary/30'"
class="inline-flex items-center gap-1 rounded-full border-2 px-2 py-0.5 text-xs font-bold cursor-pointer transition-colors"
:class="r.hasReacted ? 'border-foreground bg-violet-100' : 'border-foreground/30 hover:border-foreground'"
@click="toggleReaction(reply, r.emoji)"
>
<span>{{ r.emoji }}</span>
<span class="text-[10px] text-muted-foreground">{{ r.count }}</span>
<span class="text-[10px] font-medium text-foreground/60">{{ r.count }}</span>
</button>
</div>
@ -661,25 +670,25 @@ watch(() => props.postId, () => {
<!-- Reply floating toolbar -->
<div
v-if="hoveredCommentId === reply.id && editingComment?.id !== reply.id"
class="absolute -top-3 right-2 z-50 flex items-center gap-0.5 rounded-md border bg-background px-1 py-0.5 shadow-sm"
class="absolute -top-3 right-2 z-50 flex items-center gap-0.5 rounded-md border-2 border-foreground bg-card px-1 py-0.5 shadow-2xs"
>
<TooltipProvider :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" @mouseenter="showEmojiPicker(reply.id)"><IconMoodSmile class="h-3.5 w-3.5" /></button>
<button class="rounded p-1 text-muted-foreground hover:bg-foreground/5 hover:text-foreground" @mouseenter="showEmojiPicker(reply.id)"><IconMoodSmile class="h-3.5 w-3.5" /></button>
</TooltipTrigger>
<TooltipContent side="top" class="text-xs">React</TooltipContent>
</Tooltip>
<template v-if="reply.user_id === currentUserId">
<Tooltip>
<TooltipTrigger as-child>
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground" @click="startEdit(reply)"><IconEdit class="h-3.5 w-3.5" /></button>
<button class="rounded p-1 text-muted-foreground hover:bg-foreground/5 hover:text-foreground" @click="startEdit(reply)"><IconEdit class="h-3.5 w-3.5" /></button>
</TooltipTrigger>
<TooltipContent side="top" class="text-xs">{{ $t('comments.edit') }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button class="rounded p-1 text-muted-foreground hover:bg-muted hover:text-destructive" @click="deleteComment(reply)"><IconTrash class="h-3.5 w-3.5" /></button>
<button class="rounded p-1 text-muted-foreground hover:bg-rose-100 hover:text-rose-700" @click="deleteComment(reply)"><IconTrash class="h-3.5 w-3.5" /></button>
</TooltipTrigger>
<TooltipContent side="top" class="text-xs">{{ $t('comments.delete') }}</TooltipContent>
</Tooltip>
@ -690,7 +699,7 @@ watch(() => props.postId, () => {
<!-- Reply emoji picker -->
<div
v-if="emojiPickerCommentId === reply.id"
class="absolute -top-10 right-2 z-50 flex items-center gap-0.5 rounded-lg border bg-popover p-1.5 shadow-md" >
class="absolute -top-10 right-2 z-50 flex items-center gap-0.5 rounded-lg border-2 border-foreground bg-card p-1.5 shadow-md" >
<button v-for="emoji in EMOJIS" :key="emoji" class="rounded p-0.5 text-sm transition-transform hover:scale-125 hover:bg-muted" @click="toggleReaction(reply, emoji)">{{ emoji }}</button>
</div>
</div>
@ -708,13 +717,13 @@ watch(() => props.postId, () => {
</div>
<!-- Input area -->
<div class="shrink-0 border-t p-2">
<div class="shrink-0 border-t-2 border-foreground/10 p-2">
<!-- Replying to indicator -->
<div v-if="replyingTo" class="mb-1.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<IconArrowBackUp class="h-3 w-3" />
<div v-if="replyingTo" class="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-foreground/70">
<IconArrowBackUp class="size-3" />
<span>{{ $t('comments.replying_to', { name: replyingTo.user.name }) }}</span>
<button class="ml-auto rounded p-0.5 hover:bg-muted" @click="cancelReply">
<IconX class="h-3 w-3" />
<button class="ml-auto cursor-pointer rounded p-0.5 hover:bg-foreground/5" @click="cancelReply">
<IconX class="size-3" />
</button>
</div>
@ -731,13 +740,12 @@ watch(() => props.postId, () => {
/>
<Button
size="icon"
variant="ghost"
class="h-9 w-9 shrink-0"
class="size-9 shrink-0"
:disabled="!newBody.trim() || sending"
@click="sendComment"
>
<IconLoader2 v-if="sending" class="h-4 w-4 animate-spin" />
<IconSend v-else class="h-4 w-4" />
<IconLoader2 v-if="sending" class="size-4 animate-spin" />
<IconSend v-else class="size-4" />
</Button>
</div>
</div>

View file

@ -1,9 +1,10 @@
<script setup lang="ts">
import { IconAlertTriangle, IconBrandFacebook, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
interface SocialAccount {
id: string;
@ -45,48 +46,50 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
</script>
<template>
<div class="rounded-lg border">
<div class="rounded-xl border-2 border-foreground bg-card shadow-2xs">
<button
type="button"
class="flex w-full items-center justify-between p-4 text-sm font-medium"
class="flex w-full cursor-pointer items-center justify-between gap-3 p-4 text-sm"
@click="open = !open"
>
<span class="flex items-center gap-2">
<IconBrandFacebook class="size-5" />
<span>{{ $t('posts.form.facebook.settings') }}</span>
<span v-if="socialAccount" class="text-muted-foreground">·&nbsp;@{{ socialAccount.username }}</span>
<span class="flex min-w-0 items-center gap-2">
<span class="inline-flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img :src="getPlatformLogo('facebook')" alt="Facebook" class="size-full object-cover" />
</span>
<span class="truncate font-bold text-foreground">{{ $t('posts.form.facebook.settings') }}</span>
<span v-if="socialAccount?.username" class="truncate font-medium text-foreground/60">·&nbsp;@{{ socialAccount.username }}</span>
</span>
<IconChevronUp v-if="open" class="h-4 w-4 text-muted-foreground" />
<IconChevronDown v-else class="h-4 w-4 text-muted-foreground" />
<IconChevronUp v-if="open" class="size-4 shrink-0 text-foreground/60" />
<IconChevronDown v-else class="size-4 shrink-0 text-foreground/60" />
</button>
<div v-if="open" class="space-y-5 border-t px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-muted/50 p-3">
<div v-if="open" class="space-y-5 border-t-2 border-foreground/10 px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3">
<Avatar
:src="socialAccount.avatar_url"
:name="socialAccount.display_name"
class="h-9 w-9 shrink-0 rounded-full"
class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs"
/>
<div class="min-w-0 flex-1">
<p class="text-xs text-muted-foreground">{{ $t('posts.form.facebook.posting_to') }}</p>
<p class="truncate text-sm font-medium">
{{ socialAccount.display_name }}
<span class="text-muted-foreground">@{{ socialAccount.username }}</span>
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.facebook.posting_to') }}</p>
<p class="truncate text-sm">
<span class="font-bold text-foreground">{{ socialAccount.display_name }}</span>
<span v-if="socialAccount?.username" class="font-medium text-foreground/60">&nbsp;@{{ socialAccount.username }}</span>
</p>
</div>
</div>
<div class="space-y-2">
<p class="text-sm font-medium">{{ $t('posts.form.facebook.variant_label') }}</p>
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.facebook.variant_label') }}</p>
<div class="flex flex-wrap gap-2">
<button
v-for="variant in variants"
:key="variant.value"
type="button"
class="rounded-full border px-3 py-1.5 text-xs transition-colors"
class="cursor-pointer rounded-full border-2 px-3 py-1 text-xs font-bold uppercase tracking-widest transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:class="contentType === variant.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:text-foreground'"
? 'border-foreground bg-violet-100 text-foreground shadow-2xs'
: 'border-foreground/30 text-foreground/70 hover:border-foreground hover:text-foreground'"
:disabled="disabled"
@click="pickVariant(variant.value)"
>
@ -97,9 +100,9 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
<p
v-if="warning"
class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive"
class="flex items-start gap-2 rounded-lg border-2 border-foreground bg-rose-50 p-2 text-xs font-semibold text-rose-700"
>
<IconAlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<IconAlertTriangle class="mt-0.5 size-3.5 shrink-0" />
{{ $t(`posts.form.warnings.${warning.key}`, warning.params) }}
</p>
</div>

View file

@ -1,9 +1,10 @@
<script setup lang="ts">
import { IconAlertTriangle, IconBrandInstagram, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { ContentType } from '@/enums/content-type';
interface SocialAccount {
@ -64,48 +65,50 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
</script>
<template>
<div class="rounded-lg border">
<div class="rounded-xl border-2 border-foreground bg-card shadow-2xs">
<button
type="button"
class="flex w-full items-center justify-between p-4 text-sm font-medium"
class="flex w-full cursor-pointer items-center justify-between gap-3 p-4 text-sm"
@click="open = !open"
>
<span class="flex items-center gap-2">
<IconBrandInstagram class="size-5" />
<span>{{ $t('posts.form.instagram.settings') }}</span>
<span v-if="socialAccount" class="text-muted-foreground">·&nbsp;@{{ socialAccount.username }}</span>
<span class="flex min-w-0 items-center gap-2">
<span class="inline-flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img :src="getPlatformLogo(socialAccount?.platform ?? 'instagram')" alt="Instagram" class="size-full object-cover" />
</span>
<span class="truncate font-bold text-foreground">{{ $t('posts.form.instagram.settings') }}</span>
<span v-if="socialAccount?.username" class="truncate font-medium text-foreground/60">·&nbsp;@{{ socialAccount.username }}</span>
</span>
<IconChevronUp v-if="open" class="h-4 w-4 text-muted-foreground" />
<IconChevronDown v-else class="h-4 w-4 text-muted-foreground" />
<IconChevronUp v-if="open" class="size-4 shrink-0 text-foreground/60" />
<IconChevronDown v-else class="size-4 shrink-0 text-foreground/60" />
</button>
<div v-if="open" class="space-y-5 border-t px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-muted/50 p-3">
<div v-if="open" class="space-y-5 border-t-2 border-foreground/10 px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3">
<Avatar
:src="socialAccount.avatar_url"
:name="socialAccount.display_name"
class="h-9 w-9 shrink-0 rounded-full"
class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs"
/>
<div class="min-w-0 flex-1">
<p class="text-xs text-muted-foreground">{{ $t('posts.form.instagram.posting_to') }}</p>
<p class="truncate text-sm font-medium">
{{ socialAccount.display_name }}
<span class="text-muted-foreground">@{{ socialAccount.username }}</span>
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.instagram.posting_to') }}</p>
<p class="truncate text-sm">
<span class="font-bold text-foreground">{{ socialAccount.display_name }}</span>
<span v-if="socialAccount?.username" class="font-medium text-foreground/60">&nbsp;@{{ socialAccount.username }}</span>
</p>
</div>
</div>
<div class="space-y-2">
<p class="text-sm font-medium">{{ $t('posts.form.instagram.variant_label') }}</p>
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.instagram.variant_label') }}</p>
<div class="flex flex-wrap gap-2">
<button
v-for="variant in variants"
:key="variant.value"
type="button"
class="rounded-full border px-3 py-1.5 text-xs transition-colors"
class="cursor-pointer rounded-full border-2 px-3 py-1 text-xs font-bold uppercase tracking-widest transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:class="contentType === variant.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:text-foreground'"
? 'border-foreground bg-violet-100 text-foreground shadow-2xs'
: 'border-foreground/30 text-foreground/70 hover:border-foreground hover:text-foreground'"
:disabled="disabled"
@click="pickVariant(variant.value)"
>
@ -115,16 +118,16 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
</div>
<div v-if="isFeed" class="space-y-2">
<p class="text-sm font-medium">{{ $t('posts.form.instagram.aspect_label') }}</p>
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.instagram.aspect_label') }}</p>
<div class="flex flex-wrap gap-2">
<button
v-for="ratio in aspectRatios"
:key="ratio.value"
type="button"
class="rounded-full border px-3 py-1.5 text-xs transition-colors"
class="cursor-pointer rounded-full border-2 px-3 py-1 text-xs font-bold uppercase tracking-widest transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:class="selectedAspectRatio === ratio.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:text-foreground'"
? 'border-foreground bg-violet-100 text-foreground shadow-2xs'
: 'border-foreground/30 text-foreground/70 hover:border-foreground hover:text-foreground'"
:disabled="disabled"
@click="pickAspectRatio(ratio.value)"
>
@ -135,9 +138,9 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
<p
v-if="warning"
class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive"
class="flex items-start gap-2 rounded-lg border-2 border-foreground bg-rose-50 p-2 text-xs font-semibold text-rose-700"
>
<IconAlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<IconAlertTriangle class="mt-0.5 size-3.5 shrink-0" />
{{ $t(`posts.form.warnings.${warning.key}`, warning.params) }}
</p>
</div>

View file

@ -1,9 +1,10 @@
<script setup lang="ts">
import { IconAlertTriangle, IconBrandLinkedin, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { ContentType } from '@/enums/content-type';
interface SocialAccount {
@ -55,48 +56,50 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
</script>
<template>
<div class="rounded-lg border">
<div class="rounded-xl border-2 border-foreground bg-card shadow-2xs">
<button
type="button"
class="flex w-full items-center justify-between p-4 text-sm font-medium"
class="flex w-full cursor-pointer items-center justify-between gap-3 p-4 text-sm"
@click="open = !open"
>
<span class="flex items-center gap-2">
<IconBrandLinkedin class="size-5" />
<span>{{ isPage ? $t('posts.form.linkedin.settings_page') : $t('posts.form.linkedin.settings') }}</span>
<span v-if="socialAccount" class="text-muted-foreground">·&nbsp;@{{ socialAccount.username }}</span>
<span class="flex min-w-0 items-center gap-2">
<span class="inline-flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img :src="getPlatformLogo(platform)" alt="LinkedIn" class="size-full object-cover" />
</span>
<span class="truncate font-bold text-foreground">{{ isPage ? $t('posts.form.linkedin.settings_page') : $t('posts.form.linkedin.settings') }}</span>
<span v-if="socialAccount?.username" class="truncate font-medium text-foreground/60">·&nbsp;@{{ socialAccount.username }}</span>
</span>
<IconChevronUp v-if="open" class="h-4 w-4 text-muted-foreground" />
<IconChevronDown v-else class="h-4 w-4 text-muted-foreground" />
<IconChevronUp v-if="open" class="size-4 shrink-0 text-foreground/60" />
<IconChevronDown v-else class="size-4 shrink-0 text-foreground/60" />
</button>
<div v-if="open" class="space-y-5 border-t px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-muted/50 p-3">
<div v-if="open" class="space-y-5 border-t-2 border-foreground/10 px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3">
<Avatar
:src="socialAccount.avatar_url"
:name="socialAccount.display_name"
class="h-9 w-9 shrink-0 rounded-full"
class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs"
/>
<div class="min-w-0 flex-1">
<p class="text-xs text-muted-foreground">{{ $t('posts.form.linkedin.posting_to') }}</p>
<p class="truncate text-sm font-medium">
{{ socialAccount.display_name }}
<span class="text-muted-foreground">@{{ socialAccount.username }}</span>
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.linkedin.posting_to') }}</p>
<p class="truncate text-sm">
<span class="font-bold text-foreground">{{ socialAccount.display_name }}</span>
<span v-if="socialAccount?.username" class="font-medium text-foreground/60">&nbsp;@{{ socialAccount.username }}</span>
</p>
</div>
</div>
<div class="space-y-2">
<p class="text-sm font-medium">{{ $t('posts.form.linkedin.variant_label') }}</p>
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.linkedin.variant_label') }}</p>
<div class="flex flex-wrap gap-2">
<button
v-for="variant in variants"
:key="variant.value"
type="button"
class="rounded-full border px-3 py-1.5 text-xs transition-colors"
class="cursor-pointer rounded-full border-2 px-3 py-1 text-xs font-bold uppercase tracking-widest transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:class="contentType === variant.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:text-foreground'"
? 'border-foreground bg-violet-100 text-foreground shadow-2xs'
: 'border-foreground/30 text-foreground/70 hover:border-foreground hover:text-foreground'"
:disabled="disabled"
@click="pickVariant(variant.value)"
>
@ -107,9 +110,9 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
<p
v-if="warning"
class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive"
class="flex items-start gap-2 rounded-lg border-2 border-foreground bg-rose-50 p-2 text-xs font-semibold text-rose-700"
>
<IconAlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<IconAlertTriangle class="mt-0.5 size-3.5 shrink-0" />
{{ $t(`posts.form.warnings.${warning.key}`, warning.params) }}
</p>
</div>

View file

@ -1,9 +1,10 @@
<script setup lang="ts">
import { IconAlertTriangle, IconBrandPinterest, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref } from 'vue';
import { Avatar } from '@/components/ui/avatar';
import { getMediaValidationWarning, type MediaItem } from '@/composables/useMedia';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
import { ContentType } from '@/enums/content-type';
interface SocialAccount {
@ -46,48 +47,50 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
</script>
<template>
<div class="rounded-lg border">
<div class="rounded-xl border-2 border-foreground bg-card shadow-2xs">
<button
type="button"
class="flex w-full items-center justify-between p-4 text-sm font-medium"
class="flex w-full cursor-pointer items-center justify-between gap-3 p-4 text-sm"
@click="open = !open"
>
<span class="flex items-center gap-2">
<IconBrandPinterest class="size-5" />
<span>{{ $t('posts.form.pinterest.settings') }}</span>
<span v-if="socialAccount" class="text-muted-foreground">·&nbsp;@{{ socialAccount.username }}</span>
<span class="flex min-w-0 items-center gap-2">
<span class="inline-flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img :src="getPlatformLogo('pinterest')" alt="Pinterest" class="size-full object-cover" />
</span>
<span class="truncate font-bold text-foreground">{{ $t('posts.form.pinterest.settings') }}</span>
<span v-if="socialAccount?.username" class="truncate font-medium text-foreground/60">·&nbsp;@{{ socialAccount.username }}</span>
</span>
<IconChevronUp v-if="open" class="h-4 w-4 text-muted-foreground" />
<IconChevronDown v-else class="h-4 w-4 text-muted-foreground" />
<IconChevronUp v-if="open" class="size-4 shrink-0 text-foreground/60" />
<IconChevronDown v-else class="size-4 shrink-0 text-foreground/60" />
</button>
<div v-if="open" class="space-y-5 border-t px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-muted/50 p-3">
<div v-if="open" class="space-y-5 border-t-2 border-foreground/10 px-4 pb-4 pt-4">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3">
<Avatar
:src="socialAccount.avatar_url"
:name="socialAccount.display_name"
class="h-9 w-9 shrink-0 rounded-full"
class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs"
/>
<div class="min-w-0 flex-1">
<p class="text-xs text-muted-foreground">{{ $t('posts.form.pinterest.posting_to') }}</p>
<p class="truncate text-sm font-medium">
{{ socialAccount.display_name }}
<span class="text-muted-foreground">@{{ socialAccount.username }}</span>
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.pinterest.posting_to') }}</p>
<p class="truncate text-sm">
<span class="font-bold text-foreground">{{ socialAccount.display_name }}</span>
<span v-if="socialAccount?.username" class="font-medium text-foreground/60">&nbsp;@{{ socialAccount.username }}</span>
</p>
</div>
</div>
<div class="space-y-2">
<p class="text-sm font-medium">{{ $t('posts.form.pinterest.variant_label') }}</p>
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.pinterest.variant_label') }}</p>
<div class="flex flex-wrap gap-2">
<button
v-for="variant in variants"
:key="variant.value"
type="button"
class="rounded-full border px-3 py-1.5 text-xs transition-colors"
class="cursor-pointer rounded-full border-2 px-3 py-1 text-xs font-bold uppercase tracking-widest transition-colors disabled:cursor-not-allowed disabled:opacity-50"
:class="contentType === variant.value
? 'border-primary bg-primary/10 text-primary'
: 'border-border text-muted-foreground hover:text-foreground'"
? 'border-foreground bg-violet-100 text-foreground shadow-2xs'
: 'border-foreground/30 text-foreground/70 hover:border-foreground hover:text-foreground'"
:disabled="disabled"
@click="pickVariant(variant.value)"
>
@ -98,9 +101,9 @@ const warning = computed(() => getMediaValidationWarning(props.contentType, prop
<p
v-if="warning"
class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive"
class="flex items-start gap-2 rounded-lg border-2 border-foreground bg-rose-50 p-2 text-xs font-semibold text-rose-700"
>
<IconAlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<IconAlertTriangle class="mt-0.5 size-3.5 shrink-0" />
{{ $t(`posts.form.warnings.${warning.key}`, warning.params) }}
</p>
</div>

View file

@ -112,9 +112,9 @@ const limitsWithUsage = computed(() =>
);
const limitClass = (state: string): string => {
if (state === 'over') return 'text-destructive';
if (state === 'warn') return 'text-amber-600 dark:text-amber-400';
return 'text-muted-foreground';
if (state === 'over') return 'border-foreground bg-rose-100 text-rose-700';
if (state === 'warn') return 'border-foreground bg-amber-100 text-amber-800';
return 'border-foreground bg-card text-foreground';
};
const smallestLimit = computed(() => {
@ -289,11 +289,11 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
v-for="(item, index) in media"
:key="item.id"
:ref="(el) => { if (el) mediaThumbRefs[index] = el as HTMLElement; }"
class="group relative aspect-square cursor-zoom-in overflow-hidden rounded-xl bg-muted transition-all focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
class="group relative aspect-square cursor-zoom-in overflow-hidden rounded-xl border-2 border-foreground bg-muted shadow-2xs transition-all focus:outline-none focus:ring-2 focus:ring-foreground focus:ring-offset-2"
:class="[
dragMediaIndex === index ? 'opacity-40' : '',
dragOverIndex === index && dragMediaIndex !== index ? 'ring-2 ring-primary ring-offset-2' : '',
mediaIssues[item.id] ? 'ring-1 ring-destructive ring-offset-1' : '',
dragOverIndex === index && dragMediaIndex !== index ? 'ring-2 ring-foreground ring-offset-2' : '',
mediaIssues[item.id] ? '!border-rose-500' : '',
]"
tabindex="0"
:draggable="media.length > 1"
@ -339,7 +339,7 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
<TooltipProvider v-if="mediaIssues[item.id]" :delay-duration="100">
<Tooltip>
<TooltipTrigger as-child>
<span class="absolute bottom-1.5 right-1.5 flex h-5 items-center gap-0.5 rounded-full bg-destructive px-1.5 text-[10px] font-semibold text-destructive-foreground shadow-sm">
<span class="absolute bottom-1.5 right-1.5 inline-flex h-5 items-center gap-0.5 rounded-full border-2 border-foreground bg-rose-100 px-1.5 text-[10px] font-bold text-rose-700 shadow-2xs">
<IconAlertTriangle class="size-2.5" />
{{ mediaIssues[item.id].length }}
</span>
@ -358,47 +358,45 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
<span
v-if="media.length > 1"
class="absolute left-1.5 top-1.5 flex h-6 w-6 cursor-grab items-center justify-center rounded-md bg-black/55 text-white opacity-0 backdrop-blur-sm transition-opacity group-hover:opacity-100 group-focus:opacity-100"
class="absolute left-1.5 top-1.5 inline-flex size-6 cursor-grab items-center justify-center rounded-md border-2 border-foreground bg-card text-foreground opacity-0 shadow-2xs transition-opacity group-hover:opacity-100 group-focus:opacity-100"
>
<IconGripVertical class="h-3.5 w-3.5" />
<IconGripVertical class="size-3.5" />
</span>
<button
type="button"
class="absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded-md bg-black/55 text-white opacity-0 backdrop-blur-sm transition-all hover:bg-destructive group-hover:opacity-100 group-focus:opacity-100"
class="absolute right-1.5 top-1.5 inline-flex size-6 cursor-pointer items-center justify-center rounded-md border-2 border-foreground bg-card text-foreground opacity-0 shadow-2xs transition-all hover:bg-rose-100 hover:text-rose-700 group-hover:opacity-100 group-focus:opacity-100"
@click.stop="removeMedia(item.id)"
>
<IconTrash class="h-3.5 w-3.5" />
<IconTrash class="size-3.5" />
</button>
</div>
<button
type="button"
class="flex aspect-square flex-col items-center justify-center gap-1 rounded-xl border-2 border-dashed border-border/60 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-primary"
class="flex aspect-square cursor-pointer flex-col items-center justify-center gap-1 rounded-xl border-2 border-dashed border-foreground/25 text-foreground/60 transition-colors hover:border-foreground hover:bg-foreground/5 hover:text-foreground"
@click="mediaPickerDialog?.open()"
>
<IconLibraryPhoto class="h-5 w-5" />
<span class="text-[10px] font-medium">{{ $t('posts.edit.add') }}</span>
<IconLibraryPhoto class="size-5" />
<span class="text-[10px] font-bold uppercase tracking-widest">{{ $t('posts.edit.add') }}</span>
</button>
</div>
</div>
<!-- Toolbar: between photos and textarea -->
<div class="mb-4 flex items-center gap-0.5">
<div class="mb-4 flex items-center gap-2">
<Popover v-model:open="emojiOpen">
<PopoverAnchor as-child>
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
<button
type="button"
variant="ghost"
size="icon-sm"
class="size-8 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
class="inline-flex size-9 cursor-pointer items-center justify-center rounded-lg border-2 border-foreground bg-card text-foreground shadow-2xs transition-all hover:-translate-y-0.5 hover:bg-violet-100 hover:shadow-sm"
@click="emojiOpen = !emojiOpen"
>
<IconMoodSmile class="size-4" />
</Button>
</button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.emoji_picker.search') }}</TooltipContent>
</Tooltip>
@ -412,15 +410,13 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
<button
type="button"
variant="ghost"
size="icon-sm"
class="size-8 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
class="inline-flex size-9 cursor-pointer items-center justify-center rounded-lg border-2 border-foreground bg-card text-foreground shadow-2xs transition-all hover:-translate-y-0.5 hover:bg-violet-100 hover:shadow-sm"
@click="signaturesModal?.open()"
>
<IconHash class="size-4" />
</Button>
</button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.edit.signatures') }}</TooltipContent>
</Tooltip>
@ -429,15 +425,13 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
<button
type="button"
variant="ghost"
size="icon-sm"
class="size-8 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
class="inline-flex size-9 cursor-pointer items-center justify-center rounded-lg border-2 border-foreground bg-card text-foreground shadow-2xs transition-all hover:-translate-y-0.5 hover:bg-violet-100 hover:shadow-sm"
@click="emit('open-ai-generate')"
>
<IconSparkles class="size-4" />
</Button>
</button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.ai.generate.button_tooltip') }}</TooltipContent>
</Tooltip>
@ -446,21 +440,19 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
<TooltipProvider>
<Tooltip>
<TooltipTrigger as-child>
<Button
<button
type="button"
variant="ghost"
size="icon-sm"
class="size-8 rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground"
class="inline-flex size-9 cursor-pointer items-center justify-center rounded-lg border-2 border-foreground bg-card text-foreground shadow-2xs transition-all hover:-translate-y-0.5 hover:bg-violet-100 hover:shadow-sm"
@click="emit('open-ai-review')"
>
<IconWriting class="size-4" />
</Button>
</button>
</TooltipTrigger>
<TooltipContent>{{ $t('posts.ai.review.button_tooltip') }}</TooltipContent>
</Tooltip>
</TooltipProvider>
<span v-if="uploading" class="ml-2 flex items-center gap-1.5 text-xs text-muted-foreground">
<span v-if="uploading" class="flex items-center gap-1.5 text-xs font-medium text-foreground/60">
<IconLoader2 class="size-3.5 animate-spin" />
</span>
</div>
@ -468,17 +460,19 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
<!-- Per-platform counters (below menu, above textarea) -->
<div
v-if="limitsWithUsage.length > 0"
class="mb-4 flex flex-wrap items-center gap-3"
class="mb-4 flex flex-wrap items-center gap-2"
>
<TooltipProvider v-for="limit in limitsWithUsage" :key="limit.platform" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<span
class="inline-flex items-center gap-1 text-[11px] font-medium tabular-nums"
class="inline-flex items-center gap-1.5 rounded-full border-2 px-2 py-1 text-[11px] font-bold leading-none tabular-nums shadow-2xs transition-colors"
:class="limitClass(limit.state)"
>
<img :src="getPlatformLogo(limit.platform)" :alt="limit.platform" class="size-3 object-contain" />
{{ limit.used }}<span class="opacity-50">/{{ limit.maxLength }}</span>
<span class="inline-flex size-3.5 shrink-0 items-center justify-center overflow-hidden rounded-full">
<img :src="getPlatformLogo(limit.platform)" :alt="limit.platform" class="size-full object-cover" />
</span>
<span>{{ limit.used }}<span class="opacity-60">/{{ limit.maxLength }}</span></span>
</span>
</TooltipTrigger>
<TooltipContent>{{ getPlatformLabel(limit.platform) }}</TooltipContent>
@ -495,12 +489,12 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
aria-hidden="true"
class="pointer-events-none absolute inset-0 whitespace-pre-wrap break-words p-0 font-sans text-base leading-[1.7] text-transparent"
>
<span>{{ overflowParts.fits }}</span><span class="rounded-sm bg-destructive/15 text-destructive">{{ overflowParts.overflow }}</span>
<span>{{ overflowParts.fits }}</span><span class="rounded-sm bg-rose-100 text-rose-700">{{ overflowParts.overflow }}</span>
</div>
<textarea
v-model="content"
:placeholder="$t('posts.edit.caption_placeholder')"
class="relative block w-full resize-none border-0 bg-transparent p-0 font-sans text-base leading-[1.7] shadow-none outline-none placeholder:text-muted-foreground/50"
class="relative block w-full resize-none border-0 bg-transparent p-0 font-sans text-base leading-[1.7] shadow-none outline-none placeholder:text-foreground/40"
style="min-height: 280px; field-sizing: content;"
/>
</div>
@ -508,10 +502,17 @@ const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reas
<!-- Drag-drop overlay (full-bleed over the editor area) -->
<div
v-if="isDragging"
class="pointer-events-none absolute -inset-6 z-10 flex flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-primary bg-primary/10 backdrop-blur-sm"
class="pointer-events-none absolute -inset-6 z-10 flex flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed border-foreground bg-violet-100/80 backdrop-blur-sm"
>
<IconCloudUpload class="size-10 text-primary" />
<p class="text-base font-semibold text-primary">{{ $t('posts.edit.drag_drop') }}</p>
<div class="inline-flex size-12 -rotate-3 items-center justify-center rounded-2xl border-2 border-foreground bg-violet-200 shadow-2xs">
<IconCloudUpload class="size-6 text-foreground" stroke-width="2" />
</div>
<p
class="text-xl font-semibold text-foreground"
style="font-family: var(--font-display)"
>
{{ $t('posts.edit.drag_drop') }}
</p>
</div>
</div>

View file

@ -99,7 +99,7 @@ defineExpose({
<template>
<Tabs v-model="activeTab" class="h-full flex flex-col">
<TabsList class="mx-4 mt-4 w-auto shrink-0">
<TabsList class="mx-4 mt-4 w-fit shrink-0 self-start">
<TabsTrigger value="preview">{{ $t('posts.edit.tabs.preview') }}</TabsTrigger>
<TabsTrigger value="schedule">{{ $t('posts.edit.tabs.schedule') }}</TabsTrigger>
<TabsTrigger value="comments">{{ $t('posts.edit.tabs.comments') }}</TabsTrigger>

View file

@ -57,28 +57,30 @@ const activeContentType = computed((): string | undefined => {
<template>
<div class="flex h-full flex-col">
<div v-if="platforms.length > 1" class="border-b px-4 py-3">
<div v-if="platforms.length > 1" class="border-b-2 border-foreground/10 px-4 py-3">
<div class="flex flex-wrap gap-3">
<TooltipProvider v-for="pp in platforms" :key="pp.id" :delay-duration="200">
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
class="relative transition-opacity"
class="relative cursor-pointer transition-opacity"
:class="activeId === pp.id ? 'opacity-100' : 'opacity-40 hover:opacity-70'"
@click="activeId = pp.id"
>
<Avatar
:src="getPlatformAvatar(pp)"
:name="getPlatformDisplayName(pp)"
class="h-9 w-9 shrink-0 rounded-full ring-2 ring-offset-2"
:class="activeId === pp.id ? 'ring-primary' : 'ring-transparent'"
/>
<img
:src="getPlatformLogo(pp.platform)"
:alt="pp.platform"
class="absolute -bottom-1.5 -right-1.5 h-4 w-4 rounded-full bg-background object-contain ring-1 ring-border"
class="size-9 shrink-0 rounded-full border-2"
:class="activeId === pp.id ? 'border-foreground shadow-2xs' : 'border-foreground/20'"
/>
<span class="absolute -bottom-1 -right-1 inline-flex size-4 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img
:src="getPlatformLogo(pp.platform)"
:alt="pp.platform"
class="size-full object-cover"
/>
</span>
</button>
</TooltipTrigger>
<TooltipContent>
@ -92,7 +94,7 @@ const activeContentType = computed((): string | undefined => {
</div>
</div>
<div class="flex flex-1 items-start justify-center bg-muted/30 px-4 pb-8 pt-6">
<div class="flex flex-1 items-start justify-center bg-foreground/5 px-4 pb-8 pt-6">
<PhoneMockup v-if="activePlatform">
<PlatformPreview
:platform="activePlatform.platform"
@ -103,10 +105,17 @@ const activeContentType = computed((): string | undefined => {
:meta="platformMeta?.[activePlatform.id] ?? {}"
/>
</PhoneMockup>
<div v-else class="flex flex-col items-center gap-2 text-center text-muted-foreground">
<IconDeviceMobile class="size-10 opacity-40" />
<p class="text-sm font-medium">{{ $t('posts.edit.preview_empty.title') }}</p>
<p class="text-xs">{{ $t('posts.edit.preview_empty.description') }}</p>
<div v-else class="flex flex-col items-center gap-3 text-center">
<div class="inline-flex size-12 -rotate-3 items-center justify-center rounded-2xl border-2 border-foreground bg-violet-200 shadow-2xs">
<IconDeviceMobile class="size-6 text-foreground" stroke-width="2" />
</div>
<p
class="text-base font-bold text-foreground"
style="font-family: var(--font-display)"
>
{{ $t('posts.edit.preview_empty.title') }}
</p>
<p class="text-xs font-medium text-foreground/60">{{ $t('posts.edit.preview_empty.description') }}</p>
</div>
</div>
</div>

View file

@ -2,6 +2,7 @@
import { IconAlertCircle, IconCircleCheck, IconExternalLink, IconLoader2 } from '@tabler/icons-vue';
import { computed } from 'vue';
import LabelBadge from '@/components/labels/LabelBadge.vue';
import FacebookSettings from '@/components/posts/editor/FacebookSettings.vue';
import InstagramSettings from '@/components/posts/editor/InstagramSettings.vue';
import LinkedInSettings from '@/components/posts/editor/LinkedInSettings.vue';
@ -149,7 +150,7 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
<template>
<div class="space-y-6">
<div>
<p class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<p class="mb-3 text-[11px] font-black uppercase tracking-widest text-foreground/60">
{{ $t('posts.edit.publish_to') }}
</p>
<div class="flex flex-wrap gap-3">
@ -158,7 +159,7 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
<TooltipTrigger as-child>
<button
type="button"
class="flex w-20 flex-col items-center gap-1.5 transition-opacity"
class="flex w-20 cursor-pointer flex-col items-center gap-1.5 transition-opacity"
:class="[
platformIssues?.[pp.id] && !selectedPlatformIds.includes(pp.id) ? 'cursor-not-allowed opacity-40' : '',
platformIssues?.[pp.id] && selectedPlatformIds.includes(pp.id) ? 'opacity-100' : '',
@ -171,18 +172,20 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
<Avatar
:src="getPlatformAvatar(pp)"
:name="getPlatformDisplayName(pp)"
class="h-10 w-10 shrink-0 rounded-full ring-2 ring-offset-2"
class="size-10 shrink-0 rounded-full border-2"
:class="[
platformIssues?.[pp.id] && selectedPlatformIds.includes(pp.id) ? 'ring-destructive' : '',
!platformIssues?.[pp.id] && selectedPlatformIds.includes(pp.id) ? 'ring-primary' : '',
!selectedPlatformIds.includes(pp.id) ? 'ring-transparent' : '',
platformIssues?.[pp.id] && selectedPlatformIds.includes(pp.id) ? 'border-rose-500 shadow-2xs' : '',
!platformIssues?.[pp.id] && selectedPlatformIds.includes(pp.id) ? 'border-foreground shadow-2xs' : '',
!selectedPlatformIds.includes(pp.id) ? 'border-foreground/20' : '',
]"
/>
<img
:src="getPlatformLogo(pp.platform)"
:alt="pp.platform"
class="absolute -bottom-1.5 -right-1.5 h-5 w-5 rounded-full bg-background object-contain ring-1 ring-border"
/>
<span class="absolute -bottom-1 -right-1 inline-flex size-5 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img
:src="getPlatformLogo(pp.platform)"
:alt="pp.platform"
class="size-full object-cover"
/>
</span>
<Badge
v-if="platformIssues?.[pp.id] && selectedPlatformIds.includes(pp.id)"
variant="destructive"
@ -190,12 +193,17 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
>
<IconAlertCircle class="h-2.5 w-2.5" />
</Badge>
<Badge v-else-if="pp.status === 'published'" variant="default" class="absolute -top-1 -right-1 h-4 w-4 p-0">
<Badge v-else-if="pp.status === 'published'" variant="success" class="absolute -top-1 -right-1 h-4 w-4 p-0">
<IconCircleCheck class="h-2.5 w-2.5" />
</Badge>
<Badge v-else-if="pp.status === 'failed'" variant="destructive" class="absolute -top-1 -right-1 h-4 w-4 p-0 text-[9px]">!</Badge>
</div>
<span class="line-clamp-2 text-center text-xs leading-tight">{{ getPlatformDisplayName(pp) }}</span>
<span
class="line-clamp-2 text-center text-xs leading-tight"
:class="selectedPlatformIds.includes(pp.id) ? 'font-bold text-foreground' : 'font-medium text-foreground/70'"
>
{{ getPlatformDisplayName(pp) }}
</span>
</button>
</TooltipTrigger>
<TooltipContent>
@ -213,24 +221,36 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
</div>
<div v-if="postPlatforms.some(pp => pp.status !== 'pending')">
<p class="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<p class="mb-2 text-[11px] font-black uppercase tracking-widest text-foreground/60">
{{ $t('posts.edit.platform_status') }}
</p>
<div class="space-y-2">
<div v-for="pp in postPlatforms.filter(p => p.enabled)" :key="pp.id" class="flex items-center justify-between rounded-lg border p-3">
<div
v-for="pp in postPlatforms.filter(p => p.enabled)"
:key="pp.id"
class="flex items-center justify-between rounded-xl border-2 border-foreground bg-card p-3 shadow-2xs"
>
<div class="flex items-center gap-2">
<img :src="getPlatformLogo(pp.platform)" :alt="pp.platform" class="h-4 w-4 object-contain" />
<span class="text-sm">{{ getPlatformDisplayName(pp) }}</span>
<span class="inline-flex size-5 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card">
<img :src="getPlatformLogo(pp.platform)" :alt="pp.platform" class="size-full object-cover" />
</span>
<span class="text-sm font-bold text-foreground">{{ getPlatformDisplayName(pp) }}</span>
</div>
<div class="flex items-center gap-2">
<Badge v-if="pp.status === 'published'" variant="default">{{ $t('posts.edit.status.published') }}</Badge>
<Badge v-else-if="pp.status === 'publishing'" variant="secondary">
<IconLoader2 class="mr-1 h-3 w-3 animate-spin" />
<Badge v-if="pp.status === 'published'" variant="success">{{ $t('posts.edit.status.published') }}</Badge>
<Badge v-else-if="pp.status === 'publishing'" variant="warning">
<IconLoader2 class="size-3 animate-spin" />
{{ $t('posts.edit.status.publishing') }}
</Badge>
<Badge v-else-if="pp.status === 'failed'" variant="destructive">{{ $t('posts.edit.status.failed') }}</Badge>
<a v-if="pp.platform_url" :href="pp.platform_url" target="_blank" rel="noopener noreferrer">
<IconExternalLink class="h-4 w-4 text-muted-foreground hover:text-foreground" />
<a
v-if="pp.platform_url"
:href="pp.platform_url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex size-7 items-center justify-center rounded-full border-2 border-foreground bg-card text-foreground shadow-2xs transition-transform hover:rotate-3 hover:bg-violet-100"
>
<IconExternalLink class="size-3.5" stroke-width="2.5" />
</a>
</div>
</div>
@ -305,24 +325,21 @@ const getPlatformAvatar = (pp: PostPlatform): string | null =>
</div>
<div>
<p class="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<p class="mb-3 text-[11px] font-black uppercase tracking-widest text-foreground/60">
{{ $t('posts.edit.labels') }}
</p>
<div v-if="labels.length > 0" class="flex flex-wrap gap-2">
<button
<LabelBadge
v-for="label in labels"
:key="label.id"
type="button"
class="flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs transition-colors"
:class="selectedLabelIds.includes(label.id) ? 'border-primary bg-primary/10' : 'border-border opacity-70 hover:opacity-100'"
:label="label"
interactive
:selected="selectedLabelIds.includes(label.id)"
:disabled="isReadOnly"
@click="emit('toggleLabel', label.id)"
>
<span class="h-2.5 w-2.5 shrink-0 rounded-full" :style="{ backgroundColor: label.color }" />
<span class="truncate">{{ label.name }}</span>
</button>
/>
</div>
<p v-else class="text-sm text-muted-foreground">{{ $t('posts.edit.no_labels') }}</p>
<p v-else class="text-sm font-medium text-foreground/60">{{ $t('posts.edit.no_labels') }}</p>
</div>
</div>
</template>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { IconAlertTriangle, IconBrandTiktok, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { IconAlertTriangle, IconChevronDown, IconChevronUp } from '@tabler/icons-vue';
import { computed, ref, watch } from 'vue';
import { Avatar } from '@/components/ui/avatar';
@ -12,6 +12,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getPlatformLogo } from '@/composables/usePlatformLogo';
interface SocialAccount {
id: string;
@ -172,46 +173,48 @@ watch(
</script>
<template>
<div class="rounded-lg border">
<div class="rounded-xl border-2 border-foreground bg-card shadow-2xs">
<button
type="button"
class="flex w-full items-center justify-between p-4 text-sm font-medium"
class="flex w-full cursor-pointer items-center justify-between gap-3 p-4 text-sm"
@click="open = !open"
>
<span class="flex items-center gap-2">
<IconBrandTiktok class="size-5" />
<span>{{ $t('posts.form.tiktok.settings') }}</span>
<span v-if="socialAccount" class="text-muted-foreground">·&nbsp;@{{ socialAccount.username }}</span>
<span class="flex min-w-0 items-center gap-2">
<span class="inline-flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-foreground bg-card shadow-2xs">
<img :src="getPlatformLogo('tiktok')" alt="TikTok" class="size-full object-cover" />
</span>
<span class="truncate font-bold text-foreground">{{ $t('posts.form.tiktok.settings') }}</span>
<span v-if="socialAccount?.username" class="truncate font-medium text-foreground/60">·&nbsp;@{{ socialAccount.username }}</span>
</span>
<IconChevronUp v-if="open" class="h-4 w-4 text-muted-foreground" />
<IconChevronDown v-else class="h-4 w-4 text-muted-foreground" />
<IconChevronUp v-if="open" class="size-4 shrink-0 text-foreground/60" />
<IconChevronDown v-else class="size-4 shrink-0 text-foreground/60" />
</button>
<div v-if="open" class="space-y-5 border-t px-4 pb-4 pt-4">
<p v-if="creatorInfoLoading" class="flex items-center gap-2 text-xs text-muted-foreground">
<span class="inline-block h-3 w-3 animate-pulse rounded-full bg-muted" />
<div v-if="open" class="space-y-5 border-t-2 border-foreground/10 px-4 pb-4 pt-4">
<p v-if="creatorInfoLoading" class="flex items-center gap-2 text-xs font-medium text-foreground/60">
<span class="inline-block size-3 animate-pulse rounded-full bg-foreground/30" />
{{ $t('posts.form.tiktok.creator_info_loading') }}
</p>
<!-- Creator identity -->
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-muted/50 p-3">
<div v-if="socialAccount" class="flex items-center gap-3 rounded-lg bg-foreground/5 p-3">
<Avatar
:src="socialAccount.avatar_url"
:name="socialAccount.display_name"
class="h-9 w-9 shrink-0 rounded-full"
class="size-9 shrink-0 rounded-full border-2 border-foreground shadow-2xs"
/>
<div class="min-w-0 flex-1">
<p class="text-xs text-muted-foreground">{{ $t('posts.form.tiktok.posting_to') }}</p>
<p class="truncate text-sm font-medium">
{{ socialAccount.display_name }}
<span class="text-muted-foreground">@{{ socialAccount.username }}</span>
<p class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t('posts.form.tiktok.posting_to') }}</p>
<p class="truncate text-sm">
<span class="font-bold text-foreground">{{ socialAccount.display_name }}</span>
<span v-if="socialAccount?.username" class="font-medium text-foreground/60">&nbsp;@{{ socialAccount.username }}</span>
</p>
</div>
</div>
<!-- Privacy Level -->
<div class="space-y-2">
<Label class="text-sm font-medium">{{ $t('posts.form.tiktok.privacy_level') }}</Label>
<Label class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t("posts.form.tiktok.privacy_level") }}</Label>
<Select v-model="privacyLevel" :disabled="props.disabled">
<SelectTrigger class="w-full">
<SelectValue :placeholder="$t('posts.form.tiktok.privacy_placeholder')" />
@ -222,18 +225,18 @@ watch(
</SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">{{ $t('posts.form.tiktok.privacy_hint') }}</p>
<p class="text-xs font-medium text-foreground/60">{{ $t("posts.form.tiktok.privacy_hint") }}</p>
</div>
<!-- Max duration warning -->
<p v-if="exceedsMaxDuration" class="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-xs text-destructive">
<IconAlertTriangle class="mt-0.5 h-3.5 w-3.5 shrink-0" />
<p v-if="exceedsMaxDuration" class="flex items-start gap-2 rounded-lg border-2 border-foreground bg-rose-50 p-2 text-xs font-semibold text-rose-700">
<IconAlertTriangle class="mt-0.5 size-3.5 shrink-0" />
{{ $t('posts.form.tiktok.max_duration_exceeded', { duration: String(videoDurationSec ?? 0), max: String(maxDurationSec ?? 0) }) }}
</p>
<!-- Auto Add Music (photos only) -->
<div class="space-y-2">
<Label class="text-sm font-medium">{{ $t('posts.form.tiktok.auto_add_music') }}</Label>
<Label class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t("posts.form.tiktok.auto_add_music") }}</Label>
<Select v-model="autoAddMusic" :disabled="props.disabled">
<SelectTrigger class="w-full">
<SelectValue />
@ -243,12 +246,12 @@ watch(
<SelectItem value="no">{{ $t('posts.form.tiktok.no') }}</SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">{{ $t('posts.form.tiktok.auto_add_music_hint') }}</p>
<p class="text-xs font-medium text-foreground/60">{{ $t("posts.form.tiktok.auto_add_music_hint") }}</p>
</div>
<!-- Allow User To -->
<div class="space-y-2">
<Label class="text-sm font-medium">{{ $t('posts.form.tiktok.allow_users') }}</Label>
<Label class="text-[11px] font-black uppercase tracking-widest text-foreground/60">{{ $t("posts.form.tiktok.allow_users") }}</Label>
<div class="flex flex-wrap items-center gap-x-6 gap-y-2">
<label class="flex items-center gap-2 text-sm" :class="{ 'opacity-50': commentDisabled }" :title="commentDisabled ? $t('posts.form.tiktok.interaction_disabled_by_creator') : ''">
<Checkbox v-model="allowComments" :disabled="props.disabled || commentDisabled" />
@ -265,7 +268,7 @@ watch(
</div>
</div>
<div class="border-t" />
<div class="border-t-2 border-foreground/10" />
<!-- Video made with AI (independent) -->
<label class="flex items-center gap-2 text-sm">
@ -279,19 +282,19 @@ watch(
<Checkbox v-model="discloseOpen" :disabled="props.disabled" />
{{ $t('posts.form.tiktok.disclose') }}
</label>
<p class="ml-6 text-xs text-muted-foreground">{{ $t('posts.form.tiktok.disclose_hint') }}</p>
<p class="ml-6 text-xs font-medium text-foreground/60">{{ $t("posts.form.tiktok.disclose_hint") }}</p>
<!-- Promotional Content warning (shown once a sub-toggle is picked) -->
<div v-if="hasAnyBrandToggle" class="ml-6 flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900/40 dark:bg-amber-950/30">
<IconAlertTriangle class="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
<div class="text-xs text-amber-800 dark:text-amber-200">
<p class="font-medium">{{ $t(promotionalTitleKey) }}</p>
<p>{{ $t('posts.form.tiktok.promotional_description') }}</p>
<div v-if="hasAnyBrandToggle" class="ml-6 flex items-start gap-3 rounded-lg border-2 border-foreground bg-amber-100 p-3 shadow-2xs">
<IconAlertTriangle class="mt-0.5 size-4 shrink-0 text-amber-700" />
<div class="text-xs text-amber-800">
<p class="font-bold">{{ $t(promotionalTitleKey) }}</p>
<p class="font-medium">{{ $t('posts.form.tiktok.promotional_description') }}</p>
</div>
</div>
<!-- Compliance incomplete hint (disclose ON but no sub-toggle) -->
<p v-else-if="discloseOpen" class="ml-6 text-xs text-amber-600 dark:text-amber-400">
<p v-else-if="discloseOpen" class="ml-6 text-xs font-semibold text-amber-700">
{{ $t('posts.form.tiktok.compliance_incomplete') }}
</p>
@ -302,14 +305,14 @@ watch(
<Checkbox v-model="brandOrganicToggle" :disabled="props.disabled" />
{{ $t('posts.form.tiktok.brand_organic') }}
</label>
<p class="ml-6 text-xs text-muted-foreground">{{ $t('posts.form.tiktok.brand_organic_hint') }}</p>
<p class="ml-6 text-xs font-medium text-foreground/60">{{ $t("posts.form.tiktok.brand_organic_hint") }}</p>
</div>
<div class="space-y-1">
<label class="flex items-center gap-2 text-sm">
<Checkbox v-model="brandContentToggle" :disabled="props.disabled" />
{{ $t('posts.form.tiktok.brand_content') }}
</label>
<p class="ml-6 text-xs text-muted-foreground">{{ $t('posts.form.tiktok.brand_content_hint') }}</p>
<p class="ml-6 text-xs font-medium text-foreground/60">{{ $t("posts.form.tiktok.brand_content_hint") }}</p>
</div>
</div>
</div>
@ -321,7 +324,7 @@ watch(
:href="publishConfig?.musicUsageConfirmationUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary underline-offset-2 hover:underline"
class="font-bold text-primary underline-offset-2 hover:underline"
>
{{ $t('posts.form.tiktok.compliance.music_usage') }}
</a>
@ -331,7 +334,7 @@ watch(
:href="publishConfig?.brandedContentPolicyUrl"
target="_blank"
rel="noopener noreferrer"
class="text-primary underline-offset-2 hover:underline"
class="font-bold text-primary underline-offset-2 hover:underline"
>
{{ $t('posts.form.tiktok.compliance.branded_policy') }}
</a>

View file

@ -140,7 +140,7 @@ const languageLabel = computed(() => {
</div>
</div>
<p class="-mt-4 text-xs text-muted-foreground">
<p class="-mt-4 text-xs font-medium text-foreground/60">
{{ $t('settings.brand.content_language_description') }}
</p>

View file

@ -1,6 +1,8 @@
<script setup lang="ts">
import { Link } from '@inertiajs/vue3';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
interface Tab {
label: string;
href: string;
@ -14,21 +16,16 @@ defineProps<{
</script>
<template>
<div
class="bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]"
>
<Link
v-for="tab in tabs"
:key="tab.name"
:href="tab.href"
:class="[
'inline-flex h-[calc(100%-1px)] items-center justify-center gap-1.5 whitespace-nowrap rounded-md border border-transparent px-3 py-1 text-sm font-medium transition-[color,box-shadow]',
active === tab.name
? 'bg-background text-foreground shadow-sm dark:border-input dark:bg-input/30 dark:text-foreground'
: 'hover:text-foreground',
]"
>
{{ tab.label }}
</Link>
</div>
<Tabs :model-value="active">
<TabsList>
<TabsTrigger
v-for="tab in tabs"
:key="tab.name"
:value="tab.name"
as-child
>
<Link :href="tab.href">{{ tab.label }}</Link>
</TabsTrigger>
</TabsList>
</Tabs>
</template>

View file

@ -69,106 +69,97 @@ const changeRole = (member: Member, role: string) => {
<template>
<div class="flex flex-col space-y-6">
<div class="flex items-center justify-between">
<div class="flex items-center justify-between gap-4">
<HeadingSmall
:title="$t('settings.workspace.members_heading')"
:description="$t('settings.workspace.members_description')"
/>
<Button variant="secondary" @click="handleInviteClick">
<Button @click="handleInviteClick">
{{ $t('settings.members.invite.submit') }}
</Button>
</div>
<div class="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{{ $t('settings.workspace.name') }}</TableHead>
<TableHead>{{ $t('settings.members.invite.email') }}</TableHead>
<TableHead>{{ $t('settings.members.invite.role') }}</TableHead>
<TableHead class="w-10" />
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="member in members" :key="member.id">
<TableCell class="font-medium">
{{ member.name }}
</TableCell>
<TableCell class="text-muted-foreground">
{{ member.email }}
</TableCell>
<TableCell>
<Badge :variant="member.role === WorkspaceRole.Admin ? 'default' : 'secondary'">
{{ member.role }}
</Badge>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon" class="h-8 w-8">
<IconDots class="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
v-if="member.role === WorkspaceRole.Member"
@click="changeRole(member, WorkspaceRole.Admin)"
>
<IconShield class="size-3.5" />
{{ $t('settings.members.make_admin') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="member.role === WorkspaceRole.Admin"
@click="changeRole(member, WorkspaceRole.Member)"
>
<IconUser class="size-3.5" />
{{ $t('settings.members.make_member') }}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
@click="removeMemberModal?.open({ url: removeMemberRoute.url(member.id) })"
>
<IconTrash class="size-3.5" />
{{ $t('settings.members.remove') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
<TableRow
v-for="invitation in invitations"
:key="`inv-${invitation.id}`"
class="text-muted-foreground"
>
<TableCell>
<div class="flex items-center gap-2">
<IconClock class="size-3.5" />
<span class="italic">{{ $t('settings.members.pending.title') }}</span>
</div>
</TableCell>
<TableCell>
{{ invitation.email }}
</TableCell>
<TableCell>
<Badge variant="outline">
{{ invitation.role }}
</Badge>
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive"
@click="cancelInvitationModal?.open({ url: destroyInvite.url(invitation.id) })"
>
<IconTrash class="size-3.5" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>{{ $t('settings.workspace.name') }}</TableHead>
<TableHead>{{ $t('settings.members.invite.email') }}</TableHead>
<TableHead>{{ $t('settings.members.invite.role') }}</TableHead>
<TableHead class="w-10" />
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="member in members" :key="member.id">
<TableCell>{{ member.name }}</TableCell>
<TableCell>{{ member.email }}</TableCell>
<TableCell>
<Badge :variant="member.role === WorkspaceRole.Admin ? 'default' : 'secondary'">
{{ member.role }}
</Badge>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="outline" size="icon" class="size-8">
<IconDots class="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
v-if="member.role === WorkspaceRole.Member"
@click="changeRole(member, WorkspaceRole.Admin)"
>
<IconShield class="size-4" />
{{ $t('settings.members.make_admin') }}
</DropdownMenuItem>
<DropdownMenuItem
v-if="member.role === WorkspaceRole.Admin"
@click="changeRole(member, WorkspaceRole.Member)"
>
<IconUser class="size-4" />
{{ $t('settings.members.make_member') }}
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
@click="removeMemberModal?.open({ url: removeMemberRoute.url(member.id) })"
>
<IconTrash class="size-4" />
{{ $t('settings.members.remove') }}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
<TableRow v-for="invitation in invitations" :key="`inv-${invitation.id}`">
<TableCell class="text-foreground/60">
<div class="flex items-center gap-2">
<IconClock class="size-4" />
<span class="italic">{{ $t('settings.members.pending.title') }}</span>
</div>
</TableCell>
<TableCell class="text-foreground/60">
{{ invitation.email }}
</TableCell>
<TableCell>
<Badge variant="outline">
{{ invitation.role }}
</Badge>
</TableCell>
<TableCell>
<Button
variant="outline"
size="icon"
class="size-8 bg-rose-100 hover:bg-rose-200"
:aria-label="$t('settings.members.cancel_invite_modal.action')"
@click="cancelInvitationModal?.open({ url: destroyInvite.url(invitation.id) })"
>
<IconTrash class="size-4 text-rose-700" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
<InviteMemberDialog v-model:open="inviteDialogOpen" />

View file

@ -3,19 +3,26 @@ import { cva } from "class-variance-authority"
export { default as Badge } from "./Badge.vue"
// Indies sticker badge — `border-2 border-foreground` + uppercase
// `tracking-widest` + ink offset shadow, mirrors the eyebrow / channel
// badge pattern from the marketing site (NetworksGrid, PricingTables).
export const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
"inline-flex items-center justify-center w-fit shrink-0 whitespace-nowrap rounded-md border-2 border-foreground px-2 py-0.5 text-[10px] font-black uppercase tracking-widest gap-1 shadow-2xs [&>svg]:size-3 [&>svg]:pointer-events-none focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
success:
"bg-emerald-200 text-foreground [a&]:hover:bg-emerald-300",
warning:
"bg-amber-200 text-foreground [a&]:hover:bg-amber-300",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
"bg-card text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {

View file

@ -4,18 +4,18 @@ import { cva } from "class-variance-authority"
export { default as Button } from "./Button.vue"
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium cursor-pointer transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/90",
"border-2 border-foreground bg-primary text-primary-foreground shadow-xs hover:shadow-sm",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"border-2 border-foreground bg-destructive text-white shadow-xs hover:shadow-sm focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
"border-2 border-foreground bg-card shadow-xs hover:shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
"border-2 border-foreground bg-secondary text-secondary-foreground shadow-xs hover:shadow-sm",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",

View file

@ -4,11 +4,12 @@ import type { HTMLAttributes, Ref } from "vue"
import type { LayoutTypes } from "."
import { getLocalTimeZone, today } from "@internationalized/date"
import { createReusableTemplate, reactiveOmit, useVModel } from "@vueuse/core"
import { CalendarRoot, useDateFormatter, useForwardPropsEmits } from "reka-ui"
import { CalendarRoot, useForwardPropsEmits } from "reka-ui"
import { createYear, createYearRange, toDate } from "reka-ui/date"
import { computed, toRaw } from "vue"
import { cn } from "@/lib/utils"
import { NativeSelect, NativeSelectOption } from '@/components/ui/native-select'
import dayjs from "@/dayjs"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { CalendarCell, CalendarCellTrigger, CalendarGrid, CalendarGridBody, CalendarGridHead, CalendarGridRow, CalendarHeadCell, CalendarHeader, CalendarHeading, CalendarNextButton, CalendarPrevButton } from "."
const props = withDefaults(defineProps<CalendarRootProps & { class?: HTMLAttributes["class"], layout?: LayoutTypes, yearRange?: DateValue[] }>(), {
@ -24,7 +25,8 @@ const placeholder = useVModel(props, "placeholder", emits, {
defaultValue: props.defaultPlaceholder ?? today(getLocalTimeZone()),
}) as Ref<DateValue>
const formatter = useDateFormatter(props.locale ?? "en")
const formatMonth = (d: DateValue) => dayjs(toDate(d)).format("MMM")
const formatYear = (d: DateValue) => dayjs(toDate(d)).format("YYYY")
const yearRange = computed(() => {
return props.yearRange ?? createYearRange({
@ -44,47 +46,35 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template>
<DefineMonthTemplate v-slot="{ date }">
<div class="**:data-[slot=native-select-icon]:right-1">
<div class="relative">
<div class="absolute inset-0 flex h-full items-center text-sm pl-2 pointer-events-none">
{{ formatter.custom(toDate(date), { month: 'short' }) }}
</div>
<NativeSelect
class="text-xs h-8 pr-6 pl-2 text-transparent relative"
@change="(e: Event) => {
placeholder = placeholder.set({
month: Number((e?.target as any)?.value),
})
}"
>
<NativeSelectOption v-for="(month) in createYear({ dateObj: date })" :key="month.toString()" :value="month.month" :selected="date.month === month.month">
{{ formatter.custom(toDate(month), { month: 'short' }) }}
</NativeSelectOption>
</NativeSelect>
</div>
</div>
<Select
:model-value="String(date.month)"
@update:model-value="(v) => placeholder = placeholder.set({ month: Number(v) })"
>
<SelectTrigger class="h-8 w-auto gap-1 px-2.5 text-sm font-bold capitalize">
<SelectValue>{{ formatMonth(date) }}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="month in createYear({ dateObj: date })" :key="month.toString()" :value="String(month.month)" class="capitalize">
{{ formatMonth(month) }}
</SelectItem>
</SelectContent>
</Select>
</DefineMonthTemplate>
<DefineYearTemplate v-slot="{ date }">
<div class="**:data-[slot=native-select-icon]:right-1">
<div class="relative">
<div class="absolute inset-0 flex h-full items-center text-sm pl-2 pointer-events-none">
{{ formatter.custom(toDate(date), { year: 'numeric' }) }}
</div>
<NativeSelect
class="text-xs h-8 pr-6 pl-2 text-transparent relative"
@change="(e: Event) => {
placeholder = placeholder.set({
year: Number((e?.target as any)?.value),
})
}"
>
<NativeSelectOption v-for="(year) in yearRange" :key="year.toString()" :value="year.year" :selected="date.year === year.year">
{{ formatter.custom(toDate(year), { year: 'numeric' }) }}
</NativeSelectOption>
</NativeSelect>
</div>
</div>
<Select
:model-value="String(date.year)"
@update:model-value="(v) => placeholder = placeholder.set({ year: Number(v) })"
>
<SelectTrigger class="h-8 w-auto gap-1 px-2.5 text-sm font-bold">
<SelectValue>{{ formatYear(date) }}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem v-for="year in yearRange" :key="year.toString()" :value="String(year.year)">
{{ formatYear(year) }}
</SelectItem>
</SelectContent>
</Select>
</DefineYearTemplate>
<CalendarRoot
@ -95,7 +85,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
:class="cn('p-3', props.class)"
>
<CalendarHeader class="pt-0">
<nav class="flex items-center gap-1 absolute top-0 inset-x-0 justify-between">
<nav class="pointer-events-none absolute inset-x-0 top-0 flex items-center gap-1 justify-between [&_button]:pointer-events-auto">
<CalendarPrevButton>
<slot name="calendar-prev-icon" />
</CalendarPrevButton>
@ -114,12 +104,12 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template v-else-if="layout === 'month-only'">
<div class="flex items-center justify-center gap-1">
<ReuseMonthTemplate :date="date" />
{{ formatter.custom(toDate(date), { year: 'numeric' }) }}
{{ formatYear(date) }}
</div>
</template>
<template v-else-if="layout === 'year-only'">
<div class="flex items-center justify-center gap-1">
{{ formatter.custom(toDate(date), { month: 'short' }) }}
{{ formatMonth(date) }}
<ReuseYearTemplate :date="date" />
</div>
</template>

View file

@ -20,16 +20,17 @@ const forwardedProps = useForwardProps(delegatedProps)
data-slot="calendar-cell-trigger"
:class="cn(
buttonVariants({ variant: 'ghost' }),
'size-8 p-0 font-normal aria-selected:opacity-100 cursor-default',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selected
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
'size-8 p-0 font-medium cursor-pointer aria-selected:opacity-100 hover:bg-foreground/5',
// Today (not selected): violet pastel pill
'[&[data-today]:not([data-selected])]:bg-violet-100 [&[data-today]:not([data-selected])]:text-foreground [&[data-today]:not([data-selected])]:font-bold',
// Selected: ink sticker pill
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:font-bold data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
// Disabled
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
'data-[disabled]:text-foreground/40 data-[disabled]:opacity-50',
// Unavailable
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
// Outside months
'data-[outside-view]:text-muted-foreground',
'data-[outside-view]:text-foreground/40',
props.class,
)"
v-bind="forwardedProps"

View file

@ -15,7 +15,7 @@ const forwardedProps = useForwardProps(delegatedProps)
<template>
<CalendarHeadCell
data-slot="calendar-head-cell"
:class="cn('text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem]', props.class)"
:class="cn('text-foreground/60 rounded-md flex-1 font-bold uppercase tracking-wider text-[0.7rem]', props.class)"
v-bind="forwardedProps"
>
<slot />

View file

@ -20,7 +20,7 @@ const forwardedProps = useForwardProps(delegatedProps)
<CalendarHeading
v-slot="{ headingValue }"
data-slot="calendar-heading"
:class="cn('text-sm font-medium', props.class)"
:class="cn('text-sm font-bold text-foreground', props.class)"
v-bind="forwardedProps"
>
<slot :heading-value>

View file

@ -19,7 +19,7 @@ const forwardedProps = useForwardProps(delegatedProps)
data-slot="calendar-next-button"
:class="cn(
buttonVariants({ variant: 'outline' }),
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100',
'size-7 p-0',
props.class,
)"
v-bind="forwardedProps"

View file

@ -19,7 +19,7 @@ const forwardedProps = useForwardProps(delegatedProps)
data-slot="calendar-prev-button"
:class="cn(
buttonVariants({ variant: 'outline' }),
'size-7 bg-transparent p-0 opacity-50 hover:opacity-100',
'size-7 p-0',
props.class,
)"
v-bind="forwardedProps"

View file

@ -12,7 +12,7 @@ const props = defineProps<{
data-slot="card"
:class="
cn(
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border-2 border-foreground py-6 shadow-sm',
props.class,
)
"

View file

@ -31,14 +31,14 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<template>
<div
data-slot="command-input-wrapper"
class="flex h-9 items-center gap-2 border-b px-3"
class="flex h-10 items-center gap-2 border-b-2 border-foreground/10 px-3"
>
<IconSearch class="size-4 shrink-0 opacity-50" />
<IconSearch class="size-4 shrink-0 text-foreground/60" />
<ComboboxInput
data-slot="command-input"
:class="
cn(
'placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
'placeholder:text-foreground/50 flex h-10 w-full rounded-md bg-transparent py-3 text-sm font-medium text-foreground outline-hidden disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"

View file

@ -25,7 +25,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
v-bind="forwarded"
:class="
cn(
`data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full min-w-0 cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
`data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground hover:bg-accent hover:text-accent-foreground [&_svg:not([class*='text-'])]:text-foreground/60 relative flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-foreground outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4`,
props.class,
)
"

View file

@ -32,7 +32,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
v-bind="forwarded"
:class="
cn(
'z-50 rounded-md border bg-popover text-popover-foreground origin-(--reka-combobox-content-transform-origin) overflow-hidden shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 min-w-[var(--reka-combobox-trigger-width)] w-auto truncate max-w-[var(--reka-combobox-trigger-width)]',
'z-50 rounded-lg border-2 border-foreground bg-card text-foreground origin-(--reka-combobox-content-transform-origin) overflow-hidden shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 min-w-[var(--reka-combobox-trigger-width)] w-auto max-w-[var(--reka-combobox-trigger-width)]',
props.class,
)
"

View file

@ -25,15 +25,15 @@ const { filterState } = useCommand()
<template>
<div
data-slot="command-input-wrapper"
class="flex h-9 items-center gap-2 border-b px-3"
class="flex h-10 items-center gap-2 border-b-2 border-foreground/10 px-3"
>
<IconSearch class="size-4 shrink-0 opacity-50" />
<IconSearch class="size-4 shrink-0 text-foreground/60" />
<ListboxFilter
v-bind="{ ...forwardedProps, ...$attrs }"
v-model="filterState.search"
data-slot="command-input"
auto-focus
:class="cn('placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50', props.class)"
:class="cn('placeholder:text-foreground/50 flex h-10 w-full rounded-md bg-transparent py-3 text-sm font-medium text-foreground outline-hidden disabled:cursor-not-allowed disabled:opacity-50', props.class)"
/>
</div>
</template>

View file

@ -66,7 +66,7 @@ onUnmounted(() => {
:id="id"
ref="itemRef"
data-slot="command-item"
:class="cn('data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
:class="cn('data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-foreground/60 relative flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-foreground outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
@select="() => {
filterState.search = ''
}"

View file

@ -34,7 +34,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg',
'bg-card data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-2xl border-2 border-foreground p-6 shadow-lg duration-200 sm:max-w-lg',
props.class,
)"
>
@ -43,10 +43,11 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<DialogClose
v-if="showCloseButton"
data-slot="dialog-close"
class="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
:aria-label="$t('common.close')"
class="absolute top-3 right-3 inline-flex size-8 cursor-pointer items-center justify-center rounded-full border-2 border-foreground bg-card text-foreground shadow-2xs transition-all hover:-rotate-90 hover:bg-rose-100 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0"
>
<IconX />
<span class="sr-only">Close</span>
<IconX class="size-4" stroke-width="2.5" />
<span class="sr-only">{{ $t('common.close') }}</span>
</DialogClose>
</DialogContent>
</DialogPortal>

View file

@ -16,7 +16,7 @@ const forwardedProps = useForwardProps(delegatedProps)
<DialogDescription
data-slot="dialog-description"
v-bind="forwardedProps"
:class="cn('text-muted-foreground text-sm', props.class)"
:class="cn('text-base text-foreground/70', props.class)"
>
<slot />
</DialogDescription>

View file

@ -14,7 +14,7 @@ const delegatedProps = reactiveOmit(props, "class")
<DialogOverlay
data-slot="dialog-overlay"
v-bind="delegatedProps"
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', props.class)"
:class="cn('data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-foreground/60', props.class)"
>
<slot />
</DialogOverlay>

View file

@ -16,7 +16,8 @@ const forwardedProps = useForwardProps(delegatedProps)
<DialogTitle
data-slot="dialog-title"
v-bind="forwardedProps"
:class="cn('text-lg leading-none font-semibold', props.class)"
style="font-family: var(--font-display)"
:class="cn('text-2xl font-semibold leading-tight text-foreground', props.class)"
>
<slot />
</DialogTitle>

View file

@ -31,7 +31,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<DropdownMenuContent
data-slot="dropdown-menu-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md', props.class)"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-xl border-2 border-foreground p-1.5 shadow-md', props.class)"
>
<slot />
</DropdownMenuContent>

View file

@ -24,7 +24,7 @@ const forwardedProps = useForwardProps(delegatedProps)
:data-inset="inset ? '' : undefined"
:data-variant="variant"
v-bind="forwardedProps"
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
:class="cn('focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-rose-100 data-[variant=destructive]:focus:text-rose-700 data-[variant=destructive]:*:[svg]:!text-rose-700 [&_svg:not([class*=\'text-\'])]:text-foreground/60 relative flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', props.class)"
>
<slot />
</DropdownMenuItem>

View file

@ -16,7 +16,7 @@ const forwardedProps = useForwardProps(delegatedProps)
data-slot="dropdown-menu-label"
:data-inset="inset ? '' : undefined"
v-bind="forwardedProps"
:class="cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', props.class)"
:class="cn('px-2 py-1.5 text-[11px] font-black uppercase tracking-widest text-foreground/60 data-[inset]:pl-8', props.class)"
>
<slot />
</DropdownMenuLabel>

View file

@ -18,6 +18,6 @@ const delegatedProps = reactiveOmit(props, "class")
<DropdownMenuSeparator
data-slot="dropdown-menu-separator"
v-bind="delegatedProps"
:class="cn('bg-border -mx-1 my-1 h-px', props.class)"
:class="cn('-mx-1.5 my-1 h-px bg-foreground/10', props.class)"
/>
</template>

View file

@ -20,7 +20,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<DropdownMenuSubContent
data-slot="dropdown-menu-sub-content"
v-bind="forwarded"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg', props.class)"
:class="cn('bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-xl border-2 border-foreground p-1.5 shadow-md', props.class)"
>
<slot />
</DropdownMenuSubContent>

View file

@ -20,7 +20,7 @@ const forwardedProps = useForwardProps(delegatedProps)
data-slot="dropdown-menu-sub-trigger"
v-bind="forwardedProps"
:class="cn(
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8',
'focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium outline-hidden select-none data-[inset]:pl-8 [&_svg:not([class*=\'text-\'])]:text-foreground/60 [&_svg]:size-4 [&_svg]:shrink-0',
props.class,
)"
>

View file

@ -21,7 +21,7 @@ const modelValue = useVModel(props, "modelValue", emits, {
<template>
<input v-model="modelValue" data-slot="input" :class="cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-2 border-foreground h-9 w-full min-w-0 rounded-md bg-card px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
props.class,

View file

@ -33,16 +33,16 @@ const delegatedProps = reactiveOmit(props, "class")
v-model="modelValue"
data-slot="native-select"
:class="cn(
'border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 dark:hover:bg-input/50 h-9 w-full min-w-0 appearance-none rounded-md border bg-transparent px-3 py-2 pr-9 text-sm shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
'h-9 w-full min-w-0 cursor-pointer appearance-none rounded-md border-2 border-foreground bg-card px-3 py-2 pr-9 text-sm font-medium text-foreground shadow-2xs transition-[color,box-shadow] outline-none placeholder:text-foreground/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',
'focus-visible:ring-foreground/20 focus-visible:ring-[3px]',
'aria-invalid:border-destructive',
props.class,
)"
>
<slot />
</select>
<IconChevronDown
class="text-muted-foreground pointer-events-none absolute top-1/2 right-3.5 size-4 -translate-y-1/2 opacity-50 select-none"
class="pointer-events-none absolute top-1/2 right-2.5 size-4 -translate-y-1/2 select-none text-foreground/60"
aria-hidden="true"
data-slot="native-select-icon"
/>

View file

@ -34,7 +34,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
v-bind="{ ...$attrs, ...forwarded }"
:class="
cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md origin-(--reka-popover-content-transform-origin) outline-hidden',
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-xl border-2 border-foreground p-4 shadow-md origin-(--reka-popover-content-transform-origin) outline-hidden',
props.class,
)
"

View file

@ -34,7 +34,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
data-slot="select-content"
v-bind="{ ...$attrs, ...forwarded }"
:class="cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
'bg-card text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--reka-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-lg border-2 border-foreground shadow-md',
position === 'popper'
&& 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
props.class,

View file

@ -24,7 +24,7 @@ const forwardedProps = useForwardProps(delegatedProps)
v-bind="forwardedProps"
:class="
cn(
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
'focus:bg-accent focus:text-accent-foreground [&_svg:not([class*=\'text-\'])]:text-foreground/60 relative flex w-full cursor-pointer items-center gap-2 rounded-md py-1.5 pr-8 pl-2 text-sm font-medium text-foreground outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2',
props.class,
)
"

Some files were not shown because too many files have changed in this diff Show more