feat: improvements on ui

This commit is contained in:
Paulo Castellano 2026-01-21 20:50:57 -03:00
parent 38774aaaa6
commit b8071e775b
31 changed files with 554 additions and 205 deletions

View file

@ -130,10 +130,6 @@ PINTEREST_CLIENT_ID=
PINTEREST_CLIENT_SECRET= PINTEREST_CLIENT_SECRET=
PINTEREST_CLIENT_REDIRECT="${APP_URL}/accounts/pinterest/callback" PINTEREST_CLIENT_REDIRECT="${APP_URL}/accounts/pinterest/callback"
# Unsplash (https://unsplash.com/developers)
UNSPLASH_ACCESS_KEY=
UNSPLASH_SECRET_KEY=
# Vite # Vite
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"

View file

@ -35,6 +35,3 @@ GOOGLE_CLIENT_SECRET=test-google-client-secret
PINTEREST_CLIENT_ID=test-pinterest-client-id PINTEREST_CLIENT_ID=test-pinterest-client-id
PINTEREST_CLIENT_SECRET=test-pinterest-client-secret PINTEREST_CLIENT_SECRET=test-pinterest-client-secret
UNSPLASH_ACCESS_KEY=test-unsplash-access-key
UNSPLASH_SECRET_KEY=test-unsplash-secret-key

View file

@ -12,7 +12,7 @@ class LanguageSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
Language::create(['name' => 'English (US)', 'code' => 'en-US']); Language::create(['name' => 'English', 'code' => 'en']);
Language::create(['name' => 'Português (BR)', 'code' => 'pt-BR']); Language::create(['name' => 'Português', 'code' => 'pt-br']);
} }
} }

59
lang/en/accounts.php Normal file
View file

@ -0,0 +1,59 @@
<?php
return [
'title' => 'Connections',
'page_title' => 'Connected Accounts',
'description' => 'Connect your social networks to schedule and publish posts',
'not_connected' => 'Not connected',
'connect' => 'Connect',
'connection_lost' => 'Connection lost',
'reconnect_account' => 'Reconnect account',
'view_profile' => 'View profile',
'disconnect' => 'Disconnect',
'disconnect_modal' => [
'title' => 'Disconnect Account',
'description' => 'Are you sure you want to disconnect this account? You can reconnect it at any time.',
'confirm' => 'Disconnect',
'cancel' => 'Cancel',
],
'bluesky' => [
'title' => 'Connect Bluesky',
'description' => 'Enter your credentials to connect',
'email' => 'Email',
'email_placeholder' => 'yourhandle.bsky.social',
'app_password' => 'App Password',
'app_password_placeholder' => 'xxxx-xxxx-xxxx-xxxx',
'app_password_hint' => 'Use an <strong>App Password</strong> for security. Create one at <a href="https://bsky.app/settings/app-passwords" target="_blank" class="underline">bsky.app/settings</a>.',
'submit' => 'Connect Bluesky',
'submitting' => 'Connecting...',
],
'mastodon' => [
'title' => 'Connect Mastodon',
'description' => 'Enter your Mastodon instance',
'instance_url' => 'Instance URL',
'instance_placeholder' => 'https://mastodon.social',
'instance_hint' => 'Enter your Mastodon instance URL (e.g., mastodon.social, techhub.social)',
'submit' => 'Continue with Mastodon',
'submitting' => 'Connecting...',
],
'facebook' => [
'title' => 'Select Facebook Page',
'description' => 'Choose which page you want to connect',
'no_pages' => 'No pages found',
'no_pages_description' => 'You are not an admin of any Facebook page.',
'page_label' => 'Facebook Page',
],
'linkedin' => [
'title' => 'Select LinkedIn Page',
'description' => 'Choose which page you want to connect',
'no_pages' => 'No pages found',
'no_pages_description' => 'You are not an administrator of any LinkedIn page.',
'page_label' => 'LinkedIn Page',
],
];

11
lang/en/calendar.php Normal file
View file

@ -0,0 +1,11 @@
<?php
return [
'title' => 'Calendar',
'today' => 'Today',
'week' => 'Week',
'month' => 'Month',
'new_post' => 'New Post',
'no_content' => 'No content',
'more' => '+:count more',
];

104
lang/en/settings.php Normal file
View file

@ -0,0 +1,104 @@
<?php
return [
'title' => 'Settings',
'description' => 'Manage your profile and account settings',
'nav' => [
'profile' => 'Profile',
'password' => 'Password',
'workspace' => 'Workspace',
'members' => 'Members',
'billing' => 'Billing',
],
'profile' => [
'title' => 'Profile settings',
'heading' => 'Profile information',
'description' => 'Update your name and email address',
'avatar' => 'Avatar',
'name' => 'Name',
'name_placeholder' => 'Full name',
'email' => 'Email address',
'email_placeholder' => 'Email address',
'email_unverified' => 'Your email address is unverified.',
'resend_verification' => 'Click here to resend the verification email.',
'verification_sent' => 'A new verification link has been sent to your email address.',
'save' => 'Save',
],
'password' => [
'title' => 'Password settings',
'heading' => 'Update password',
'description' => 'Ensure your account is using a long, random password to stay secure',
'current_password' => 'Current password',
'current_password_placeholder' => 'Current password',
'new_password' => 'New password',
'new_password_placeholder' => 'New password',
'confirm_password' => 'Confirm password',
'confirm_password_placeholder' => 'Confirm password',
'save' => 'Save password',
],
'delete_account' => [
'heading' => 'Delete account',
'description' => 'Delete your account and all of its resources',
'warning' => 'Warning',
'warning_message' => 'Please proceed with caution, this cannot be undone.',
'button' => 'Delete account',
'modal_title' => 'Are you sure you want to delete your account?',
'modal_description' => 'Once your account is deleted, all of its resources and data will also be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.',
'password' => 'Password',
'password_placeholder' => 'Password',
'cancel' => 'Cancel',
'confirm' => 'Delete account',
],
'workspace' => [
'title' => 'Workspace settings',
'heading' => 'Workspace settings',
'description' => 'Update your workspace name, logo, and timezone',
'logo' => 'Logo',
'name' => 'Name',
'name_placeholder' => 'My Workspace',
'timezone' => 'Timezone',
'save' => 'Save',
'saved' => 'Saved.',
],
'members' => [
'title' => 'Members',
'heading' => 'Team members',
'description' => 'Manage members and invites for this workspace',
'invite' => [
'title' => 'Invite Member',
'description' => 'Send an email invite to add collaborators',
'email' => 'Email',
'email_placeholder' => 'collaborator@email.com',
'role' => 'Role',
'role_placeholder' => 'Select a role',
'submit' => 'Send Invite',
'cancel_confirm' => 'Are you sure you want to cancel this invite?',
],
'pending' => [
'title' => 'Pending Invites',
'description' => 'Invites awaiting acceptance',
'empty' => 'No pending invites',
],
'list' => [
'title' => 'Members',
'description' => 'People with access to this workspace',
'empty' => 'No members besides the owner',
'remove_confirm' => 'Are you sure you want to remove this member?',
],
'roles' => [
'owner' => 'Owner',
'admin' => 'Admin',
'member' => 'Member',
],
],
];

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

59
lang/pt-BR/accounts.php Normal file
View file

@ -0,0 +1,59 @@
<?php
return [
'title' => 'Conexões',
'page_title' => 'Contas Conectadas',
'description' => 'Conecte suas redes sociais para agendar e publicar posts',
'not_connected' => 'Não conectado',
'connect' => 'Conectar',
'connection_lost' => 'Conexão perdida',
'reconnect_account' => 'Reconectar conta',
'view_profile' => 'Ver perfil',
'disconnect' => 'Desconectar',
'disconnect_modal' => [
'title' => 'Desconectar Conta',
'description' => 'Tem certeza que deseja desconectar esta conta? Você pode reconectá-la a qualquer momento.',
'confirm' => 'Desconectar',
'cancel' => 'Cancelar',
],
'bluesky' => [
'title' => 'Conectar Bluesky',
'description' => 'Digite suas credenciais para conectar',
'email' => 'E-mail',
'email_placeholder' => 'seuhandle.bsky.social',
'app_password' => 'Senha do App',
'app_password_placeholder' => 'xxxx-xxxx-xxxx-xxxx',
'app_password_hint' => 'Use uma <strong>Senha do App</strong> por segurança. Crie uma em <a href="https://bsky.app/settings/app-passwords" target="_blank" class="underline">bsky.app/settings</a>.',
'submit' => 'Conectar Bluesky',
'submitting' => 'Conectando...',
],
'mastodon' => [
'title' => 'Conectar Mastodon',
'description' => 'Digite a instância do seu Mastodon',
'instance_url' => 'URL da Instância',
'instance_placeholder' => 'https://mastodon.social',
'instance_hint' => 'Digite a URL da sua instância Mastodon (ex: mastodon.social, techhub.social)',
'submit' => 'Continuar com Mastodon',
'submitting' => 'Conectando...',
],
'facebook' => [
'title' => 'Selecionar Página do Facebook',
'description' => 'Escolha qual página você deseja conectar',
'no_pages' => 'Nenhuma página encontrada',
'no_pages_description' => 'Você não é administrador de nenhuma página do Facebook.',
'page_label' => 'Página do Facebook',
],
'linkedin' => [
'title' => 'Selecionar Página do LinkedIn',
'description' => 'Escolha qual página você deseja conectar',
'no_pages' => 'Nenhuma página encontrada',
'no_pages_description' => 'Você não é administrador de nenhuma página do LinkedIn.',
'page_label' => 'Página do LinkedIn',
],
];

11
lang/pt-BR/calendar.php Normal file
View file

@ -0,0 +1,11 @@
<?php
return [
'title' => 'Calendário',
'today' => 'Hoje',
'week' => 'Semana',
'month' => 'Mês',
'new_post' => 'Novo Post',
'no_content' => 'Sem conteúdo',
'more' => '+:count mais',
];

104
lang/pt-BR/settings.php Normal file
View file

@ -0,0 +1,104 @@
<?php
return [
'title' => 'Configurações',
'description' => 'Gerencie seu perfil e configurações da conta',
'nav' => [
'profile' => 'Perfil',
'password' => 'Senha',
'workspace' => 'Workspace',
'members' => 'Membros',
'billing' => 'Faturamento',
],
'profile' => [
'title' => 'Configurações do perfil',
'heading' => 'Informações do perfil',
'description' => 'Atualize seu nome e endereço de e-mail',
'avatar' => 'Avatar',
'name' => 'Nome',
'name_placeholder' => 'Nome completo',
'email' => 'Endereço de e-mail',
'email_placeholder' => 'Endereço de e-mail',
'email_unverified' => 'Seu endereço de e-mail não foi verificado.',
'resend_verification' => 'Clique aqui para reenviar o e-mail de verificação.',
'verification_sent' => 'Um novo link de verificação foi enviado para seu endereço de e-mail.',
'save' => 'Salvar',
],
'password' => [
'title' => 'Configurações de senha',
'heading' => 'Atualizar senha',
'description' => 'Certifique-se de que sua conta esteja usando uma senha longa e aleatória para se manter seguro',
'current_password' => 'Senha atual',
'current_password_placeholder' => 'Senha atual',
'new_password' => 'Nova senha',
'new_password_placeholder' => 'Nova senha',
'confirm_password' => 'Confirmar senha',
'confirm_password_placeholder' => 'Confirmar senha',
'save' => 'Salvar senha',
],
'delete_account' => [
'heading' => 'Excluir conta',
'description' => 'Exclua sua conta e todos os seus recursos',
'warning' => 'Atenção',
'warning_message' => 'Por favor, prossiga com cuidado, isso não pode ser desfeito.',
'button' => 'Excluir conta',
'modal_title' => 'Tem certeza que deseja excluir sua conta?',
'modal_description' => 'Uma vez que sua conta for excluída, todos os seus recursos e dados também serão permanentemente excluídos. Por favor, digite sua senha para confirmar que deseja excluir permanentemente sua conta.',
'password' => 'Senha',
'password_placeholder' => 'Senha',
'cancel' => 'Cancelar',
'confirm' => 'Excluir conta',
],
'workspace' => [
'title' => 'Configurações do workspace',
'heading' => 'Configurações do workspace',
'description' => 'Atualize o nome, logo e fuso horário do workspace',
'logo' => 'Logo',
'name' => 'Nome',
'name_placeholder' => 'Meu Workspace',
'timezone' => 'Fuso horário',
'save' => 'Salvar',
'saved' => 'Salvo.',
],
'members' => [
'title' => 'Membros',
'heading' => 'Membros da equipe',
'description' => 'Gerencie membros e convites deste workspace',
'invite' => [
'title' => 'Convidar Membro',
'description' => 'Envie um convite por e-mail para adicionar colaboradores',
'email' => 'E-mail',
'email_placeholder' => 'colaborador@email.com',
'role' => 'Função',
'role_placeholder' => 'Selecione uma função',
'submit' => 'Enviar Convite',
'cancel_confirm' => 'Tem certeza que deseja cancelar este convite?',
],
'pending' => [
'title' => 'Convites Pendentes',
'description' => 'Convites aguardando aceitação',
'empty' => 'Nenhum convite pendente',
],
'list' => [
'title' => 'Membros',
'description' => 'Pessoas com acesso a este workspace',
'empty' => 'Nenhum membro além do proprietário',
'remove_confirm' => 'Tem certeza que deseja remover este membro?',
],
'roles' => [
'owner' => 'Proprietário',
'admin' => 'Administrador',
'member' => 'Membro',
],
],
];

View file

