refactor: standardize UI components, modernize localization structure, and remove legacy appearance management middleware.
This commit is contained in:
parent
768760cdda
commit
acdc78f8bd
140 changed files with 2114 additions and 1792 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -29,5 +29,6 @@
|
|||
|
||||
'cancel' => 'Cancel',
|
||||
'clear' => 'Clear',
|
||||
'close' => 'Close',
|
||||
'loading_more' => 'Loading more...',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -29,5 +29,6 @@
|
|||
|
||||
'cancel' => 'Cancelar',
|
||||
'clear' => 'Limpiar',
|
||||
'close' => 'Cerrar',
|
||||
'loading_more' => 'Cargando más...',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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
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
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
1
lang/php_pt-BR.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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' => [
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -29,5 +29,6 @@
|
|||
|
||||
'cancel' => 'Cancelar',
|
||||
'clear' => 'Limpar',
|
||||
'close' => 'Fechar',
|
||||
'loading_more' => 'Carregando mais...',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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…',
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
41
resources/js/components/labels/LabelBadge.vue
Normal file
41
resources/js/components/labels/LabelBadge.vue
Normal 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>
|
||||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">· @{{ 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">· @{{ 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"> @{{ 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>
|
||||
|
|
|
|||
|
|
@ -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">· @{{ 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">· @{{ 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"> @{{ 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>
|
||||
|
|
|
|||
|
|
@ -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">· @{{ 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">· @{{ 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"> @{{ 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>
|
||||
|
|
|
|||
|
|
@ -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">· @{{ 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">· @{{ 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"> @{{ 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">· @{{ 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">· @{{ 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"> @{{ 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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 = ''
|
||||
}"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue