Add Telegram connection UI (connect dialog + code polling)

This commit is contained in:
Paulo Castellano 2026-06-13 21:58:49 -03:00
parent a4cf8aa4ce
commit 79acdccc45
6 changed files with 434 additions and 67 deletions

View file

@ -51,6 +51,7 @@
'pinterest' => 'Connect your Pinterest account',
'bluesky' => 'Connect your Bluesky account',
'mastodon' => 'Connect your Mastodon account',
'telegram' => 'Connect a Telegram channel or group',
],
'disconnect_modal' => [
@ -82,6 +83,22 @@
'submitting' => 'Connecting...',
],
'telegram' => [
'title' => 'Connect Telegram',
'description' => 'Link a channel or group',
'step_admin' => 'Add :bot as an administrator to your Telegram channel or group.',
'step_command' => 'Post this command in the channel or group:',
'waiting' => 'Waiting for the channel to connect…',
'connected' => 'Channel connected!',
'connected_toast' => 'Telegram channel connected successfully!',
'copied_toast' => 'Command copied to clipboard',
'expired' => 'This code has expired. Generate a new one to try again.',
'new_code' => 'Generate a new code',
'retry' => 'Try again',
'error_generic' => 'Could not start the connection. Please try again.',
'close' => 'Close',
],
'facebook' => [
'title' => 'Select Facebook Page',
'description' => 'Choose which page you want to connect',

View file

@ -51,6 +51,7 @@
'pinterest' => 'Conecta tu cuenta de Pinterest',
'bluesky' => 'Conecta tu cuenta de Bluesky',
'mastodon' => 'Conecta tu cuenta de Mastodon',
'telegram' => 'Conecta un canal o grupo de Telegram',
],
'disconnect_modal' => [
@ -82,6 +83,22 @@
'submitting' => 'Conectando...',
],
'telegram' => [
'title' => 'Conectar Telegram',
'description' => 'Vincula un canal o grupo',
'step_admin' => 'Añade :bot como administrador de tu canal o grupo de Telegram.',
'step_command' => 'Publica este comando en el canal o grupo:',
'waiting' => 'Esperando a que el canal se conecte…',
'connected' => '¡Canal conectado!',
'connected_toast' => '¡Canal de Telegram conectado correctamente!',
'copied_toast' => 'Comando copiado al portapapeles',
'expired' => 'Este código ha caducado. Genera uno nuevo para volver a intentarlo.',
'new_code' => 'Generar un nuevo código',
'retry' => 'Reintentar',
'error_generic' => 'No se pudo iniciar la conexión. Inténtalo de nuevo.',
'close' => 'Cerrar',
],
'facebook' => [
'title' => 'Seleccionar página de Facebook',
'description' => 'Elige qué página deseas conectar',

View file

@ -51,6 +51,7 @@
'pinterest' => 'Conecte sua conta do Pinterest',
'bluesky' => 'Conecte sua conta do Bluesky',
'mastodon' => 'Conecte sua conta do Mastodon',
'telegram' => 'Conecte um canal ou grupo do Telegram',
],
'disconnect_modal' => [
@ -82,6 +83,22 @@
'submitting' => 'Conectando...',
],
'telegram' => [
'title' => 'Conectar Telegram',
'description' => 'Vincule um canal ou grupo',
'step_admin' => 'Adicione :bot como administrador do seu canal ou grupo do Telegram.',
'step_command' => 'Publique este comando no canal ou grupo:',
'waiting' => 'Aguardando o canal conectar…',
'connected' => 'Canal conectado!',
'connected_toast' => 'Canal do Telegram conectado com sucesso!',
'copied_toast' => 'Comando copiado para a área de transferência',
'expired' => 'Este código expirou. Gere um novo para tentar de novo.',
'new_code' => 'Gerar um novo código',
'retry' => 'Tentar novamente',
'error_generic' => 'Não foi possível iniciar a conexão. Tente novamente.',
'close' => 'Fechar',
],
'facebook' => [
'title' => 'Selecionar Página do Facebook',
'description' => 'Escolha qual página você deseja conectar',

View file

@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 240 240" fill="none">
<defs>
<linearGradient id="tg" x1="120" y1="0" x2="120" y2="240" gradientUnits="userSpaceOnUse">
<stop stop-color="#2AABEE" />
<stop offset="1" stop-color="#229ED9" />
</linearGradient>
</defs>
<circle cx="120" cy="120" r="120" fill="url(#tg)" />
<path fill="#fff" d="M54 118.7c35-15.2 58.3-25.3 70-30.2 33.3-13.9 40.2-16.3 44.7-16.4 1 0 3.2.2 4.7 1.4.8.7 1.2 1.6 1.4 2.6.2 1 .4 3.2.2 4.9-1.8 19-9.7 65.1-13.7 86.4-1.7 9-5 12.1-8.2 12.4-7 .6-12.3-4.6-19-9-10.6-7-16.6-11.3-26.9-18.1-11.9-7.8-4.2-12.1 2.6-19.1 1.8-1.8 32.5-29.8 33.1-32.3.1-.3.1-1.5-.6-2.1-.7-.6-1.7-.4-2.5-.2-1.1.2-17.9 11.4-50.6 33.4-4.8 3.3-9.1 4.9-13 4.8-4.3-.1-12.5-2.4-18.6-4.4-7.5-2.4-13.5-3.7-13-7.9.3-2.2 3.3-4.4 9.1-6.6z" />
</svg>

After

Width:  |  Height:  |  Size: 862 B

View file

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

View file

@ -0,0 +1,225 @@
<script setup lang="ts">
import { router, useHttp } from '@inertiajs/vue3';
import { IconCheck, IconCopy, IconLoader2 } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { onUnmounted, ref, watch } from 'vue';
import { toast } from 'vue-sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { copyToClipboard } from '@/lib/utils';
import {
connect as connectTelegram,
status as telegramStatus,
} from '@/routes/app/social/telegram';
const open = defineModel<boolean>('open', { required: true });
type Phase = 'loading' | 'ready' | 'connected' | 'expired' | 'error';
const phase = ref<Phase>('loading');
const code = ref('');
const botUsername = ref('');
const errorMessage = ref('');
const httpConnect = useHttp<
Record<string, never>,
{ code: string; bot_username: string; expires_at: string }
>({});
const httpStatus = useHttp<Record<string, never>, { status: string }>({});
let pollTimer: ReturnType<typeof setTimeout> | null = null;
const stopPolling = () => {
if (pollTimer !== null) {
clearTimeout(pollTimer);
pollTimer = null;
}
};
const poll = async () => {
if (phase.value !== 'ready') return;
try {
const response = await httpStatus.get(
telegramStatus.url({ query: { code: code.value } }),
);
if (response?.status === 'connected') {
phase.value = 'connected';
stopPolling();
toast.success(trans('accounts.telegram.connected_toast'));
setTimeout(() => {
open.value = false;
router.reload();
}, 1200);
return;
}
if (response?.status === 'expired' || response?.status === 'unknown') {
phase.value = 'expired';
stopPolling();
return;
}
} catch {
// Transient polling failures are ignored; the next tick retries.
}
pollTimer = setTimeout(poll, 3000);
};
const start = async () => {
phase.value = 'loading';
errorMessage.value = '';
try {
const response = await httpConnect.post(connectTelegram.url());
code.value = response.code;
botUsername.value = response.bot_username;
phase.value = 'ready';
poll();
} catch (error) {
phase.value = 'error';
errorMessage.value =
(error as { response?: { data?: { message?: string } } })?.response
?.data?.message ?? trans('accounts.telegram.error_generic');
}
};
const copyCommand = () => {
copyToClipboard(`/connect ${code.value}`);
toast.success(trans('accounts.telegram.copied_toast'));
};
watch(open, (isOpen) => {
if (isOpen) {
start();
} else {
stopPolling();
}
});
onUnmounted(stopPolling);
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<div class="flex items-center gap-3">
<img
src="/images/accounts/telegram.svg"
alt="Telegram"
class="size-10"
/>
<div class="text-left">
<DialogTitle>{{
$t('accounts.telegram.title')
}}</DialogTitle>
<DialogDescription>{{
$t('accounts.telegram.description')
}}</DialogDescription>
</div>
</div>
</DialogHeader>
<div
v-if="phase === 'loading'"
class="flex items-center justify-center py-10"
>
<IconLoader2
class="size-6 animate-spin text-muted-foreground"
/>
</div>
<div v-else-if="phase === 'error'" class="space-y-4 py-2">
<p class="text-sm text-destructive">{{ errorMessage }}</p>
<Button class="w-full" @click="start">{{
$t('accounts.telegram.retry')
}}</Button>
</div>
<div v-else-if="phase === 'expired'" class="space-y-4 py-2">
<p class="text-sm text-muted-foreground">
{{ $t('accounts.telegram.expired') }}
</p>
<Button class="w-full" @click="start">{{
$t('accounts.telegram.new_code')
}}</Button>
</div>
<div
v-else-if="phase === 'connected'"
class="flex flex-col items-center gap-3 py-8 text-center"
>
<span
class="inline-flex size-12 items-center justify-center rounded-full bg-emerald-100 text-emerald-600"
>
<IconCheck class="size-6" stroke-width="3" />
</span>
<p class="text-sm font-medium">
{{ $t('accounts.telegram.connected') }}
</p>
</div>
<div v-else class="space-y-5 py-2">
<ol class="space-y-4 text-sm">
<li class="flex gap-3">
<span
class="flex size-6 shrink-0 items-center justify-center rounded-full border-2 border-foreground text-xs font-semibold"
>1</span
>
<span
v-html="
trans('accounts.telegram.step_admin', {
bot: `@${botUsername}`,
})
"
/>
</li>
<li class="flex gap-3">
<span
class="flex size-6 shrink-0 items-center justify-center rounded-full border-2 border-foreground text-xs font-semibold"
>2</span
>
<div class="flex-1 space-y-2">
<span>{{
$t('accounts.telegram.step_command')
}}</span>
<button
type="button"
class="group flex w-full items-center justify-between gap-2 rounded-lg border bg-muted px-3 py-2 text-left font-mono text-sm transition-colors hover:bg-muted/70"
@click="copyCommand"
>
<span>/connect {{ code }}</span>
<IconCopy
class="size-4 shrink-0 text-muted-foreground group-hover:text-foreground"
/>
</button>
</div>
</li>
</ol>
<div
class="flex items-center gap-2 text-sm text-muted-foreground"
>
<IconLoader2 class="size-4 animate-spin" />
{{ $t('accounts.telegram.waiting') }}
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="open = false">{{
$t('accounts.telegram.close')
}}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>