@ -8,6 +8,7 @@ import type { DefineComponent } from 'vue';
import { createApp, h } from 'vue'; import { createApp, h } from 'vue';
import { initializeTheme } from './composables/useAppearance'; import { initializeTheme } from './composables/useAppearance';
import dayjs from './dayjs';
configureEcho({ configureEcho({
broadcaster: 'reverb', broadcaster: 'reverb',
@ -23,6 +24,10 @@ createInertiaApp({
import.meta.glob<DefineComponent>('./pages/**/*.vue'), import.meta.glob<DefineComponent>('./pages/**/*.vue'),
), ),
setup({ el, App, props, plugin }) { setup({ el, App, props, plugin }) {
// Set dayjs locale based on user's language
const userLanguage = (props.initialPage.props as { auth?: { user?: { language?: { code?: string } } } })?.auth?.user?.language?.code;
dayjs.locale(userLanguage?.toLowerCase() || 'en');
createApp({ render: () => h(App, props) }) createApp({ render: () => h(App, props) })
.use(i18nVue, { .use(i18nVue, {
resolve: async (lang: string) => { resolve: async (lang: string) => {

View file

@ -344,7 +344,7 @@ function handleLogout() {
<div v-if="currentWorkspace" class="px-2 py-2"> <div v-if="currentWorkspace" class="px-2 py-2">
<Link :href="createPost.url()"> <Link :href="createPost.url()">
<Button :size="sidebarState === 'collapsed' ? 'icon' : 'default'" class="w-full"> <Button :size="sidebarState === 'collapsed' ? 'icon' : 'default'" class="w-full">
<IconPlus class="size-4" /> <IconPlus v-if="sidebarState === 'collapsed'" class="size-4" />
<span v-if="sidebarState === 'expanded'">{{ $t('sidebar.create_post') }}</span> <span v-if="sidebarState === 'expanded'">{{ $t('sidebar.create_post') }}</span>
</Button> </Button>
</Link> </Link>

View file

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form } from '@inertiajs/vue3'; import { Form } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { useTemplateRef } from 'vue'; import { useTemplateRef } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
@ -25,23 +26,23 @@ const passwordInput = useTemplateRef('passwordInput');
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<HeadingSmall <HeadingSmall
title="Delete account" :title="$t('settings.delete_account.heading')"
description="Delete your account and all of its resources" :description="$t('settings.delete_account.description')"
/> />
<div <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" 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"> <div class="relative space-y-0.5 text-red-600 dark:text-red-100">
<p class="font-medium">Warning</p> <p class="font-medium">{{ $t('settings.delete_account.warning') }}</p>
<p class="text-sm"> <p class="text-sm">
Please proceed with caution, this cannot be undone. {{ $t('settings.delete_account.warning_message') }}
</p> </p>
</div> </div>
<Dialog> <Dialog>
<DialogTrigger as-child> <DialogTrigger as-child>
<Button variant="destructive" data-test="delete-user-button" <Button variant="destructive" data-test="delete-user-button">
>Delete account</Button {{ $t('settings.delete_account.button') }}
> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<Form <Form
@ -55,29 +56,22 @@ const passwordInput = useTemplateRef('passwordInput');
v-slot="{ errors, processing, reset, clearErrors }" v-slot="{ errors, processing, reset, clearErrors }"
> >
<DialogHeader class="space-y-3"> <DialogHeader class="space-y-3">
<DialogTitle <DialogTitle>{{ $t('settings.delete_account.modal_title') }}</DialogTitle>
>Are you sure you want to delete your
account?</DialogTitle
>
<DialogDescription> <DialogDescription>
Once your account is deleted, all of its {{ $t('settings.delete_account.modal_description') }}
resources and data will also be permanently
deleted. Please enter your password to confirm
you would like to permanently delete your
account.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="password" class="sr-only" <Label for="password" class="sr-only">
>Password</Label {{ $t('settings.delete_account.password') }}
> </Label>
<Input <Input
id="password" id="password"
type="password" type="password"
name="password" name="password"
ref="passwordInput" ref="passwordInput"
placeholder="Password" :placeholder="trans('settings.delete_account.password_placeholder')"
/> />
<InputError :message="errors.password" /> <InputError :message="errors.password" />
</div> </div>
@ -93,7 +87,7 @@ const passwordInput = useTemplateRef('passwordInput');
} }
" "
> >
Cancel {{ $t('settings.delete_account.cancel') }}
</Button> </Button>
</DialogClose> </DialogClose>
@ -103,7 +97,7 @@ const passwordInput = useTemplateRef('passwordInput');
:disabled="processing" :disabled="processing"
data-test="confirm-delete-user-button" data-test="confirm-delete-user-button"
> >
Delete account {{ $t('settings.delete_account.confirm') }}
</Button> </Button>
</DialogFooter> </DialogFooter>
</Form> </Form>

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { router } from '@inertiajs/vue3'; import { router } from '@inertiajs/vue3';
import { IconAlertCircle, IconCheck, IconExternalLink, IconRefresh, IconTrash } from '@tabler/icons-vue'; import { IconAlertCircle, IconCheck, IconExternalLink, IconRefresh, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed, onMounted, onUnmounted } from 'vue'; import { computed, onMounted, onUnmounted } from 'vue';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
@ -171,7 +172,7 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
@{{ platform.account.username || platform.account.display_name }} @{{ platform.account.username || platform.account.display_name }}
</p> </p>
<p v-else class="text-sm text-muted-foreground"> <p v-else class="text-sm text-muted-foreground">
Not connected {{ trans('accounts.not_connected') }}
</p> </p>
</div> </div>
</div> </div>
@ -182,7 +183,7 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
<div v-if="isDisconnected(platform.account)" class="mb-3 flex items-start gap-2 rounded-lg bg-red-100 p-2 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400"> <div v-if="isDisconnected(platform.account)" class="mb-3 flex items-start gap-2 rounded-lg bg-red-100 p-2 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-400">
<IconAlertCircle class="h-4 w-4 mt-0.5 shrink-0" /> <IconAlertCircle class="h-4 w-4 mt-0.5 shrink-0" />
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="font-medium">Connection lost</p> <p class="font-medium">{{ trans('accounts.connection_lost') }}</p>
<p v-if="platform.account.error_message" class="text-xs truncate opacity-80"> <p v-if="platform.account.error_message" class="text-xs truncate opacity-80">
{{ platform.account.error_message }} {{ platform.account.error_message }}
</p> </p>
@ -214,7 +215,7 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Reconnect account</p> <p>{{ trans('accounts.reconnect_account') }}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@ -230,7 +231,7 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
</a> </a>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>View profile</p> <p>{{ trans('accounts.view_profile') }}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@ -245,7 +246,7 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Disconnect</p> <p>{{ trans('accounts.disconnect') }}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@ -256,7 +257,7 @@ const isDisconnected = (account: SocialAccount | null): boolean => {
<!-- Not Connected State --> <!-- Not Connected State -->
<div v-else class="border-t px-4 py-3"> <div v-else class="border-t px-4 py-3">
<Button variant="outline" class="w-full" size="sm" @click="openOAuthPopup(platform.value)"> <Button variant="outline" class="w-full" size="sm" @click="openOAuthPopup(platform.value)">
Connect {{ trans('accounts.connect') }}
</Button> </Button>
</div> </div>
</div> </div>

View file

@ -12,6 +12,7 @@ import weekday from 'dayjs/plugin/weekday';
// Import locales // Import locales
import 'dayjs/locale/en'; import 'dayjs/locale/en';
import "dayjs/locale/pt-br"
// Extend dayjs with plugins // Extend dayjs with plugins
dayjs.extend(utc); dayjs.extend(utc);
@ -25,11 +26,13 @@ dayjs.extend(advancedFormat);
dayjs.extend(weekday); dayjs.extend(weekday);
dayjs.extend(isBetween); dayjs.extend(isBetween);
dayjs.locale('en');
// Set Monday as first day of week (to match Carbon/Laravel) // Set Monday as first day of week (to match Carbon/Laravel)
dayjs.updateLocale('en', { dayjs.updateLocale('en', {
weekStart: 1, weekStart: 1,
}); });
dayjs.updateLocale('pt-br', {
weekStart: 1,
});
export default dayjs; export default dayjs;

View file

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Link, usePage } from '@inertiajs/vue3'; import { Link, usePage } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue'; import { computed } from 'vue';
import Heading from '@/components/Heading.vue'; import Heading from '@/components/Heading.vue';
@ -19,11 +20,11 @@ const canManageWorkspace = computed(() => auth.value.role !== 'member');
const navItems = computed<NavItem[]>(() => { const navItems = computed<NavItem[]>(() => {
const items: NavItem[] = [ const items: NavItem[] = [
{ {
title: 'Profile', title: trans('settings.nav.profile'),
href: editProfile(), href: editProfile(),
}, },
{ {
title: 'Password', title: trans('settings.nav.password'),
href: editPassword(), href: editPassword(),
}, },
]; ];
@ -31,18 +32,18 @@ const navItems = computed<NavItem[]>(() => {
if (canManageWorkspace.value) { if (canManageWorkspace.value) {
items.push( items.push(
{ {
title: 'Workspace', title: trans('settings.nav.workspace'),
href: workspaceSettings(), href: workspaceSettings(),
}, },
{ {
title: 'Members', title: trans('settings.nav.members'),
href: members(), href: members(),
}, },
); );
if (!page.props.selfHosted) { if (!page.props.selfHosted) {
items.push({ items.push({
title: 'Billing', title: trans('settings.nav.billing'),
href: billing(), href: billing(),
}); });
} }
@ -58,8 +59,8 @@ const { urlIsActive } = useActiveUrl();
<div class="px-4 py-6"> <div class="px-4 py-6">
<div class="flex flex-col items-center gap-6"> <div class="flex flex-col items-center gap-6">
<Heading <Heading
title="Settings" :title="$t('settings.title')"
description="Manage your profile and account settings" :description="$t('settings.description')"
class="w-full max-w-2xl" class="w-full max-w-2xl"
/> />

View file

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { IconInfoCircle } from '@tabler/icons-vue'; import { IconInfoCircle } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { ref } from 'vue'; import { ref } from 'vue';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
@ -29,13 +30,13 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
</script> </script>
<template> <template>
<PopupLayout title="Connect Bluesky"> <PopupLayout :title="$t('accounts.bluesky.title')">
<div class="max-w-md mx-auto"> <div class="max-w-md mx-auto">
<div class="flex items-center gap-3 mb-6"> <div class="flex items-center gap-3 mb-6">
<img src="/images/accounts/bluesky.png" alt="Bluesky" class="h-12 w-12" /> <img src="/images/accounts/bluesky.png" alt="Bluesky" class="h-12 w-12" />
<div> <div>
<h1 class="text-xl font-bold tracking-tight">Connect Bluesky</h1> <h1 class="text-xl font-bold tracking-tight">{{ $t('accounts.bluesky.title') }}</h1>
<p class="text-sm text-muted-foreground">Enter your credentials to connect</p> <p class="text-sm text-muted-foreground">{{ $t('accounts.bluesky.description') }}</p>
</div> </div>
</div> </div>
@ -43,9 +44,9 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
<input type="hidden" name="_token" :value="csrfToken" /> <input type="hidden" name="_token" :value="csrfToken" />
<div class="space-y-2"> <div class="space-y-2">
<Label for="identifier">Email</Label> <Label for="identifier">{{ $t('accounts.bluesky.email') }}</Label>
<Input id="identifier" name="identifier" v-model="identifier" type="text" <Input id="identifier" name="identifier" v-model="identifier" type="text"
placeholder="yourhandle.bsky.social" :class="{ 'border-destructive': errors?.identifier }" :placeholder="trans('accounts.bluesky.email_placeholder')" :class="{ 'border-destructive': errors?.identifier }"
required /> required />
<p v-if="errors?.identifier" class="text-sm text-destructive"> <p v-if="errors?.identifier" class="text-sm text-destructive">
{{ errors.identifier }} {{ errors.identifier }}
@ -53,9 +54,9 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="password">App Password</Label> <Label for="password">{{ $t('accounts.bluesky.app_password') }}</Label>
<Input id="password" name="password" v-model="password" type="password" <Input id="password" name="password" v-model="password" type="password"
placeholder="xxxx-xxxx-xxxx-xxxx" :class="{ 'border-destructive': errors?.password }" :placeholder="trans('accounts.bluesky.app_password_placeholder')" :class="{ 'border-destructive': errors?.password }"
required /> required />
<p v-if="errors?.password" class="text-sm text-destructive"> <p v-if="errors?.password" class="text-sm text-destructive">
{{ errors.password }} {{ errors.password }}
@ -64,15 +65,11 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
<Alert> <Alert>
<IconInfoCircle class="h-4 w-4" /> <IconInfoCircle class="h-4 w-4" />
<AlertDescription class="inline"> <AlertDescription class="inline" v-html="$t('accounts.bluesky.app_password_hint')" />
Use an <strong>App Password</strong> for security. Create one at <a
href="https://bsky.app/settings/app-passwords" target="_blank"
class="underline">bsky.app/settings</a>.
</AlertDescription>
</Alert> </Alert>
<Button type="submit" :disabled="isSubmitting" class="w-full"> <Button type="submit" :disabled="isSubmitting" class="w-full">
{{ isSubmitting ? 'Connecting...' : 'Connect Bluesky' }} {{ isSubmitting ? $t('accounts.bluesky.submitting') : $t('accounts.bluesky.submit') }}
</Button> </Button>
</form> </form>
</div> </div>

View file

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { IconBrandFacebook, IconCheck } from '@tabler/icons-vue'; import { IconBrandFacebook, IconCheck } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { ref } from 'vue'; import { ref } from 'vue';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
@ -40,7 +41,7 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
</script> </script>
<template> <template>
<PopupLayout title="Select Facebook Page"> <PopupLayout :title="$t('accounts.facebook.title')">
<!-- Hidden form for regular POST submission --> <!-- Hidden form for regular POST submission -->
<form ref="formRef" :action="selectFacebookPage.url()" method="POST" class="hidden"> <form ref="formRef" :action="selectFacebookPage.url()" method="POST" class="hidden">
<input type="hidden" name="_token" :value="csrfToken" /> <input type="hidden" name="_token" :value="csrfToken" />
@ -51,8 +52,8 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<img src="/images/accounts/facebook.png" alt="Facebook" class="h-10 w-10" /> <img src="/images/accounts/facebook.png" alt="Facebook" class="h-10 w-10" />
<div> <div>
<h1 class="text-xl font-bold tracking-tight">Select Facebook Page</h1> <h1 class="text-xl font-bold tracking-tight">{{ $t('accounts.facebook.title') }}</h1>
<p class="text-sm text-muted-foreground">Choose which page you want to connect</p> <p class="text-sm text-muted-foreground">{{ $t('accounts.facebook.description') }}</p>
</div> </div>
</div> </div>
@ -64,9 +65,9 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-muted"> <div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-muted">
<IconBrandFacebook class="h-7 w-7 text-muted-foreground" /> <IconBrandFacebook class="h-7 w-7 text-muted-foreground" />
</div> </div>
<h3 class="mt-4 text-lg font-semibold">No pages found</h3> <h3 class="mt-4 text-lg font-semibold">{{ $t('accounts.facebook.no_pages') }}</h3>
<p class="mt-1 text-sm text-muted-foreground"> <p class="mt-1 text-sm text-muted-foreground">
You are not an admin of any Facebook page. {{ $t('accounts.facebook.no_pages_description') }}
</p> </p>
</div> </div>
@ -91,7 +92,7 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
<p v-if="page.username" class="text-sm text-muted-foreground truncate"> <p v-if="page.username" class="text-sm text-muted-foreground truncate">
facebook.com/{{ page.username }} facebook.com/{{ page.username }}
</p> </p>
<p v-else class="text-sm text-muted-foreground">Facebook Page</p> <p v-else class="text-sm text-muted-foreground">{{ $t('accounts.facebook.page_label') }}</p>
</div> </div>
<div class="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"> <div class="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<div <div

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { ref } from 'vue'; import { trans } from 'laravel-vue-i18n';
import { computed, ref } from 'vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue'; import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import SocialAccountsGrid, { type Platform } from '@/components/SocialAccountsGrid.vue'; import SocialAccountsGrid, { type Platform } from '@/components/SocialAccountsGrid.vue';
@ -23,9 +24,9 @@ defineProps<Props>();
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null); const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
const breadcrumbs: BreadcrumbItemType[] = [ const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
{ title: 'Accounts', href: accounts.url() }, { title: trans('accounts.title'), href: accounts.url() },
]; ]);
const handleDisconnect = (accountId: string) => { const handleDisconnect = (accountId: string) => {
deleteModal.value?.open({ deleteModal.value?.open({
@ -35,14 +36,14 @@ const handleDisconnect = (accountId: string) => {
</script> </script>
<template> <template>
<Head title="Connected Accounts" /> <Head :title="$t('accounts.page_title')" />
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<div class="flex flex-col gap-8 p-6"> <div class="flex flex-col gap-8 p-6">
<div> <div>
<h1 class="text-2xl font-bold tracking-tight">Connected Accounts</h1> <h1 class="text-2xl font-bold tracking-tight">{{ $t('accounts.page_title') }}</h1>
<p class="text-muted-foreground"> <p class="text-muted-foreground">
Connect your social networks to schedule and publish posts {{ $t('accounts.description') }}
</p> </p>
</div> </div>
@ -55,9 +56,9 @@ const handleDisconnect = (accountId: string) => {
<ConfirmDeleteModal <ConfirmDeleteModal
ref="deleteModal" ref="deleteModal"
title="Disconnect Account" :title="$t('accounts.disconnect_modal.title')"
description="Are you sure you want to disconnect this account? You can reconnect it at any time." :description="$t('accounts.disconnect_modal.description')"
action="Disconnect" :action="$t('accounts.disconnect_modal.confirm')"
cancel="Cancel" :cancel="$t('accounts.disconnect_modal.cancel')"
/> />
</template> </template>

View file

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { IconBuilding, IconCheck } from '@tabler/icons-vue'; import { IconBuilding, IconCheck } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { ref } from 'vue'; import { ref } from 'vue';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
@ -40,7 +41,7 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
</script> </script>
<template> <template>
<PopupLayout title="Select LinkedIn Page"> <PopupLayout :title="$t('accounts.linkedin.title')">
<!-- Hidden form for regular POST submission --> <!-- Hidden form for regular POST submission -->
<form ref="formRef" :action="selectLinkedInPage.url()" method="POST" class="hidden"> <form ref="formRef" :action="selectLinkedInPage.url()" method="POST" class="hidden">
<input type="hidden" name="_token" :value="csrfToken" /> <input type="hidden" name="_token" :value="csrfToken" />
@ -54,8 +55,8 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<img src="/images/accounts/linkedin.png" alt="LinkedIn" class="h-10 w-10" /> <img src="/images/accounts/linkedin.png" alt="LinkedIn" class="h-10 w-10" />
<div> <div>
<h1 class="text-xl font-bold tracking-tight">Select LinkedIn Page</h1> <h1 class="text-xl font-bold tracking-tight">{{ $t('accounts.linkedin.title') }}</h1>
<p class="text-sm text-muted-foreground">Choose which page you want to connect</p> <p class="text-sm text-muted-foreground">{{ $t('accounts.linkedin.description') }}</p>
</div> </div>
</div> </div>
@ -67,9 +68,9 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-muted"> <div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-muted">
<IconBuilding class="h-7 w-7 text-muted-foreground" /> <IconBuilding class="h-7 w-7 text-muted-foreground" />
</div> </div>
<h3 class="mt-4 text-lg font-semibold">No pages found</h3> <h3 class="mt-4 text-lg font-semibold">{{ $t('accounts.linkedin.no_pages') }}</h3>
<p class="mt-1 text-sm text-muted-foreground"> <p class="mt-1 text-sm text-muted-foreground">
You are not an administrator of any LinkedIn page. {{ $t('accounts.linkedin.no_pages_description') }}
</p> </p>
</div> </div>
@ -94,7 +95,7 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
<p v-if="org.vanity_name" class="text-sm text-muted-foreground truncate"> <p v-if="org.vanity_name" class="text-sm text-muted-foreground truncate">
linkedin.com/company/{{ org.vanity_name }} linkedin.com/company/{{ org.vanity_name }}
</p> </p>
<p v-else class="text-sm text-muted-foreground">LinkedIn Page</p> <p v-else class="text-sm text-muted-foreground">{{ $t('accounts.linkedin.page_label') }}</p>
</div> </div>
<div class="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"> <div class="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<div <div

View file

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { IconInfoCircle } from '@tabler/icons-vue'; import { IconInfoCircle } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { ref } from 'vue'; import { ref } from 'vue';
import { Alert, AlertDescription } from '@/components/ui/alert'; import { Alert, AlertDescription } from '@/components/ui/alert';
@ -28,13 +29,13 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
</script> </script>
<template> <template>
<PopupLayout title="Connect Mastodon"> <PopupLayout :title="$t('accounts.mastodon.title')">
<div class="max-w-md mx-auto"> <div class="max-w-md mx-auto">
<div class="flex items-center gap-3 mb-6"> <div class="flex items-center gap-3 mb-6">
<img src="/images/accounts/mastodon.png" alt="Mastodon" class="h-12 w-12" /> <img src="/images/accounts/mastodon.png" alt="Mastodon" class="h-12 w-12" />
<div> <div>
<h1 class="text-xl font-bold tracking-tight">Connect Mastodon</h1> <h1 class="text-xl font-bold tracking-tight">{{ $t('accounts.mastodon.title') }}</h1>
<p class="text-sm text-muted-foreground">Enter your Mastodon instance</p> <p class="text-sm text-muted-foreground">{{ $t('accounts.mastodon.description') }}</p>
</div> </div>
</div> </div>
@ -48,13 +49,13 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
<input type="hidden" name="_token" :value="csrfToken" /> <input type="hidden" name="_token" :value="csrfToken" />
<div class="space-y-2"> <div class="space-y-2">
<Label for="instance">Instance URL</Label> <Label for="instance">{{ $t('accounts.mastodon.instance_url') }}</Label>
<Input <Input
id="instance" id="instance"
name="instance" name="instance"
v-model="instance" v-model="instance"
type="url" type="url"
placeholder="https://mastodon.social" :placeholder="trans('accounts.mastodon.instance_placeholder')"
:class="{ 'border-destructive': errors?.instance }" :class="{ 'border-destructive': errors?.instance }"
required required
/> />
@ -65,11 +66,11 @@ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribut
<Alert> <Alert>
<IconInfoCircle class="h-4 w-4" /> <IconInfoCircle class="h-4 w-4" />
<AlertDescription class="inline">Enter your Mastodon instance URL (e.g., mastodon.social, techhub.social)</AlertDescription> <AlertDescription class="inline">{{ $t('accounts.mastodon.instance_hint') }}</AlertDescription>
</Alert> </Alert>
<Button type="submit" :disabled="isSubmitting" class="w-full"> <Button type="submit" :disabled="isSubmitting" class="w-full">
{{ isSubmitting ? 'Connecting...' : 'Continue with Mastodon' }} {{ isSubmitting ? $t('accounts.mastodon.submitting') : $t('accounts.mastodon.submit') }}
</Button> </Button>
</form> </form>
</div> </div>

View file

@ -2,7 +2,7 @@
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { IconPlus, IconHash, IconPencil, IconTrash } from '@tabler/icons-vue'; import { IconPlus, IconHash, IconPencil, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n'; import { trans } from 'laravel-vue-i18n';
import { ref } from 'vue'; import { computed, ref } from 'vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue'; import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import CreateDialog from '@/components/hashtags/CreateDialog.vue'; import CreateDialog from '@/components/hashtags/CreateDialog.vue';
@ -37,9 +37,9 @@ const isCreateDialogOpen = ref(false);
const isEditDialogOpen = ref(false); const isEditDialogOpen = ref(false);
const editingHashtag = ref<Hashtag | null>(null); const editingHashtag = ref<Hashtag | null>(null);
const breadcrumbs: BreadcrumbItemType[] = [ const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
{ title: trans('hashtags.title'), href: hashtagsIndex.url() }, { title: trans('hashtags.title'), href: hashtagsIndex.url() },
]; ]);
const openEditDialog = (hashtag: Hashtag) => { const openEditDialog = (hashtag: Hashtag) => {
editingHashtag.value = hashtag; editingHashtag.value = hashtag;

View file

@ -2,7 +2,7 @@
import { Head } from '@inertiajs/vue3'; import { Head } from '@inertiajs/vue3';
import { IconPlus, IconTag, IconPencil, IconTrash } from '@tabler/icons-vue'; import { IconPlus, IconTag, IconPencil, IconTrash } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n'; import { trans } from 'laravel-vue-i18n';
import { ref } from 'vue'; import { computed, ref } from 'vue';
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue'; import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
import CreateDialog from '@/components/labels/CreateDialog.vue'; import CreateDialog from '@/components/labels/CreateDialog.vue';
@ -37,9 +37,9 @@ const isCreateDialogOpen = ref(false);
const isEditDialogOpen = ref(false); const isEditDialogOpen = ref(false);
const editingLabel = ref<Label | null>(null); const editingLabel = ref<Label | null>(null);
const breadcrumbs: BreadcrumbItemType[] = [ const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
{ title: trans('labels.title'), href: labelsIndex.url() }, { title: trans('labels.title'), href: labelsIndex.url() },
]; ]);
const openEditDialog = (label: Label) => { const openEditDialog = (label: Label) => {
editingLabel.value = label; editingLabel.value = label;

View file

@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, Link, router } from '@inertiajs/vue3'; import { Head, Link, router } from '@inertiajs/vue3';
import { IconChevronLeft, IconChevronRight, IconPlus } from '@tabler/icons-vue'; import { IconChevronLeft, IconChevronRight, IconPlus } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue'; import { computed } from 'vue';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -46,9 +47,19 @@ interface Props {
const props = defineProps<Props>(); const props = defineProps<Props>();
const breadcrumbs: BreadcrumbItemType[] = [ const breadcrumbs = computed<BreadcrumbItemType[]>(() => [
{ title: 'Calendar', href: calendar.url() }, { title: trans('calendar.title'), href: calendar.url() },
]; ]);
// Generate weekday names based on dayjs locale (respects weekStart config)
const weekdayNames = computed(() => {
const names = [];
const start = dayjs().startOf('week');
for (let i = 0; i < 7; i++) {
names.push(start.add(i, 'day').format('dddd'));
}
return names;
});
// Week view computed // Week view computed
const weekStart = computed(() => dayjs(props.currentWeekStart)); const weekStart = computed(() => dayjs(props.currentWeekStart));
@ -193,7 +204,7 @@ const formatTime = (scheduledAt: string): string => {
<template> <template>
<Head title="Calendar" /> <Head :title="$t('calendar.title')" />
<AppLayout :breadcrumbs="breadcrumbs"> <AppLayout :breadcrumbs="breadcrumbs">
<div class="flex flex-col h-full"> <div class="flex flex-col h-full">
@ -209,7 +220,7 @@ const formatTime = (scheduledAt: string): string => {
</Button> </Button>
</div> </div>
<Button variant="outline" size="sm" @click="goToToday"> <Button variant="outline" size="sm" @click="goToToday">
Today {{ $t('calendar.today') }}
</Button> </Button>
<h1 class="text-lg font-semibold"> <h1 class="text-lg font-semibold">
{{ headerTitle }} {{ headerTitle }}
@ -218,14 +229,13 @@ const formatTime = (scheduledAt: string): string => {
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<Tabs :default-value="view" @update:model-value="switchView"> <Tabs :default-value="view" @update:model-value="switchView">
<TabsList> <TabsList>
<TabsTrigger value="week">Week</TabsTrigger> <TabsTrigger value="week">{{ $t('calendar.week') }}</TabsTrigger>
<TabsTrigger value="month">Month</TabsTrigger> <TabsTrigger value="month">{{ $t('calendar.month') }}</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
<Link :href="createPost.url()"> <Link :href="createPost.url()">
<Button> <Button>
<IconPlus class="h-4 w-4" /> {{ $t('calendar.new_post') }}
New Post
</Button> </Button>
</Link> </Link>
</div> </div>
@ -237,15 +247,14 @@ const formatTime = (scheduledAt: string): string => {
:class="{ 'bg-primary/5': isToday(day) }"> :class="{ 'bg-primary/5': isToday(day) }">
<!-- Day Header --> <!-- Day Header -->
<div class="flex flex-col items-center py-3 border-b bg-muted/30"> <div class="flex flex-col items-center py-3 border-b bg-muted/30">
<span class="text-xs font-medium text-muted-foreground uppercase"> <span class="text-xs font-medium text-muted-foreground">
{{ day.format('ddd') }} {{ day.format('dddd') }}
</span> </span>
<span class="mt-1 flex items-center justify-center w-8 h-8 text-sm font-semibold rounded-full" <span class="mt-1 text-sm font-semibold" :class="{
:class="{ 'text-primary': isToday(day),
'bg-primary text-primary-foreground': isToday(day), 'text-foreground': !isToday(day),
'text-foreground': !isToday(day), }">
}"> {{ day.format('D/MMMM') }}
{{ day.format('D') }}
</span> </span>
</div> </div>
@ -279,7 +288,7 @@ const formatTime = (scheduledAt: string): string => {
<!-- Content Preview --> <!-- Content Preview -->
<p class="text-xs line-clamp-2 opacity-80"> <p class="text-xs line-clamp-2 opacity-80">
{{ post.post_platforms[0]?.content || 'No content' }} {{ post.post_platforms[0]?.content || $t('calendar.no_content') }}
</p> </p>
</div> </div>
</Link> </Link>
@ -288,11 +297,11 @@ const formatTime = (scheduledAt: string): string => {
</div> </div>
<!-- Month View --> <!-- Month View -->
<div v-else class="flex-1 flex flex-col overflow-hidden border-l"> <div v-else class="flex-1 flex flex-col overflow-hidden">
<!-- Weekday Headers --> <!-- Weekday Headers -->
<div class="grid grid-cols-7 divide-x border-b bg-muted/30"> <div class="grid grid-cols-7 divide-x border-b bg-muted/30">
<div v-for="day in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']" :key="day" <div v-for="day in weekdayNames" :key="day"
class="py-3 text-center text-xs font-medium text-muted-foreground uppercase"> class="py-3 text-center text-xs font-medium text-muted-foreground">
{{ day }} {{ day }}
</div> </div>
</div> </div>
@ -343,7 +352,7 @@ const formatTime = (scheduledAt: string): string => {
</Link> </Link>
<div v-if="getPostsForDay(day).length > 3" <div v-if="getPostsForDay(day).length > 3"
class="text-xs text-muted-foreground px-2 py-0.5"> class="text-xs text-muted-foreground px-2 py-0.5">
+{{ getPostsForDay(day).length - 3 }} more {{ $t('calendar.more', { count: getPostsForDay(day).length - 3 }) }}
</div> </div>
</div> </div>
</div> </div>

View file

@ -9,7 +9,6 @@ import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import dayjs from '@/dayjs'; import dayjs from '@/dayjs';
import AppLayout from '@/layouts/AppLayout.vue'; import AppLayout from '@/layouts/AppLayout.vue';
import { calendar } from '@/routes';
import { index as postsIndex, create as createPost, edit as editPost, destroy as destroyPost } from '@/actions/App/Http/Controllers/PostController'; import { index as postsIndex, create as createPost, edit as editPost, destroy as destroyPost } from '@/actions/App/Http/Controllers/PostController';
import { type BreadcrumbItemType } from '@/types'; import { type BreadcrumbItemType } from '@/types';
@ -161,15 +160,8 @@ const handleDelete = (post: Post) => {
</p> </p>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<Link :href="calendar.url()">
<Button variant="outline">
<IconClock class="mr-2 h-4 w-4" />
Calendar
</Button>
</Link>
<Link :href="createPost.url()"> <Link :href="createPost.url()">
<Button> <Button>
<IconPlus class="mr-2 h-4 w-4" />
New Post New Post
</Button> </Button>
</Link> </Link>

View file

@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, useForm, router } from '@inertiajs/vue3'; import { Head, useForm, router } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { IconUserPlus, IconUsers, IconMail, IconTrash, IconCrown, IconUser, IconShield } from '@tabler/icons-vue'; import { IconUserPlus, IconUsers, IconMail, IconTrash, IconCrown, IconUser, IconShield } from '@tabler/icons-vue';
import { computed } from 'vue';
import HeadingSmall from '@/components/HeadingSmall.vue'; import HeadingSmall from '@/components/HeadingSmall.vue';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -49,9 +51,9 @@ interface Props {
defineProps<Props>(); defineProps<Props>();
const breadcrumbItems: BreadcrumbItem[] = [ const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{ title: 'Members', href: membersRoute.url() }, { title: trans('settings.members.title'), href: membersRoute.url() },
]; ]);
const form = useForm({ const form = useForm({
email: '', email: '',
@ -68,7 +70,7 @@ function submitInvite() {
} }
function cancelInvite(inviteId: string) { function cancelInvite(inviteId: string) {
if (confirm('Are you sure you want to cancel this invite?')) { if (confirm(trans('settings.members.invite.cancel_confirm'))) {
router.delete(destroyInvite.url(inviteId), { router.delete(destroyInvite.url(inviteId), {
preserveScroll: true, preserveScroll: true,
}); });
@ -76,7 +78,7 @@ function cancelInvite(inviteId: string) {
} }
function handleRemoveMember(memberId: string) { function handleRemoveMember(memberId: string) {
if (confirm('Are you sure you want to remove this member?')) { if (confirm(trans('settings.members.list.remove_confirm'))) {
router.delete(removeMember.url(memberId), { router.delete(removeMember.url(memberId), {
preserveScroll: true, preserveScroll: true,
}); });
@ -84,12 +86,7 @@ function handleRemoveMember(memberId: string) {
} }
function getRoleLabel(role: string): string { function getRoleLabel(role: string): string {
const labels: Record<string, string> = { return trans(`settings.members.roles.${role}`);
owner: 'Owner',
admin: 'Admin',
member: 'Member',
};
return labels[role] || role;
} }
function getRoleIcon(role: string) { function getRoleIcon(role: string) {
@ -100,36 +97,36 @@ function getRoleIcon(role: string) {
<template> <template>
<AppLayout :breadcrumbs="breadcrumbItems"> <AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Members" /> <Head :title="$t('settings.members.title')" />
<h1 class="sr-only">Members</h1> <h1 class="sr-only">{{ $t('settings.members.title') }}</h1>
<SettingsLayout> <SettingsLayout>
<div class="flex flex-col space-y-6"> <div class="flex flex-col space-y-6">
<HeadingSmall <HeadingSmall
title="Team members" :title="$t('settings.members.heading')"
description="Manage members and invites for this workspace" :description="$t('settings.members.description')"
/> />
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle class="flex items-center gap-2"> <CardTitle class="flex items-center gap-2">
<IconUserPlus class="h-5 w-5" /> <IconUserPlus class="h-5 w-5" />
Invite Member {{ $t('settings.members.invite.title') }}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Send an email invite to add collaborators {{ $t('settings.members.invite.description') }}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form @submit.prevent="submitInvite" class="space-y-4"> <form @submit.prevent="submitInvite" class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
<Label for="email">Email</Label> <Label for="email">{{ $t('settings.members.invite.email') }}</Label>
<Input <Input
id="email" id="email"
v-model="form.email" v-model="form.email"
type="email" type="email"
placeholder="collaborator@email.com" :placeholder="trans('settings.members.invite.email_placeholder')"
:class="{ 'border-red-500': form.errors.email }" :class="{ 'border-red-500': form.errors.email }"
/> />
<p v-if="form.errors.email" class="text-sm text-red-500"> <p v-if="form.errors.email" class="text-sm text-red-500">
@ -138,10 +135,10 @@ function getRoleIcon(role: string) {
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="role">Role</Label> <Label for="role">{{ $t('settings.members.invite.role') }}</Label>
<Select v-model="form.role"> <Select v-model="form.role">
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a role" /> <SelectValue :placeholder="trans('settings.members.invite.role_placeholder')" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem <SelectItem
@ -157,7 +154,7 @@ function getRoleIcon(role: string) {
<Button type="submit" :disabled="form.processing" class="w-full"> <Button type="submit" :disabled="form.processing" class="w-full">
<IconMail class="mr-2 h-4 w-4" /> <IconMail class="mr-2 h-4 w-4" />
Send Invite {{ $t('settings.members.invite.submit') }}
</Button> </Button>
</form> </form>
</CardContent> </CardContent>
@ -167,15 +164,15 @@ function getRoleIcon(role: string) {
<CardHeader> <CardHeader>
<CardTitle class="flex items-center gap-2"> <CardTitle class="flex items-center gap-2">
<IconMail class="h-5 w-5" /> <IconMail class="h-5 w-5" />
Pending Invites {{ $t('settings.members.pending.title') }}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Invites awaiting acceptance {{ $t('settings.members.pending.description') }}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div v-if="invites.length === 0" class="text-center py-6 text-muted-foreground"> <div v-if="invites.length === 0" class="text-center py-6 text-muted-foreground">
No pending invites {{ $t('settings.members.pending.empty') }}
</div> </div>
<div v-else class="space-y-3"> <div v-else class="space-y-3">
<div <div
@ -205,10 +202,10 @@ function getRoleIcon(role: string) {
<CardHeader> <CardHeader>
<CardTitle class="flex items-center gap-2"> <CardTitle class="flex items-center gap-2">
<IconUsers class="h-5 w-5" /> <IconUsers class="h-5 w-5" />
Members {{ $t('settings.members.list.title') }}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
People with access to this workspace {{ $t('settings.members.list.description') }}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -223,7 +220,7 @@ function getRoleIcon(role: string) {
<p class="text-sm text-muted-foreground">{{ owner.email }}</p> <p class="text-sm text-muted-foreground">{{ owner.email }}</p>
</div> </div>
</div> </div>
<Badge>Owner</Badge> <Badge>{{ $t('settings.members.roles.owner') }}</Badge>
</div> </div>
<div <div
@ -241,7 +238,7 @@ function getRoleIcon(role: string) {
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Badge variant="outline" class="capitalize">{{ member.role }}</Badge> <Badge variant="outline">{{ getRoleLabel(member.role) }}</Badge>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@ -253,7 +250,7 @@ function getRoleIcon(role: string) {
</div> </div>
<div v-if="members.length === 0" class="text-center py-6 text-muted-foreground"> <div v-if="members.length === 0" class="text-center py-6 text-muted-foreground">
No members besides the owner {{ $t('settings.members.list.empty') }}
</div> </div>
</div> </div>
</CardContent> </CardContent>

View file

@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3'; import { Form, Head } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import PasswordController from '@/actions/App/Http/Controllers/Settings/PasswordController'; import PasswordController from '@/actions/App/Http/Controllers/Settings/PasswordController';
import HeadingSmall from '@/components/HeadingSmall.vue'; import HeadingSmall from '@/components/HeadingSmall.vue';
@ -12,25 +14,25 @@ import SettingsLayout from '@/layouts/settings/Layout.vue';
import { edit } from '@/routes/user-password'; import { edit } from '@/routes/user-password';
import { type BreadcrumbItem } from '@/types'; import { type BreadcrumbItem } from '@/types';
const breadcrumbItems: BreadcrumbItem[] = [ const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{ {
title: 'Password settings', title: trans('settings.password.title'),
href: edit().url, href: edit().url,
}, },
]; ]);
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbItems"> <AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Password settings" /> <Head :title="$t('settings.password.title')" />
<h1 class="sr-only">Password Settings</h1> <h1 class="sr-only">{{ $t('settings.password.title') }}</h1>
<SettingsLayout> <SettingsLayout>
<div class="space-y-6"> <div class="space-y-6">
<HeadingSmall <HeadingSmall
title="Update password" :title="$t('settings.password.heading')"
description="Ensure your account is using a long, random password to stay secure" :description="$t('settings.password.description')"
/> />
<Form <Form
@ -48,37 +50,37 @@ const breadcrumbItems: BreadcrumbItem[] = [
v-slot="{ errors, processing }" v-slot="{ errors, processing }"
> >
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="current_password">Current password</Label> <Label for="current_password">{{ $t('settings.password.current_password') }}</Label>
<Input <Input
id="current_password" id="current_password"
name="current_password" name="current_password"
type="password" type="password"
autocomplete="current-password" autocomplete="current-password"
placeholder="Current password" :placeholder="trans('settings.password.current_password_placeholder')"
/> />
<InputError :message="errors.current_password" /> <InputError :message="errors.current_password" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="password">New password</Label> <Label for="password">{{ $t('settings.password.new_password') }}</Label>
<Input <Input
id="password" id="password"
name="password" name="password"
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
placeholder="New password" :placeholder="trans('settings.password.new_password_placeholder')"
/> />
<InputError :message="errors.password" /> <InputError :message="errors.password" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="password_confirmation">Confirm password</Label> <Label for="password_confirmation">{{ $t('settings.password.confirm_password') }}</Label>
<Input <Input
id="password_confirmation" id="password_confirmation"
name="password_confirmation" name="password_confirmation"
type="password" type="password"
autocomplete="new-password" autocomplete="new-password"
placeholder="Confirm password" :placeholder="trans('settings.password.confirm_password_placeholder')"
/> />
<InputError :message="errors.password_confirmation" /> <InputError :message="errors.password_confirmation" />
</div> </div>
@ -87,7 +89,7 @@ const breadcrumbItems: BreadcrumbItem[] = [
:disabled="processing" :disabled="processing"
data-test="update-password-button" data-test="update-password-button"
> >
Save password {{ $t('settings.password.save') }}
</Button> </Button>
</Form> </Form>
</div> </div>

View file

@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Form, Head, Link, usePage } from '@inertiajs/vue3'; import { Form, Head, Link, usePage } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController'; import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
import DeleteUser from '@/components/DeleteUser.vue'; import DeleteUser from '@/components/DeleteUser.vue';
@ -25,30 +27,30 @@ defineProps<Props>();
const page = usePage(); const page = usePage();
const user = page.props.auth.user; const user = page.props.auth.user;
const breadcrumbItems: BreadcrumbItem[] = [ const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{ {
title: 'Profile settings', title: trans('settings.profile.title'),
href: edit().url, href: edit().url,
}, },
]; ]);
</script> </script>
<template> <template>
<AppLayout :breadcrumbs="breadcrumbItems"> <AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Profile settings" /> <Head :title="$t('settings.profile.title')" />
<h1 class="sr-only">Profile Settings</h1> <h1 class="sr-only">{{ $t('settings.profile.title') }}</h1>
<SettingsLayout> <SettingsLayout>
<div class="space-y-10"> <div class="space-y-10">
<div class="space-y-6"> <div class="space-y-6">
<HeadingSmall <HeadingSmall
title="Profile information" :title="$t('settings.profile.heading')"
description="Update your name and email address" :description="$t('settings.profile.description')"
/> />
<div class="grid gap-2"> <div class="grid gap-2">
<Label>Avatar</Label> <Label>{{ $t('settings.profile.avatar') }}</Label>
<PhotoUpload <PhotoUpload
:model-id="user.id" :model-id="user.id"
model-type="App\Models\User" model-type="App\Models\User"
@ -65,20 +67,20 @@ const breadcrumbItems: BreadcrumbItem[] = [
v-slot="{ errors, processing }" v-slot="{ errors, processing }"
> >
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="name">Name</Label> <Label for="name">{{ $t('settings.profile.name') }}</Label>
<Input <Input
id="name" id="name"
name="name" name="name"
:default-value="user.name" :default-value="user.name"
required required
autocomplete="name" autocomplete="name"
placeholder="Full name" :placeholder="trans('settings.profile.name_placeholder')"
/> />
<InputError :message="errors.name" /> <InputError :message="errors.name" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="email">Email address</Label> <Label for="email">{{ $t('settings.profile.email') }}</Label>
<Input <Input
id="email" id="email"
type="email" type="email"
@ -86,20 +88,20 @@ const breadcrumbItems: BreadcrumbItem[] = [
:default-value="user.email" :default-value="user.email"
required required
autocomplete="username" autocomplete="username"
placeholder="Email address" :placeholder="trans('settings.profile.email_placeholder')"
/> />
<InputError :message="errors.email" /> <InputError :message="errors.email" />
</div> </div>
<div v-if="mustVerifyEmail && !user.email_verified_at"> <div v-if="mustVerifyEmail && !user.email_verified_at">
<p class="-mt-4 text-sm text-muted-foreground"> <p class="-mt-4 text-sm text-muted-foreground">
Your email address is unverified. {{ $t('settings.profile.email_unverified') }}
<Link <Link
:href="send()" :href="send()"
as="button" as="button"
class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500" class="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
> >
Click here to resend the verification email. {{ $t('settings.profile.resend_verification') }}
</Link> </Link>
</p> </p>
@ -107,8 +109,7 @@ const breadcrumbItems: BreadcrumbItem[] = [
v-if="status === 'verification-link-sent'" v-if="status === 'verification-link-sent'"
class="mt-2 text-sm font-medium text-green-600" class="mt-2 text-sm font-medium text-green-600"
> >
A new verification link has been sent to your email {{ $t('settings.profile.verification_sent') }}
address.
</div> </div>
</div> </div>
@ -116,7 +117,7 @@ const breadcrumbItems: BreadcrumbItem[] = [
:disabled="processing" :disabled="processing"
data-test="update-profile-button" data-test="update-profile-button"
> >
Save {{ $t('settings.profile.save') }}
</Button> </Button>
</Form> </Form>
</div> </div>

View file

@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Head, useForm } from '@inertiajs/vue3'; import { Head, useForm } from '@inertiajs/vue3';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import HeadingSmall from '@/components/HeadingSmall.vue'; import HeadingSmall from '@/components/HeadingSmall.vue';
import InputError from '@/components/InputError.vue'; import InputError from '@/components/InputError.vue';
@ -31,12 +33,12 @@ interface Props {
const props = defineProps<Props>(); const props = defineProps<Props>();
const breadcrumbItems: BreadcrumbItem[] = [ const breadcrumbItems = computed<BreadcrumbItem[]>(() => [
{ {
title: 'Workspace settings', title: trans('settings.workspace.title'),
href: settings().url, href: settings().url,
}, },
]; ]);
const form = useForm({ const form = useForm({
name: props.workspace.name, name: props.workspace.name,
@ -50,19 +52,19 @@ const submit = () => {
<template> <template>
<AppLayout :breadcrumbs="breadcrumbItems"> <AppLayout :breadcrumbs="breadcrumbItems">
<Head title="Workspace settings" /> <Head :title="$t('settings.workspace.title')" />
<h1 class="sr-only">Workspace Settings</h1> <h1 class="sr-only">{{ $t('settings.workspace.title') }}</h1>
<SettingsLayout> <SettingsLayout>
<div class="flex flex-col space-y-6"> <div class="flex flex-col space-y-6">
<HeadingSmall <HeadingSmall
title="Workspace settings" :title="$t('settings.workspace.heading')"
description="Update your workspace name, logo, and timezone" :description="$t('settings.workspace.description')"
/> />
<div class="grid gap-2"> <div class="grid gap-2">
<Label>Logo</Label> <Label>{{ $t('settings.workspace.logo') }}</Label>
<PhotoUpload <PhotoUpload
:model-id="workspace.id" :model-id="workspace.id"
model-type="App\Models\Workspace" model-type="App\Models\Workspace"
@ -75,19 +77,19 @@ const submit = () => {
<form @submit.prevent="submit" class="space-y-6"> <form @submit.prevent="submit" class="space-y-6">
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="name">Name</Label> <Label for="name">{{ $t('settings.workspace.name') }}</Label>
<Input <Input
id="name" id="name"
v-model="form.name" v-model="form.name"
class="mt-1 block w-full" class="mt-1 block w-full"
required required
placeholder="My Workspace" :placeholder="trans('settings.workspace.name_placeholder')"
/> />
<InputError class="mt-2" :message="form.errors.name" /> <InputError class="mt-2" :message="form.errors.name" />
</div> </div>
<div class="grid gap-2"> <div class="grid gap-2">
<Label for="timezone">Timezone</Label> <Label for="timezone">{{ $t('settings.workspace.timezone') }}</Label>
<TimezoneCombobox <TimezoneCombobox
v-model="form.timezone" v-model="form.timezone"
:timezones="timezones" :timezones="timezones"
@ -96,7 +98,7 @@ const submit = () => {
</div> </div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<Button :disabled="form.processing">Save</Button> <Button :disabled="form.processing">{{ $t('settings.workspace.save') }}</Button>
<Transition <Transition
enter-active-class="transition ease-in-out" enter-active-class="transition ease-in-out"
@ -108,7 +110,7 @@ const submit = () => {
v-show="form.recentlySuccessful" v-show="form.recentlySuccessful"
class="text-sm text-neutral-600" class="text-sm text-neutral-600"
> >
Saved. {{ $t('settings.workspace.saved') }}
</p> </p>
</Transition> </Transition>
</div> </div>

View file

@ -14,9 +14,9 @@
test('language has fillable attributes', function () { test('language has fillable attributes', function () {
$language = Language::factory()->create([ $language = Language::factory()->create([
'name' => 'Portuguese', 'name' => 'Portuguese',
'code' => 'pt-BR', 'code' => 'pt-br',
]); ]);
expect($language->name)->toBe('Portuguese'); expect($language->name)->toBe('Portuguese');
expect($language->code)->toBe('pt-BR'); expect($language->code)->toBe('pt-br');
}); });