feat: implement paginated social accounts table with search functionality and updated UI components
This commit is contained in:
parent
f4fd8a2aab
commit
17ca1f7b0e
13 changed files with 326 additions and 164 deletions
|
|
@ -26,6 +26,7 @@ class LinkedInController extends SocialController
|
|||
'openid',
|
||||
'profile',
|
||||
'email',
|
||||
'r_basicprofile',
|
||||
'w_member_social',
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
use App\Enums\SocialAccount\Status;
|
||||
use App\Features\SocialAccountLimit;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\App\SocialAccountResource;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
|
@ -56,9 +57,17 @@ public function index(Request $request): Response|RedirectResponse
|
|||
|
||||
$this->authorize('view', $workspace);
|
||||
|
||||
$connectedAccounts = $workspace->socialAccounts()
|
||||
$accounts = $workspace->socialAccounts()
|
||||
->when(
|
||||
$request->input('search'),
|
||||
fn ($query, $search) => $query->where(function ($q) use ($search): void {
|
||||
$q->where('display_name', 'ilike', "%{$search}%")
|
||||
->orWhere('username', 'ilike', "%{$search}%")
|
||||
->orWhere('platform', 'ilike', "%{$search}%");
|
||||
}),
|
||||
)
|
||||
->orderBy('id')
|
||||
->get();
|
||||
->paginate(config('app.pagination.default'));
|
||||
|
||||
$platforms = collect(SocialPlatform::enabled())->map(fn ($platform) => [
|
||||
'value' => $platform->value,
|
||||
|
|
@ -68,8 +77,11 @@ public function index(Request $request): Response|RedirectResponse
|
|||
|
||||
return Inertia::render('accounts/Index', [
|
||||
'workspace' => $workspace,
|
||||
'accounts' => $connectedAccounts,
|
||||
'accounts' => Inertia::scroll(fn () => SocialAccountResource::collection($accounts)),
|
||||
'platforms' => $platforms,
|
||||
'filters' => [
|
||||
'search' => $request->input('search', ''),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
32
app/Http/Resources/App/SocialAccountResource.php
Normal file
32
app/Http/Resources/App/SocialAccountResource.php
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\App;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class SocialAccountResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'platform' => $this->platform,
|
||||
'platform_user_id' => $this->platform_user_id,
|
||||
'username' => $this->username,
|
||||
'display_name' => $this->display_name,
|
||||
'avatar_url' => $this->avatar_url,
|
||||
'profile_url' => $this->profile_url,
|
||||
'status' => $this->status,
|
||||
'is_active' => $this->is_active,
|
||||
'error_message' => $this->error_message,
|
||||
'last_used_at' => $this->last_used_at,
|
||||
'created_at' => $this->created_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -95,12 +95,16 @@ public function markAsPublishing(): void
|
|||
|
||||
public function markAsPublished(string $platformPostId, ?string $platformUrl = null): void
|
||||
{
|
||||
$now = now();
|
||||
|
||||
$this->update([
|
||||
'status' => Status::Published,
|
||||
'platform_post_id' => $platformPostId,
|
||||
'platform_url' => $platformUrl,
|
||||
'published_at' => now(),
|
||||
'published_at' => $now,
|
||||
]);
|
||||
|
||||
$this->socialAccount?->update(['last_used_at' => $now]);
|
||||
}
|
||||
|
||||
public function markAsFailed(string $errorMessage, ?array $errorContext = null): void
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ class SocialAccount extends Model
|
|||
'is_active',
|
||||
'error_message',
|
||||
'disconnected_at',
|
||||
'last_used_at',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
|
|
@ -59,6 +60,7 @@ protected function casts(): array
|
|||
'refresh_token' => 'encrypted',
|
||||
'token_expires_at' => 'datetime',
|
||||
'disconnected_at' => 'datetime',
|
||||
'last_used_at' => 'datetime',
|
||||
'scopes' => 'array',
|
||||
'meta' => 'array',
|
||||
];
|
||||
|
|
@ -95,6 +97,37 @@ protected function avatarUrl(): Attribute
|
|||
);
|
||||
}
|
||||
|
||||
protected function profileUrl(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function (): ?string {
|
||||
$username = $this->username;
|
||||
$platformUserId = $this->platform_user_id;
|
||||
|
||||
return match ($this->platform) {
|
||||
SocialPlatform::Facebook => ($username || $platformUserId)
|
||||
? 'https://facebook.com/'.($username ?: $platformUserId)
|
||||
: null,
|
||||
SocialPlatform::LinkedIn => $username ? "https://linkedin.com/in/{$username}" : null,
|
||||
SocialPlatform::LinkedInPage => $username ? "https://linkedin.com/company/{$username}" : null,
|
||||
SocialPlatform::X => $username ? "https://x.com/{$username}" : null,
|
||||
SocialPlatform::TikTok => $username ? "https://tiktok.com/@{$username}" : null,
|
||||
SocialPlatform::Instagram, SocialPlatform::InstagramFacebook => $username
|
||||
? "https://instagram.com/{$username}"
|
||||
: null,
|
||||
SocialPlatform::YouTube => $username ? "https://youtube.com/@{$username}" : null,
|
||||
SocialPlatform::Threads => $username ? "https://threads.net/@{$username}" : null,
|
||||
SocialPlatform::Bluesky => $username ? "https://bsky.app/profile/{$username}" : null,
|
||||
SocialPlatform::Pinterest => $username ? "https://pinterest.com/{$username}" : null,
|
||||
SocialPlatform::Mastodon => ($username && data_get($this->meta, 'instance'))
|
||||
? rtrim((string) data_get($this->meta, 'instance'), '/')."/@{$username}"
|
||||
: null,
|
||||
default => null,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public function markAsDisconnected(string $errorMessage): void
|
||||
{
|
||||
$lock = Cache::lock("social_account_disconnect:{$this->id}", 10);
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ public function up(): void
|
|||
$table->string('status');
|
||||
$table->text('error_message')->nullable();
|
||||
$table->timestamp('disconnected_at')->nullable();
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->foreign('workspace_id')->references('id')->on('workspaces')->cascadeOnDelete();
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@
|
|||
'add_social_description' => 'Connect a social account to TryPost to start posting',
|
||||
'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',
|
||||
'try_different_search' => 'Try a different keyword or clear the search.',
|
||||
'search' => 'Search accounts...',
|
||||
'added' => 'Added :date',
|
||||
|
||||
'limit_reached' => 'You have reached your plan limit for social accounts.',
|
||||
|
|
@ -20,6 +23,20 @@
|
|||
'view_profile' => 'View profile',
|
||||
'disconnect' => 'Disconnect',
|
||||
|
||||
'table' => [
|
||||
'account' => 'Account',
|
||||
'platform' => 'Platform',
|
||||
'status' => 'Status',
|
||||
'last_used' => 'Last used',
|
||||
'added' => 'Added',
|
||||
'active' => 'Active',
|
||||
],
|
||||
'never_used' => 'Never used',
|
||||
'status' => [
|
||||
'connected' => 'Connected',
|
||||
'disconnected' => 'Disconnected',
|
||||
],
|
||||
|
||||
'descriptions' => [
|
||||
'linkedin' => 'Connect your LinkedIn personal profile',
|
||||
'linkedin-page' => 'Connect a LinkedIn company page',
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@
|
|||
'add_social_description' => 'Conecta una cuenta social a TryPost para empezar a publicar',
|
||||
'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',
|
||||
'try_different_search' => 'Prueba otra palabra clave o limpia la búsqueda.',
|
||||
'search' => 'Buscar cuentas...',
|
||||
'added' => 'Agregada :date',
|
||||
|
||||
'limit_reached' => 'Has alcanzado el límite de cuentas sociales de tu plan.',
|
||||
|
|
@ -20,6 +23,20 @@
|
|||
'view_profile' => 'Ver perfil',
|
||||
'disconnect' => 'Desconectar',
|
||||
|
||||
'table' => [
|
||||
'account' => 'Cuenta',
|
||||
'platform' => 'Plataforma',
|
||||
'status' => 'Estado',
|
||||
'last_used' => 'Último uso',
|
||||
'added' => 'Añadida',
|
||||
'active' => 'Activa',
|
||||
],
|
||||
'never_used' => 'Nunca usada',
|
||||
'status' => [
|
||||
'connected' => 'Conectada',
|
||||
'disconnected' => 'Desconectada',
|
||||
],
|
||||
|
||||
'descriptions' => [
|
||||
'linkedin' => 'Conecta tu perfil personal de LinkedIn',
|
||||
'linkedin-page' => 'Conecta una página de empresa de LinkedIn',
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -9,6 +9,9 @@
|
|||
'add_social_description' => 'Conecte uma conta social ao TryPost para começar a publicar',
|
||||
'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',
|
||||
'try_different_search' => 'Tente outra palavra-chave ou limpe a busca.',
|
||||
'search' => 'Buscar contas...',
|
||||
'added' => 'Adicionada :date',
|
||||
|
||||
'limit_reached' => 'Você atingiu o limite de contas sociais do seu plano.',
|
||||
|
|
@ -20,6 +23,20 @@
|
|||
'view_profile' => 'Ver perfil',
|
||||
'disconnect' => 'Desconectar',
|
||||
|
||||
'table' => [
|
||||
'account' => 'Conta',
|
||||
'platform' => 'Plataforma',
|
||||
'status' => 'Status',
|
||||
'last_used' => 'Último uso',
|
||||
'added' => 'Adicionada',
|
||||
'active' => 'Ativa',
|
||||
],
|
||||
'never_used' => 'Nunca usada',
|
||||
'status' => [
|
||||
'connected' => 'Conectada',
|
||||
'disconnected' => 'Desconectada',
|
||||
],
|
||||
|
||||
'descriptions' => [
|
||||
'linkedin' => 'Conecte seu perfil pessoal do LinkedIn',
|
||||
'linkedin-page' => 'Conecte uma página de empresa do LinkedIn',
|
||||
|
|
|
|||
|
|
@ -1,25 +1,50 @@
|
|||
<script setup lang="ts">
|
||||
import { Head, router } from '@inertiajs/vue3';
|
||||
import { IconAffiliate, IconAlertCircle, IconCheck, IconExternalLink, IconPlus, IconRefresh, IconTrash } from '@tabler/icons-vue';
|
||||
import { Head, InfiniteScroll, router } from '@inertiajs/vue3';
|
||||
import {
|
||||
IconAffiliate,
|
||||
IconAlertCircle,
|
||||
IconDots,
|
||||
IconExternalLink,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-vue';
|
||||
import { trans } from 'laravel-vue-i18n';
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import { index as accountsIndex } from '@/actions/App/Http/Controllers/Auth/SocialController';
|
||||
import AddSocialDialog, { type AvailablePlatform } from '@/components/accounts/AddSocialDialog.vue';
|
||||
import ConfirmDeleteModal from '@/components/ConfirmDeleteModal.vue';
|
||||
import EmptyState from '@/components/EmptyState.vue';
|
||||
import PageHeader from '@/components/PageHeader.vue';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableLoadMore,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
|
||||
import date from '@/date';
|
||||
import debounce from '@/debounce';
|
||||
import AppLayout from '@/layouts/AppLayout.vue';
|
||||
import { disconnect as disconnectAccount, toggle as toggleAccount } from '@/routes/app/accounts';
|
||||
import type { BreadcrumbItem } from '@/types';
|
||||
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [{ title: trans('accounts.page_title') }]);
|
||||
interface SocialAccount {
|
||||
id: string;
|
||||
platform: string;
|
||||
|
|
@ -27,73 +52,63 @@ interface SocialAccount {
|
|||
username: string;
|
||||
display_name: string;
|
||||
avatar_url: string;
|
||||
profile_url: string | null;
|
||||
status: 'connected' | 'disconnected' | 'token_expired' | null;
|
||||
is_active: boolean;
|
||||
error_message: string | null;
|
||||
last_used_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ScrollAccounts {
|
||||
data: SocialAccount[];
|
||||
meta: { hasNextPage: boolean };
|
||||
}
|
||||
|
||||
interface Props {
|
||||
accounts: SocialAccount[];
|
||||
accounts: ScrollAccounts;
|
||||
platforms: AvailablePlatform[];
|
||||
filters: { search: string };
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const breadcrumbs = computed<BreadcrumbItem[]>(() => [{ title: trans('accounts.page_title') }]);
|
||||
|
||||
const isAddDialogOpen = ref(false);
|
||||
const deleteModal = ref<InstanceType<typeof ConfirmDeleteModal> | null>(null);
|
||||
const searchQuery = ref(props.filters.search);
|
||||
|
||||
const getProfileUrl = (platform: string, username: string | null, platformUserId: string | null = null): string | null => {
|
||||
if (platform === 'facebook') {
|
||||
const identifier = username || platformUserId;
|
||||
return identifier ? `https://facebook.com/${identifier}` : null;
|
||||
}
|
||||
const search = debounce(() => {
|
||||
router.get(
|
||||
accountsIndex.url(),
|
||||
{ search: searchQuery.value || undefined },
|
||||
{ preserveState: true, preserveScroll: true, reset: ['accounts'] },
|
||||
);
|
||||
}, 300);
|
||||
|
||||
if (!username) return null;
|
||||
watch(searchQuery, () => search());
|
||||
|
||||
const urls: Record<string, string> = {
|
||||
'linkedin': `https://linkedin.com/in/${username}`,
|
||||
'linkedin-page': `https://linkedin.com/company/${username}`,
|
||||
'x': `https://x.com/${username}`,
|
||||
'tiktok': `https://tiktok.com/@${username}`,
|
||||
'instagram': `https://instagram.com/${username}`,
|
||||
'instagram-facebook': `https://instagram.com/${username}`,
|
||||
'youtube': `https://youtube.com/@${username}`,
|
||||
'threads': `https://threads.net/@${username}`,
|
||||
'bluesky': `https://bsky.app/profile/${username}`,
|
||||
'pinterest': `https://pinterest.com/${username}`,
|
||||
};
|
||||
return urls[platform] || null;
|
||||
};
|
||||
const hasActiveSearch = computed(() => Boolean(searchQuery.value?.trim()));
|
||||
|
||||
const isDisconnected = (account: SocialAccount): boolean => {
|
||||
return account.status === 'disconnected' || account.status === 'token_expired';
|
||||
};
|
||||
const isDisconnected = (account: SocialAccount): boolean =>
|
||||
account.status === 'disconnected' || account.status === 'token_expired';
|
||||
|
||||
const openOAuthPopup = (platformValue: string) => {
|
||||
const url = `/connect/${platformValue}`;
|
||||
const width = 600;
|
||||
const height = 700;
|
||||
const left = window.screenX + (window.outerWidth - width) / 2;
|
||||
const top = window.screenY + (window.outerHeight - height) / 2;
|
||||
|
||||
window.open(
|
||||
url,
|
||||
'oauth-popup',
|
||||
`width=${width},height=${height},left=${left},top=${top},scrollbars=yes,resizable=yes`,
|
||||
);
|
||||
const w = 600;
|
||||
const h = 700;
|
||||
const left = window.screenX + (window.outerWidth - w) / 2;
|
||||
const top = window.screenY + (window.outerHeight - h) / 2;
|
||||
window.open(url, 'oauth-popup', `width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`);
|
||||
};
|
||||
|
||||
const handleToggle = (accountId: string) => {
|
||||
router.put(toggleAccount.url(accountId), {}, {
|
||||
preserveScroll: true,
|
||||
});
|
||||
router.put(toggleAccount.url(accountId), {}, { preserveScroll: true });
|
||||
};
|
||||
|
||||
const handleDisconnect = (accountId: string) => {
|
||||
deleteModal.value?.open({
|
||||
url: disconnectAccount.url(accountId),
|
||||
});
|
||||
deleteModal.value?.open({ url: disconnectAccount.url(accountId) });
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
@ -104,131 +119,144 @@ const handleDisconnect = (accountId: string) => {
|
|||
<div class="flex h-full flex-1 flex-col gap-4 p-4">
|
||||
<PageHeader :title="$t('accounts.page_title')" />
|
||||
|
||||
<div class="flex items-center justify-end gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="relative">
|
||||
<IconSearch class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('accounts.search')"
|
||||
class="w-64 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button @click="isAddDialogOpen = true">
|
||||
{{ $t('accounts.add_social') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-if="props.accounts.length === 0"
|
||||
v-if="accounts.data.length === 0"
|
||||
:icon="IconAffiliate"
|
||||
:title="$t('accounts.no_accounts')"
|
||||
:description="$t('accounts.no_accounts_description')"
|
||||
:title="hasActiveSearch ? $t('accounts.no_search_results') : $t('accounts.no_accounts')"
|
||||
:description="hasActiveSearch ? $t('accounts.try_different_search') : $t('accounts.no_accounts_description')"
|
||||
/>
|
||||
|
||||
<div v-else class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<Card
|
||||
v-for="account in props.accounts"
|
||||
:key="account.id"
|
||||
class="overflow-hidden"
|
||||
:class="{
|
||||
'border-red-500/30': isDisconnected(account),
|
||||
}"
|
||||
>
|
||||
<CardContent class="p-4">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<!-- Avatar with platform badge -->
|
||||
<div class="relative mb-3">
|
||||
<Avatar class="h-16 w-16">
|
||||
<AvatarImage v-if="account.avatar_url" :src="account.avatar_url" />
|
||||
<AvatarFallback class="text-lg">
|
||||
{{ account.display_name?.charAt(0) }}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<img
|
||||
:src="getPlatformLogo(account.platform)"
|
||||
:alt="account.platform"
|
||||
class="absolute -bottom-1 -right-1 h-6 w-6 rounded-full border-2 border-background object-contain"
|
||||
/>
|
||||
<div
|
||||
v-if="!isDisconnected(account)"
|
||||
class="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-green-500 text-white ring-2 ring-background"
|
||||
>
|
||||
<IconCheck class="h-2 w-2" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="absolute -top-0.5 -right-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-white ring-2 ring-background"
|
||||
>
|
||||
<IconAlertCircle class="h-2 w-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="rounded-md border">
|
||||
<InfiniteScroll data="accounts" items-element="#accounts-body" preserve-url>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{{ $t('accounts.table.account') }}</TableHead>
|
||||
<TableHead>{{ $t('accounts.table.platform') }}</TableHead>
|
||||
<TableHead>{{ $t('accounts.table.status') }}</TableHead>
|
||||
<TableHead>{{ $t('accounts.table.last_used') }}</TableHead>
|
||||
<TableHead>{{ $t('accounts.table.added') }}</TableHead>
|
||||
<TableHead class="text-right">{{ $t('accounts.table.active') }}</TableHead>
|
||||
<TableHead class="w-10" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody id="accounts-body">
|
||||
<TableRow v-for="account in accounts.data" :key="account.id">
|
||||
<TableCell>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative shrink-0">
|
||||
<Avatar class="size-9">
|
||||
<AvatarImage v-if="account.avatar_url" :src="account.avatar_url" />
|
||||
<AvatarFallback>{{ account.display_name?.charAt(0) }}</AvatarFallback>
|
||||
</Avatar>
|
||||
<img
|
||||
:src="getPlatformLogo(account.platform)"
|
||||
:alt="account.platform"
|
||||
class="absolute -bottom-0.5 -right-0.5 size-4 rounded-full bg-background object-contain ring-2 ring-card"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<a
|
||||
v-if="account.profile_url"
|
||||
:href="account.profile_url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 truncate text-sm font-medium hover:underline"
|
||||
>
|
||||
{{ account.display_name }}
|
||||
<IconExternalLink class="size-3.5" />
|
||||
</a>
|
||||
<p v-else class="truncate text-sm font-medium">{{ account.display_name }}</p>
|
||||
<p class="truncate text-xs text-muted-foreground">
|
||||
@{{ account.username || account.display_name }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<!-- Name and username -->
|
||||
<p class="font-semibold truncate max-w-full">{{ account.display_name }}</p>
|
||||
<p class="text-sm text-muted-foreground truncate max-w-full">
|
||||
@{{ account.username || account.display_name }}
|
||||
</p>
|
||||
<TableCell class="text-sm text-muted-foreground">
|
||||
{{ getPlatformLabel(account.platform) }}
|
||||
</TableCell>
|
||||
|
||||
<!-- Added date -->
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
{{ $t('accounts.added', { date: date.diffForHumans(account.created_at) }) }}
|
||||
</p>
|
||||
<TableCell>
|
||||
<Badge
|
||||
v-if="!isDisconnected(account)"
|
||||
class="bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-300"
|
||||
>
|
||||
{{ $t('accounts.status.connected') }}
|
||||
</Badge>
|
||||
<Badge v-else variant="destructive" class="gap-1">
|
||||
<IconAlertCircle class="size-3" />
|
||||
{{ $t('accounts.status.disconnected') }}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
<!-- Disconnected warning -->
|
||||
<div v-if="isDisconnected(account)" class="mt-3 w-full rounded-md bg-red-100 px-2 py-1.5 text-xs text-red-700 dark:bg-red-900/30 dark:text-red-400">
|
||||
{{ $t('accounts.connection_lost') }}
|
||||
</div>
|
||||
<TableCell class="text-sm text-muted-foreground">
|
||||
<span v-if="account.last_used_at">{{ date.diffForHumans(account.last_used_at) }}</span>
|
||||
<span v-else>{{ $t('accounts.never_used') }}</span>
|
||||
</TableCell>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="mt-3 flex items-center gap-1">
|
||||
<TooltipProvider v-if="isDisconnected(account)">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 text-amber-600 hover:text-amber-700" @click="openOAuthPopup(account.platform)">
|
||||
<IconRefresh class="h-4 w-4" />
|
||||
<TableCell class="text-sm text-muted-foreground">
|
||||
{{ date.diffForHumans(account.created_at) }}
|
||||
</TableCell>
|
||||
|
||||
<TableCell class="text-right">
|
||||
<Switch
|
||||
:model-value="account.is_active"
|
||||
:disabled="isDisconnected(account)"
|
||||
@update:model-value="handleToggle(account.id)"
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell class="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="size-8">
|
||||
<IconDots class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ $t('accounts.reconnect_account') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
v-if="isDisconnected(account)"
|
||||
@click="openOAuthPopup(account.platform)"
|
||||
>
|
||||
<IconRefresh class="size-4" />
|
||||
{{ $t('accounts.reconnect_account') }}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator v-if="isDisconnected(account)" />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
@click="handleDisconnect(account.id)"
|
||||
>
|
||||
<IconTrash class="size-4" />
|
||||
{{ $t('accounts.disconnect') }}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<TooltipProvider v-if="getProfileUrl(account.platform, account.username, account.platform_user_id)">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8" as-child>
|
||||
<a :href="getProfileUrl(account.platform, account.username, account.platform_user_id)!" target="_blank">
|
||||
<IconExternalLink class="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ $t('accounts.view_profile') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<Switch :model-value="account.is_active" @update:model-value="handleToggle(account.id)" />
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<Button variant="ghost" size="icon" class="h-8 w-8 text-destructive hover:text-destructive" @click="handleDisconnect(account.id)">
|
||||
<IconTrash class="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{{ $t('accounts.disconnect') }}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Add Social Card -->
|
||||
<Card class="cursor-pointer overflow-hidden border-dashed transition-all hover:border-primary hover:shadow-md" @click="isAddDialogOpen = true">
|
||||
<CardContent class="flex h-full flex-col items-center justify-center p-4">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-muted mb-3">
|
||||
<IconPlus class="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p class="font-semibold">{{ $t('accounts.add_social') }}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<template #next="{ loading }">
|
||||
<TableLoadMore v-if="loading" />
|
||||
</template>
|
||||
</InfiniteScroll>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
|
|
|
|||
Loading…
Reference in a new issue